mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-mode): add chat history with multi-backend persistence
Persist Agent Mode sessions to markdown files in the configured save folder using an `agent__` filename prefix and `mode: agent` / `backendId: <id>` frontmatter. Empty sessions never persist. Add the same History button (and popover UI) to `AgentChatControls` that legacy Copilot Plus mode uses, wired to a new `AgentChatPersistenceManager` and plugin methods on `main.ts`. The history list is shared across all registered backends — backendId is stored in frontmatter for routing, not surfaced in the row. Loading a saved chat switches to the matching live tab if one exists; otherwise spawns a new session on the saved backend and seeds its message store. Subsequent turns update the same file. Update the legacy `getChatHistoryFiles` filter to exclude `agent__` files so the two history lists never overlap. https://claude.ai/code/session_01CCLHubxusHSDTcbc5aWtH6
This commit is contained in:
parent
fe25ed9e16
commit
a1d49f4b2e
10 changed files with 1113 additions and 18 deletions
|
|
@ -4,6 +4,7 @@ import { logError } from "@/logger";
|
|||
import { getSettings } from "@/settings/model";
|
||||
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
|
||||
import { AgentModelPreloader } from "./session/AgentModelPreloader";
|
||||
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
|
||||
import { AgentSessionManager } from "./session/AgentSessionManager";
|
||||
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ export {
|
|||
export { useAgentModelPicker } from "./ui/useAgentModelPicker";
|
||||
export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker";
|
||||
export type { AgentSessionManager } from "./session/AgentSessionManager";
|
||||
export { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
|
||||
export { AGENT_CHAT_FILE_PREFIX } from "./session/AgentChatPersistenceManager";
|
||||
export type { BackendDescriptor, BackendId, InstallState } from "./session/types";
|
||||
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
|
||||
|
||||
|
|
@ -33,9 +36,11 @@ export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/r
|
|||
* the existing manager and call this again.
|
||||
*/
|
||||
export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager {
|
||||
const persistence = new AgentChatPersistenceManager(app);
|
||||
const manager = new AgentSessionManager(app, plugin, {
|
||||
permissionPrompter: createDefaultPermissionPrompter(app),
|
||||
resolveDescriptor: (id) => backendRegistry[id],
|
||||
persistence,
|
||||
});
|
||||
// Non-blocking — plugin load should not wait on disk reconcile.
|
||||
for (const descriptor of listBackendDescriptors()) {
|
||||
|
|
|
|||
310
src/agentMode/session/AgentChatPersistenceManager.test.ts
Normal file
310
src/agentMode/session/AgentChatPersistenceManager.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { TFile, TFolder } from "obsidian";
|
||||
import { AGENT_CHAT_FILE_PREFIX, AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
|
||||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import type { AgentChatMessage } from "./types";
|
||||
|
||||
const SAVE_FOLDER = "copilot-conversations";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logInfo: jest.fn(),
|
||||
logWarn: jest.fn(),
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => ({
|
||||
defaultSaveFolder: "copilot-conversations",
|
||||
defaultConversationTag: "conversation",
|
||||
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual("@/utils");
|
||||
return {
|
||||
...actual,
|
||||
ensureFolderExists: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeFile {
|
||||
path: string;
|
||||
basename: string;
|
||||
body: string;
|
||||
frontmatter: Record<string, any>;
|
||||
ctime: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory `App` shim that captures vault writes and lets the test
|
||||
* inspect what landed on "disk". Keeps the test free of jest mocks for every
|
||||
* vault method by routing the persistence manager's calls through a small
|
||||
* map.
|
||||
*/
|
||||
function toTFile(f: FakeFile): TFile {
|
||||
// The obsidian mock's TFile is a constructable function — `instanceof TFile`
|
||||
// checks downstream rely on this prototype chain.
|
||||
const tfile = new (TFile as any)(f.path);
|
||||
tfile.stat = { ctime: f.ctime, mtime: f.ctime, size: f.body.length };
|
||||
return tfile;
|
||||
}
|
||||
|
||||
function makeApp(initialFiles: FakeFile[] = []) {
|
||||
const files = new Map<string, FakeFile>();
|
||||
for (const f of initialFiles) files.set(f.path, f);
|
||||
|
||||
const folder = new (TFolder as any)(SAVE_FOLDER);
|
||||
|
||||
const vault = {
|
||||
getAbstractFileByPath: (path: string) => {
|
||||
if (path === SAVE_FOLDER) return folder;
|
||||
const f = files.get(path);
|
||||
return f ? toTFile(f) : null;
|
||||
},
|
||||
create: jest.fn(async (path: string, content: string) => {
|
||||
if (files.has(path)) {
|
||||
const err = new Error("File already exists.");
|
||||
throw err;
|
||||
}
|
||||
const basename = path.split("/").pop()!.replace(/\.md$/, "");
|
||||
const file: FakeFile = { path, basename, body: content, frontmatter: {}, ctime: Date.now() };
|
||||
files.set(path, file);
|
||||
return toTFile(file);
|
||||
}),
|
||||
modify: jest.fn(async (file: TFile, content: string) => {
|
||||
const f = files.get(file.path);
|
||||
if (f) f.body = content;
|
||||
}),
|
||||
read: jest.fn(async (file: TFile) => files.get(file.path)?.body ?? ""),
|
||||
delete: jest.fn(async (file: TFile) => {
|
||||
files.delete(file.path);
|
||||
}),
|
||||
getMarkdownFiles: () => Array.from(files.values()).map(toTFile),
|
||||
adapter: {
|
||||
exists: jest.fn(async (path: string) => path === SAVE_FOLDER || files.has(path)),
|
||||
read: jest.fn(async (path: string) => files.get(path)?.body ?? ""),
|
||||
write: jest.fn(async (path: string, content: string) => {
|
||||
const f = files.get(path);
|
||||
if (f) f.body = content;
|
||||
}),
|
||||
remove: jest.fn(async (path: string) => {
|
||||
files.delete(path);
|
||||
}),
|
||||
list: jest.fn(async (path: string) => ({
|
||||
files:
|
||||
path === SAVE_FOLDER
|
||||
? Array.from(files.keys()).filter((p) => p.startsWith(`${SAVE_FOLDER}/`))
|
||||
: [],
|
||||
folders: [],
|
||||
})),
|
||||
stat: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const metadataCache = {
|
||||
getFileCache: (file: TFile) => {
|
||||
const f = files.get(file.path);
|
||||
return f ? { frontmatter: f.frontmatter } : null;
|
||||
},
|
||||
};
|
||||
|
||||
const fileManager = {
|
||||
processFrontMatter: jest.fn(async (file: TFile, mutator: (fm: any) => void) => {
|
||||
const f = files.get(file.path);
|
||||
if (!f) return;
|
||||
mutator(f.frontmatter);
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
app: { vault, metadataCache, fileManager } as any,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessages(): AgentChatMessage[] {
|
||||
return [
|
||||
{
|
||||
id: "u1",
|
||||
sender: USER_SENDER,
|
||||
message: "Hello agent",
|
||||
isVisible: true,
|
||||
timestamp: {
|
||||
epoch: 1700000000000,
|
||||
display: "2023/11/14 22:13:20",
|
||||
fileName: "20231114_221320",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a1",
|
||||
sender: AI_SENDER,
|
||||
message: "Hi there!",
|
||||
isVisible: true,
|
||||
timestamp: {
|
||||
epoch: 1700000005000,
|
||||
display: "2023/11/14 22:13:25",
|
||||
fileName: "20231114_221325",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("AgentChatPersistenceManager", () => {
|
||||
it("save() returns null when there are no messages", async () => {
|
||||
const { app } = makeApp();
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
const result = await mgr.save({ messages: [], backendId: "opencode" });
|
||||
expect(result).toBeNull();
|
||||
expect(app.vault.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("save() creates a file with the agent__ prefix and mode/backendId frontmatter", async () => {
|
||||
const { app, files } = makeApp();
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
await mgr.save({ messages: makeMessages(), backendId: "opencode", label: "My session" });
|
||||
|
||||
const created = Array.from(files.values()).find((f) =>
|
||||
f.basename.startsWith(AGENT_CHAT_FILE_PREFIX)
|
||||
);
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.body).toMatch(/mode: agent/);
|
||||
expect(created!.body).toMatch(/backendId: opencode/);
|
||||
expect(created!.body).toMatch(/agentLabel: "My session"/);
|
||||
expect(created!.body).toMatch(/\*\*user\*\*: Hello agent/);
|
||||
expect(created!.body).toMatch(/\*\*ai\*\*: Hi there!/);
|
||||
});
|
||||
|
||||
it("save() updates the existing file in place when given its path", async () => {
|
||||
const { app, files } = makeApp();
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
const first = await mgr.save({ messages: makeMessages(), backendId: "opencode" });
|
||||
expect(first).not.toBeNull();
|
||||
const path = first!.path;
|
||||
const fm = files.get(path)!.frontmatter;
|
||||
fm.epoch = 1700000000000;
|
||||
|
||||
const more = [
|
||||
...makeMessages(),
|
||||
{
|
||||
id: "u2",
|
||||
sender: USER_SENDER,
|
||||
message: "Another turn",
|
||||
isVisible: true,
|
||||
timestamp: {
|
||||
epoch: 1700000010000,
|
||||
display: "2023/11/14 22:13:30",
|
||||
fileName: "20231114_221330",
|
||||
},
|
||||
},
|
||||
] as AgentChatMessage[];
|
||||
|
||||
await mgr.save({ messages: more, backendId: "opencode" }, path);
|
||||
expect(app.vault.create).toHaveBeenCalledTimes(1); // no new file
|
||||
expect(files.get(path)!.body).toMatch(/Another turn/);
|
||||
});
|
||||
|
||||
it("listFiles() filters to agent__ prefix only", async () => {
|
||||
const { app } = makeApp([
|
||||
{
|
||||
path: "copilot-conversations/agent__abc.md",
|
||||
basename: "agent__abc",
|
||||
body: "",
|
||||
frontmatter: {},
|
||||
ctime: 0,
|
||||
},
|
||||
{
|
||||
path: "copilot-conversations/regular_chat.md",
|
||||
basename: "regular_chat",
|
||||
body: "",
|
||||
frontmatter: {},
|
||||
ctime: 0,
|
||||
},
|
||||
{
|
||||
path: "copilot-conversations/projectId__chat.md",
|
||||
basename: "projectId__chat",
|
||||
body: "",
|
||||
frontmatter: {},
|
||||
ctime: 0,
|
||||
},
|
||||
]);
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
const result = await mgr.listFiles();
|
||||
expect(result.map((f) => f.basename)).toEqual(["agent__abc"]);
|
||||
});
|
||||
|
||||
it("loadFile() round-trips a saved chat", async () => {
|
||||
const { app, files } = makeApp();
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
const file = await mgr.save({ messages: makeMessages(), backendId: "claude-code" });
|
||||
expect(file).not.toBeNull();
|
||||
|
||||
// Mirror the frontmatter the metadataCache would have picked up after save.
|
||||
const stored = files.get(file!.path)!;
|
||||
stored.frontmatter = parseFrontmatter(stored.body);
|
||||
|
||||
const loaded = await mgr.loadFile(file!);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.backendId).toBe("claude-code");
|
||||
expect(loaded!.messages).toHaveLength(2);
|
||||
expect(loaded!.messages[0].sender).toBe(USER_SENDER);
|
||||
expect(loaded!.messages[0].message).toBe("Hello agent");
|
||||
expect(loaded!.messages[1].sender).toBe(AI_SENDER);
|
||||
expect(loaded!.messages[1].message).toBe("Hi there!");
|
||||
});
|
||||
|
||||
it("loadFile() rejects files whose mode is not 'agent'", async () => {
|
||||
const file: FakeFile = {
|
||||
path: "copilot-conversations/agent__legacy.md",
|
||||
basename: "agent__legacy",
|
||||
body: `---\nmode: copilot\n---\n\n**user**: not for agent`,
|
||||
frontmatter: { mode: "copilot" },
|
||||
ctime: 0,
|
||||
};
|
||||
const { app } = makeApp([file]);
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
const tFile = app.vault.getAbstractFileByPath(file.path);
|
||||
const loaded = await mgr.loadFile(tFile);
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it("updateTopic() patches frontmatter via fileManager", async () => {
|
||||
const file: FakeFile = {
|
||||
path: "copilot-conversations/agent__abc.md",
|
||||
basename: "agent__abc",
|
||||
body: `---\nmode: agent\nbackendId: opencode\n---\n\n**user**: hi`,
|
||||
frontmatter: { mode: "agent", backendId: "opencode" },
|
||||
ctime: 0,
|
||||
};
|
||||
const { app, files } = makeApp([file]);
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
await mgr.updateTopic(file.path, "Renamed");
|
||||
expect(files.get(file.path)!.frontmatter.topic).toBe("Renamed");
|
||||
});
|
||||
|
||||
it("deleteFile() removes the file from the vault", async () => {
|
||||
const file: FakeFile = {
|
||||
path: "copilot-conversations/agent__abc.md",
|
||||
basename: "agent__abc",
|
||||
body: "",
|
||||
frontmatter: {},
|
||||
ctime: 0,
|
||||
};
|
||||
const { app, files } = makeApp([file]);
|
||||
const mgr = new AgentChatPersistenceManager(app);
|
||||
await mgr.deleteFile(file.path);
|
||||
expect(files.has(file.path)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Tiny YAML extractor — just enough to feed the round-trip test. */
|
||||
function parseFrontmatter(body: string): Record<string, any> {
|
||||
const match = body.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
const result: Record<string, any> = {};
|
||||
for (const line of match[1].split("\n")) {
|
||||
const m = line.match(/^(\w+):\s*(.+)/);
|
||||
if (m) result[m[1]] = m[2].replace(/^"|"$/g, "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
440
src/agentMode/session/AgentChatPersistenceManager.ts
Normal file
440
src/agentMode/session/AgentChatPersistenceManager.ts
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { logError, logInfo } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import {
|
||||
ensureFolderExists,
|
||||
formatDateTime,
|
||||
getUtf8ByteLength,
|
||||
truncateToByteLimit,
|
||||
} from "@/utils";
|
||||
import {
|
||||
isInVaultCache,
|
||||
listMarkdownFiles,
|
||||
patchFrontmatter,
|
||||
readFrontmatterViaAdapter,
|
||||
} from "@/utils/vaultAdapterUtils";
|
||||
import { App, Notice, TFile } from "obsidian";
|
||||
import type { AgentChatMessage, NewAgentChatMessage } from "./types";
|
||||
import type { BackendId } from "@/agentMode/acp/types";
|
||||
|
||||
const SAFE_FILENAME_BYTE_LIMIT = 100;
|
||||
|
||||
/** Filename prefix that marks a markdown file as an Agent Mode chat. */
|
||||
export const AGENT_CHAT_FILE_PREFIX = "agent__";
|
||||
|
||||
/** Frontmatter discriminator value for `mode`. */
|
||||
const AGENT_MODE_FRONTMATTER_VALUE = "agent";
|
||||
|
||||
function escapeYamlString(str: string): string {
|
||||
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/** Parsed payload returned by `loadFile` — matches what `AgentSessionManager` needs to seed a new session. */
|
||||
export interface LoadedAgentChat {
|
||||
/** Parsed messages without ids — the consuming `AgentMessageStore` assigns ids on load. */
|
||||
messages: NewAgentChatMessage[];
|
||||
backendId: BackendId | null;
|
||||
topic?: string;
|
||||
label?: string;
|
||||
modelKey?: string;
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
/** Snapshot the persistence manager needs to write a session to disk. */
|
||||
export interface AgentChatSaveSnapshot {
|
||||
/** Visible messages in display order. Persistence is skipped when this is empty. */
|
||||
messages: AgentChatMessage[];
|
||||
backendId: BackendId;
|
||||
/** Optional sticky tab label set by the user or pushed by the agent. */
|
||||
label?: string | null;
|
||||
/** Optional agent-native model id, recorded for debugging — not used on load. */
|
||||
modelKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists Agent Mode chat sessions to markdown files in the configured save
|
||||
* folder. Files are tagged with an `agent__` filename prefix and a `mode: agent`
|
||||
* frontmatter discriminator so they can be filtered out of the legacy chat
|
||||
* history list.
|
||||
*
|
||||
* Designed to be backend-agnostic: the `backendId` is recorded in frontmatter
|
||||
* for routing on load, but listing is unified across all registered backends.
|
||||
*/
|
||||
export class AgentChatPersistenceManager {
|
||||
constructor(private readonly app: App) {}
|
||||
|
||||
/**
|
||||
* Save a session snapshot to disk. Returns the resulting `TFile` (or `null`
|
||||
* when the snapshot has no visible messages — empty sessions never persist).
|
||||
*
|
||||
* If `existingPath` points to a previously written file, that file is
|
||||
* updated in place. Otherwise a fresh filename is generated based on the
|
||||
* snapshot's first user message.
|
||||
*/
|
||||
async save(snapshot: AgentChatSaveSnapshot, existingPath?: string): Promise<TFile | null> {
|
||||
if (snapshot.messages.length === 0) return null;
|
||||
|
||||
try {
|
||||
const settings = getSettings();
|
||||
await ensureFolderExists(settings.defaultSaveFolder);
|
||||
|
||||
const firstEpoch = this.firstMessageEpoch(snapshot.messages);
|
||||
const existingFile = existingPath ? this.app.vault.getAbstractFileByPath(existingPath) : null;
|
||||
const existingTopic = await this.readExistingTopic(existingPath, existingFile);
|
||||
const existingLastAccessedAt = await this.readExistingLastAccessedAt(
|
||||
existingPath,
|
||||
existingFile
|
||||
);
|
||||
|
||||
const targetPath =
|
||||
existingFile instanceof TFile
|
||||
? existingFile.path
|
||||
: existingPath && (await this.app.vault.adapter.exists(existingPath))
|
||||
? existingPath
|
||||
: this.generateFileName(snapshot.messages, firstEpoch, existingTopic);
|
||||
|
||||
const noteContent = this.generateNoteContent(snapshot, firstEpoch, {
|
||||
topic: existingTopic,
|
||||
lastAccessedAt: existingLastAccessedAt,
|
||||
});
|
||||
|
||||
if (existingFile instanceof TFile && isInVaultCache(this.app, existingFile.path)) {
|
||||
await this.app.vault.modify(existingFile, noteContent);
|
||||
return existingFile;
|
||||
}
|
||||
|
||||
if (
|
||||
!isInVaultCache(this.app, targetPath) &&
|
||||
(await this.app.vault.adapter.exists(targetPath))
|
||||
) {
|
||||
await this.app.vault.adapter.write(targetPath, noteContent);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await this.app.vault.create(targetPath, noteContent);
|
||||
return created;
|
||||
} catch (error) {
|
||||
if (this.isFileAlreadyExistsError(error)) {
|
||||
const existing = this.app.vault.getAbstractFileByPath(targetPath);
|
||||
if (existing instanceof TFile) {
|
||||
await this.app.vault.modify(existing, noteContent);
|
||||
return existing;
|
||||
}
|
||||
await this.app.vault.adapter.write(targetPath, noteContent);
|
||||
return null;
|
||||
}
|
||||
if (this.isNameTooLongError(error)) {
|
||||
const fallback = `${settings.defaultSaveFolder}/${AGENT_CHAT_FILE_PREFIX}chat-${firstEpoch}.md`;
|
||||
try {
|
||||
return await this.app.vault.create(fallback, noteContent);
|
||||
} catch (fallbackError) {
|
||||
if (this.isFileAlreadyExistsError(fallbackError)) {
|
||||
const existing = this.app.vault.getAbstractFileByPath(fallback);
|
||||
if (existing instanceof TFile) {
|
||||
await this.app.vault.modify(existing, noteContent);
|
||||
return existing;
|
||||
}
|
||||
await this.app.vault.adapter.write(fallback, noteContent);
|
||||
return null;
|
||||
}
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
logError("[AgentChatPersistenceManager] Error saving session:", error);
|
||||
new Notice("Failed to save agent chat. Check console for details.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a saved agent chat file and return the parsed payload. Returns `null`
|
||||
* when the file isn't an Agent Mode file (no `mode: agent` frontmatter).
|
||||
*/
|
||||
async loadFile(file: TFile): Promise<LoadedAgentChat | null> {
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await this.app.vault.read(file);
|
||||
} catch {
|
||||
content = await this.app.vault.adapter.read(file.path);
|
||||
}
|
||||
|
||||
const frontmatter = await this.readFrontmatter(file);
|
||||
if (frontmatter?.mode && frontmatter.mode !== AGENT_MODE_FRONTMATTER_VALUE) return null;
|
||||
|
||||
const messages = this.parseChatBody(content);
|
||||
const epoch = Number(frontmatter?.epoch);
|
||||
logInfo(`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path}`);
|
||||
return {
|
||||
messages,
|
||||
backendId: (frontmatter?.backendId as BackendId | undefined) ?? null,
|
||||
topic: frontmatter?.topic,
|
||||
label: frontmatter?.agentLabel,
|
||||
modelKey: frontmatter?.modelKey,
|
||||
epoch: Number.isFinite(epoch) && epoch > 0 ? epoch : file.stat.ctime,
|
||||
};
|
||||
} catch (error) {
|
||||
logError("[AgentChatPersistenceManager] Error loading file:", error);
|
||||
new Notice("Failed to load agent chat. Check console for details.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all agent chat files in the configured save folder, regardless of
|
||||
* which backend produced them. Filtering is filename-prefix based to avoid
|
||||
* reading every file's frontmatter just to build the list.
|
||||
*/
|
||||
async listFiles(): Promise<TFile[]> {
|
||||
const settings = getSettings();
|
||||
const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder);
|
||||
return files.filter((f) => f.basename.startsWith(AGENT_CHAT_FILE_PREFIX));
|
||||
}
|
||||
|
||||
private async readFrontmatter(file: TFile): Promise<Record<string, any> | null> {
|
||||
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
if (cached) return cached;
|
||||
try {
|
||||
return await readFrontmatterViaAdapter(this.app, file.path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readExistingTopic(
|
||||
path: string | undefined,
|
||||
file: ReturnType<App["vault"]["getAbstractFileByPath"]>
|
||||
): Promise<string | undefined> {
|
||||
if (!path) return undefined;
|
||||
if (file instanceof TFile) {
|
||||
const fm = await this.readFrontmatter(file);
|
||||
return fm?.topic;
|
||||
}
|
||||
try {
|
||||
const fm = await readFrontmatterViaAdapter(this.app, path);
|
||||
return fm?.topic;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async readExistingLastAccessedAt(
|
||||
path: string | undefined,
|
||||
file: ReturnType<App["vault"]["getAbstractFileByPath"]>
|
||||
): Promise<number | undefined> {
|
||||
if (!path) return undefined;
|
||||
if (file instanceof TFile) {
|
||||
const fm = await this.readFrontmatter(file);
|
||||
const value = Number(fm?.lastAccessedAt);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
try {
|
||||
const fm = await readFrontmatterViaAdapter(this.app, path);
|
||||
const value = Number(fm?.lastAccessedAt);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private firstMessageEpoch(messages: AgentChatMessage[]): number {
|
||||
for (const msg of messages) {
|
||||
if (msg.timestamp?.epoch) return msg.timestamp.epoch;
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
private generateNoteContent(
|
||||
snapshot: AgentChatSaveSnapshot,
|
||||
firstEpoch: number,
|
||||
preserved: { topic?: string; lastAccessedAt?: number }
|
||||
): string {
|
||||
const settings = getSettings();
|
||||
const topic = preserved.topic;
|
||||
const label = snapshot.label?.trim();
|
||||
const modelKey = snapshot.modelKey?.trim();
|
||||
const lastAccessedAt = preserved.lastAccessedAt;
|
||||
|
||||
const frontmatterLines = [
|
||||
`epoch: ${firstEpoch}`,
|
||||
`mode: ${AGENT_MODE_FRONTMATTER_VALUE}`,
|
||||
`backendId: ${snapshot.backendId}`,
|
||||
];
|
||||
if (topic) frontmatterLines.push(`topic: "${escapeYamlString(topic)}"`);
|
||||
if (label) frontmatterLines.push(`agentLabel: "${escapeYamlString(label)}"`);
|
||||
if (modelKey) frontmatterLines.push(`modelKey: "${escapeYamlString(modelKey)}"`);
|
||||
if (lastAccessedAt) frontmatterLines.push(`lastAccessedAt: ${lastAccessedAt}`);
|
||||
frontmatterLines.push("tags:");
|
||||
frontmatterLines.push(` - ${settings.defaultConversationTag}`);
|
||||
|
||||
const body = this.formatChatBody(snapshot.messages);
|
||||
return `---\n${frontmatterLines.join("\n")}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
private formatChatBody(messages: AgentChatMessage[]): string {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const timestamp = message.timestamp?.display ?? "Unknown time";
|
||||
const sender = message.sender;
|
||||
return `**${sender}**: ${message.message}\n[Timestamp: ${timestamp}]`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the markdown body into `AgentChatMessage` objects. Tool / plan /
|
||||
* thought parts are not restored because they're display-only and don't
|
||||
* survive a serialize/parse round-trip; the saved view is plain text.
|
||||
*/
|
||||
private parseChatBody(content: string): NewAgentChatMessage[] {
|
||||
const stripped = content.replace(/^---\n[\s\S]*?\n---/, "").trim();
|
||||
const messages: NewAgentChatMessage[] = [];
|
||||
const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(stripped)) !== null) {
|
||||
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
|
||||
const lines = match[2].trim().split("\n");
|
||||
let timestamp: string | undefined;
|
||||
let endIndex = lines.length;
|
||||
const last = lines[endIndex - 1];
|
||||
if (last?.startsWith("[Timestamp: ")) {
|
||||
const ts = last.match(/\[Timestamp: (.*?)\]/);
|
||||
if (ts) {
|
||||
timestamp = ts[1];
|
||||
endIndex--;
|
||||
}
|
||||
}
|
||||
const messageText = lines.slice(0, endIndex).join("\n").trim();
|
||||
const epoch = timestamp ? Date.parse(timestamp) : NaN;
|
||||
|
||||
messages.push({
|
||||
message: messageText,
|
||||
sender,
|
||||
isVisible: true,
|
||||
timestamp:
|
||||
timestamp && Number.isFinite(epoch) ? { epoch, display: timestamp, fileName: "" } : null,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh filename from the first user message. Mirrors the budget
|
||||
* logic in `ChatPersistenceManager.generateFileName` — same `defaultSaveFolder`,
|
||||
* same `defaultConversationNoteName` template, but with `agent__` prefix
|
||||
* instead of a project id prefix.
|
||||
*/
|
||||
private generateFileName(
|
||||
messages: AgentChatMessage[],
|
||||
firstEpoch: number,
|
||||
topic?: string
|
||||
): string {
|
||||
const settings = getSettings();
|
||||
const formatted = formatDateTime(new Date(firstEpoch));
|
||||
const [date, time] = formatted.fileName.split("_");
|
||||
|
||||
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
|
||||
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
|
||||
.trim() || "Untitled Chat"
|
||||
: "Untitled Chat";
|
||||
}
|
||||
|
||||
let template = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
|
||||
const filePrefix = AGENT_CHAT_FILE_PREFIX;
|
||||
const extensionBytes = getUtf8ByteLength(".md");
|
||||
const filePrefixBytes = getUtf8ByteLength(filePrefix);
|
||||
const overhead = template
|
||||
.replace("{$topic}", "")
|
||||
.replace("{$date}", date)
|
||||
.replace("{$time}", time);
|
||||
const overheadBytes = getUtf8ByteLength(overhead);
|
||||
const topicBudget = Math.max(
|
||||
20,
|
||||
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - overheadBytes
|
||||
);
|
||||
const truncatedTopic = truncateToByteLimit(topicForFilename.replace(/\s+/g, "_"), topicBudget);
|
||||
|
||||
template = template
|
||||
.replace("{$topic}", truncatedTopic)
|
||||
.replace("{$date}", date)
|
||||
.replace("{$time}", time);
|
||||
|
||||
const sanitized = template
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/[{}[\]]/g, "_")
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
|
||||
|
||||
const basename = `${filePrefix}${sanitized}.md`;
|
||||
if (getUtf8ByteLength(basename) > SAFE_FILENAME_BYTE_LIMIT) {
|
||||
const available = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
|
||||
const truncated = truncateToByteLimit(sanitized, available);
|
||||
return `${settings.defaultSaveFolder}/${filePrefix}${truncated}.md`;
|
||||
}
|
||||
return `${settings.defaultSaveFolder}/${basename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `topic` frontmatter field. Used by the inline rename in the
|
||||
* history popover. Falls back to the adapter when the file lives in a
|
||||
* hidden directory not indexed by Obsidian's metadata cache.
|
||||
*/
|
||||
async updateTopic(filePath: string, topic: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile && this.app.fileManager?.processFrontMatter) {
|
||||
await this.app.fileManager.processFrontMatter(file, (fm) => {
|
||||
fm.topic = topic;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await this.app.vault.adapter.exists(filePath)) {
|
||||
await patchFrontmatter(this.app, filePath, { topic: topic.trim() });
|
||||
return;
|
||||
}
|
||||
throw new Error("Agent chat file not found.");
|
||||
}
|
||||
|
||||
/** Delete a saved agent chat file. */
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.vault.delete(file);
|
||||
return;
|
||||
}
|
||||
if (await this.app.vault.adapter.exists(filePath)) {
|
||||
await this.app.vault.adapter.remove(filePath);
|
||||
return;
|
||||
}
|
||||
throw new Error("Agent chat file not found.");
|
||||
}
|
||||
|
||||
private isFileAlreadyExistsError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return msg.toLowerCase().includes("already exists");
|
||||
}
|
||||
|
||||
private isNameTooLongError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const normalized = msg.toLowerCase();
|
||||
return normalized.includes("enametoolong") || normalized.includes("name too long");
|
||||
}
|
||||
}
|
||||
|
|
@ -172,20 +172,10 @@ export class AgentMessageStore {
|
|||
return msg ? this.toAgentChatMessage(msg) : undefined;
|
||||
}
|
||||
|
||||
loadMessages(messages: AgentChatMessage[]): void {
|
||||
loadMessages(messages: NewAgentChatMessage[]): void {
|
||||
this.clear();
|
||||
for (const msg of messages) {
|
||||
this.messages.push({
|
||||
id: msg.id || this.generateId(),
|
||||
displayText: msg.message,
|
||||
sender: msg.sender,
|
||||
timestamp: msg.timestamp || formatDateTime(new Date()),
|
||||
context: msg.context,
|
||||
isVisible: msg.isVisible !== false,
|
||||
isErrorMessage: msg.isErrorMessage,
|
||||
content: msg.content,
|
||||
parts: msg.parts,
|
||||
});
|
||||
this.addMessage(msg);
|
||||
}
|
||||
logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,6 +318,17 @@ export class AgentSession {
|
|||
this.abortController?.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the session's message history with `messages` and notify
|
||||
* subscribers. Used by the history-load flow: the underlying ACP session is
|
||||
* fresh (no prior context), but the user-visible transcript is restored from
|
||||
* the saved file.
|
||||
*/
|
||||
loadHistoryMessages(messages: NewAgentChatMessage[]): void {
|
||||
this.store.loadMessages(messages);
|
||||
this.notifyMessages();
|
||||
}
|
||||
|
||||
/** Detach from the backend. Does not cancel — call `cancel()` first. */
|
||||
async dispose(): Promise<void> {
|
||||
this.unregisterSessionHandler?.();
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ function buildManager(): AgentSessionManager {
|
|||
{
|
||||
permissionPrompter: jest.fn(),
|
||||
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
|
||||
persistence: {
|
||||
save: jest.fn().mockResolvedValue(null),
|
||||
loadFile: jest.fn().mockResolvedValue(null),
|
||||
listFiles: jest.fn().mockResolvedValue([]),
|
||||
updateTopic: jest.fn().mockResolvedValue(undefined),
|
||||
deleteFile: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as ConstructorParameters<typeof AgentSessionManager>[2]["persistence"],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import type CopilotPlugin from "@/main";
|
||||
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
|
||||
import type { AgentChatPersistenceManager } from "@/agentMode/session/AgentChatPersistenceManager";
|
||||
import { getSettings, setSettings } from "@/settings/model";
|
||||
import { err2String } from "@/utils";
|
||||
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
|
||||
import { App, FileSystemAdapter, Platform } from "obsidian";
|
||||
import { App, FileSystemAdapter, Platform, TFile } from "obsidian";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
|
||||
import { AgentSession } from "./AgentSession";
|
||||
import type { AgentModelPreloader } from "./AgentModelPreloader";
|
||||
import type { BackendDescriptor, BackendId } from "./types";
|
||||
|
||||
/** Trailing-edge debounce window for persisting message changes to disk. */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
export type PermissionPrompter = (
|
||||
req: RequestPermissionRequest
|
||||
) => Promise<RequestPermissionResponse>;
|
||||
|
|
@ -22,6 +26,8 @@ export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefine
|
|||
export interface AgentSessionManagerOptions {
|
||||
permissionPrompter: PermissionPrompter;
|
||||
resolveDescriptor: DescriptorResolver;
|
||||
/** Persistence sink for session message changes. Required so the history list reflects live sessions. */
|
||||
persistence: AgentChatPersistenceManager;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -51,6 +57,12 @@ export class AgentSessionManager {
|
|||
private isStarting = false;
|
||||
private lastError: string | null = null;
|
||||
private preloader: AgentModelPreloader | null = null;
|
||||
/** File path each session is currently persisted to. Unset until first save lands. */
|
||||
private sessionFilePaths = new Map<string, string>();
|
||||
/** Pending debounced save handle per session. */
|
||||
private saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Per-session unsubscribe handles for the persistence listener. */
|
||||
private persistenceUnsubs = new Map<string, () => void>();
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
|
|
@ -128,6 +140,7 @@ export class AgentSessionManager {
|
|||
this.sessions.set(session.internalId, session);
|
||||
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
|
||||
this.activeSessionId = session.internalId;
|
||||
this.attachPersistence(session);
|
||||
// Clear lastError on success — but not eagerly at the start of every
|
||||
// create. Eager clearing would let a second concurrent create wipe the
|
||||
// first's error before the user (or a retry handler) has seen it.
|
||||
|
|
@ -181,6 +194,13 @@ export class AgentSessionManager {
|
|||
return this.preloader;
|
||||
}
|
||||
|
||||
/** The persistence manager handed in at construction time. Exposed so the
|
||||
* plugin host can call list/delete/rename without deep-importing the
|
||||
* session layer. */
|
||||
getPersistenceManager(): AgentChatPersistenceManager {
|
||||
return this.opts.persistence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any in-flight turn, dispose the session, and remove it from the
|
||||
* pool. If the closed session was active, picks the right neighbor (or the
|
||||
|
|
@ -204,6 +224,7 @@ export class AgentSessionManager {
|
|||
} catch (e) {
|
||||
logWarn(`[AgentMode] dispose during closeSession failed`, e);
|
||||
}
|
||||
this.detachPersistence(id);
|
||||
this.sessions.delete(id);
|
||||
this.chatUIStates.delete(id);
|
||||
if (this.activeSessionId === id) {
|
||||
|
|
@ -321,6 +342,12 @@ export class AgentSessionManager {
|
|||
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
|
||||
);
|
||||
|
||||
// Flush pending debounced saves first so the last turn isn't lost when the
|
||||
// user closes Obsidian within the debounce window. Done before dispose so
|
||||
// the sessions can still report messages.
|
||||
await Promise.allSettled(
|
||||
Array.from(this.sessions.keys()).map((id) => this.flushPersistence(id))
|
||||
);
|
||||
const allSessions = Array.from(this.sessions.values());
|
||||
for (const session of allSessions) {
|
||||
try {
|
||||
|
|
@ -333,10 +360,12 @@ export class AgentSessionManager {
|
|||
} catch (e) {
|
||||
logError("[AgentMode] dispose during shutdown failed", e);
|
||||
}
|
||||
this.detachPersistence(session.internalId);
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.chatUIStates.clear();
|
||||
this.activeSessionId = null;
|
||||
this.sessionFilePaths.clear();
|
||||
|
||||
const allBackends = Array.from(this.backends.values());
|
||||
for (const proc of allBackends) {
|
||||
|
|
@ -379,6 +408,7 @@ export class AgentSessionManager {
|
|||
const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
|
||||
if (dead.length === 0) return;
|
||||
for (const s of dead) {
|
||||
this.detachPersistence(s.internalId);
|
||||
this.sessions.delete(s.internalId);
|
||||
this.chatUIStates.delete(s.internalId);
|
||||
s.cancel().catch(() => {});
|
||||
|
|
@ -405,4 +435,115 @@ export class AgentSessionManager {
|
|||
this.starting.delete(backendId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a session's message and label changes and trigger debounced
|
||||
* persistence on each notification. Sessions with zero visible messages are
|
||||
* skipped at save time, so empty tabs never produce a file.
|
||||
*/
|
||||
private attachPersistence(session: AgentSession): void {
|
||||
const id = session.internalId;
|
||||
const unsub = session.subscribe({
|
||||
onMessagesChanged: () => this.scheduleSave(id),
|
||||
onStatusChanged: () => {},
|
||||
onLabelChanged: () => this.scheduleSave(id),
|
||||
});
|
||||
this.persistenceUnsubs.set(id, unsub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any pending save and unsubscribe from the session's notifications.
|
||||
* Called when a session is closed, when its backend exits, and on shutdown.
|
||||
* Note: this does NOT delete the persisted file — that requires an explicit
|
||||
* delete via the history popover.
|
||||
*/
|
||||
private detachPersistence(id: string): void {
|
||||
const timer = this.saveTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.saveTimers.delete(id);
|
||||
}
|
||||
const unsub = this.persistenceUnsubs.get(id);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
this.persistenceUnsubs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleSave(id: string): void {
|
||||
const existing = this.saveTimers.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
this.saveTimers.delete(id);
|
||||
void this.runSave(id);
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
this.saveTimers.set(id, timer);
|
||||
}
|
||||
|
||||
/** Cancel the debounced timer (if any) and run the save synchronously. */
|
||||
private async flushPersistence(id: string): Promise<void> {
|
||||
const timer = this.saveTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.saveTimers.delete(id);
|
||||
}
|
||||
await this.runSave(id);
|
||||
}
|
||||
|
||||
private async runSave(id: string): Promise<void> {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) return;
|
||||
const messages = session.store.getDisplayMessages();
|
||||
if (messages.length === 0) return;
|
||||
const existingPath = this.sessionFilePaths.get(id);
|
||||
try {
|
||||
const file = await this.opts.persistence.save(
|
||||
{
|
||||
messages,
|
||||
backendId: session.backendId,
|
||||
label: session.getLabel(),
|
||||
modelKey: session.getModelState()?.currentModelId ?? null,
|
||||
},
|
||||
existingPath
|
||||
);
|
||||
if (file) this.sessionFilePaths.set(id, file.path);
|
||||
} catch (e) {
|
||||
logWarn(`[AgentMode] persistence save failed for session ${id}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved agent chat into a session. If a live session is already bound
|
||||
* to this file (e.g. the user clicked an item that's already an open tab),
|
||||
* just activate that tab. Otherwise spawn a new session on the file's
|
||||
* backend, seed its message store from the file, and bind the new session
|
||||
* to the same path so subsequent saves update the same file.
|
||||
*/
|
||||
async loadSessionFromHistory(file: TFile): Promise<AgentSession | null> {
|
||||
if (this.disposed) {
|
||||
throw new Error("AgentSessionManager has been shut down");
|
||||
}
|
||||
|
||||
// Switch if already open.
|
||||
for (const [id, path] of this.sessionFilePaths) {
|
||||
if (path === file.path) {
|
||||
this.setActiveSession(id);
|
||||
return this.sessions.get(id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await this.opts.persistence.loadFile(file);
|
||||
if (!loaded) {
|
||||
throw new Error(`File "${file.path}" is not a saved agent chat.`);
|
||||
}
|
||||
const backendId = loaded.backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
|
||||
const session = await this.createSession(backendId);
|
||||
// Bind the persistence path BEFORE seeding messages so the debounced save
|
||||
// triggered by `loadHistoryMessages` updates the existing file instead of
|
||||
// generating a fresh filename.
|
||||
this.sessionFilePaths.set(session.internalId, file.path);
|
||||
if (loaded.label) session.setLabel(loaded.label);
|
||||
session.loadHistoryMessages(loaded.messages);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import AgentChatMessages from "@/agentMode/ui/AgentChatMessages";
|
|||
import { AgentModeStatus } from "@/agentMode/ui/AgentModeStatus";
|
||||
import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
|
||||
import { useAgentModelPicker } from "@/agentMode/ui/useAgentModelPicker";
|
||||
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
|
||||
import ChatInput from "@/components/chat-components/ChatInput";
|
||||
import { EVENT_NAMES } from "@/constants";
|
||||
import { AppContext, EventTargetContext } from "@/context";
|
||||
|
|
@ -47,6 +48,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
const [includeActiveNote, setIncludeActiveNote] = useState(false);
|
||||
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -148,6 +150,68 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
setContextNotes([]);
|
||||
}, [backend, handleStopGenerating]);
|
||||
|
||||
const handleLoadChatHistory = useCallback(async () => {
|
||||
try {
|
||||
const items = await plugin.getAgentChatHistoryItems();
|
||||
setChatHistoryItems(items);
|
||||
} catch (error) {
|
||||
logError("[AgentMode] failed to load chat history", error);
|
||||
new Notice("Failed to load agent chat history.");
|
||||
}
|
||||
}, [plugin]);
|
||||
|
||||
const handleLoadChat = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await plugin.loadAgentChatById(id);
|
||||
} catch (error) {
|
||||
logError("[AgentMode] failed to load chat", error);
|
||||
new Notice("Failed to load agent chat.");
|
||||
}
|
||||
},
|
||||
[plugin]
|
||||
);
|
||||
|
||||
const handleUpdateChatTitle = useCallback(
|
||||
async (id: string, newTitle: string) => {
|
||||
try {
|
||||
await plugin.updateAgentChatTitle(id, newTitle);
|
||||
setChatHistoryItems((prev) =>
|
||||
prev.map((it) => (it.id === id ? { ...it, title: newTitle } : it))
|
||||
);
|
||||
} catch (error) {
|
||||
logError("[AgentMode] failed to rename chat", error);
|
||||
new Notice("Failed to rename agent chat.");
|
||||
}
|
||||
},
|
||||
[plugin]
|
||||
);
|
||||
|
||||
const handleDeleteChat = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await plugin.deleteAgentChatHistory(id);
|
||||
setChatHistoryItems((prev) => prev.filter((it) => it.id !== id));
|
||||
} catch (error) {
|
||||
logError("[AgentMode] failed to delete chat", error);
|
||||
new Notice("Failed to delete agent chat.");
|
||||
}
|
||||
},
|
||||
[plugin]
|
||||
);
|
||||
|
||||
const handleOpenSourceFile = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await plugin.openAgentChatSourceFile(id);
|
||||
} catch (error) {
|
||||
logError("[AgentMode] failed to open chat source file", error);
|
||||
new Notice("Failed to open chat source file.");
|
||||
}
|
||||
},
|
||||
[plugin]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (messageId: string) => {
|
||||
try {
|
||||
|
|
@ -191,7 +255,15 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
|
|||
<div className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
|
||||
<AgentModeStatus manager={manager} onInstallClick={handleInstall} />
|
||||
<AgentChatMessages messages={messages} app={app} onDelete={handleDelete} />
|
||||
<AgentChatControls onNewChat={handleNewChat} />
|
||||
<AgentChatControls
|
||||
onNewChat={handleNewChat}
|
||||
chatHistoryItems={chatHistoryItems}
|
||||
onLoadHistory={handleLoadChatHistory}
|
||||
onLoadChat={handleLoadChat}
|
||||
onUpdateChatTitle={handleUpdateChatTitle}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onOpenSourceFile={handleOpenSourceFile}
|
||||
/>
|
||||
<ChatInput
|
||||
inputMessage={inputMessage}
|
||||
setInputMessage={setInputMessage}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { useChainType } from "@/aiParams";
|
||||
import { ChainType } from "@/chainFactory";
|
||||
import {
|
||||
ChatHistoryItem,
|
||||
ChatHistoryPopover,
|
||||
} from "@/components/chat-components/ChatHistoryPopover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -10,6 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu
|
|||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
History,
|
||||
LibraryBig,
|
||||
MessageCirclePlus,
|
||||
Sparkles,
|
||||
|
|
@ -22,6 +27,16 @@ interface AgentChatControlsProps {
|
|||
* button is hidden — clicking it would be a no-op since there's nothing to
|
||||
* clear. */
|
||||
onNewChat?: () => void;
|
||||
/** Items currently loaded into the history popover. Empty until the user
|
||||
* opens the popover and `onLoadHistory` populates it. */
|
||||
chatHistoryItems?: ChatHistoryItem[];
|
||||
/** Triggered when the user clicks the History button — should populate
|
||||
* `chatHistoryItems`. Omit to hide the button entirely. */
|
||||
onLoadHistory?: () => void;
|
||||
onLoadChat?: (id: string) => Promise<void>;
|
||||
onUpdateChatTitle?: (id: string, newTitle: string) => Promise<void>;
|
||||
onDeleteChat?: (id: string) => Promise<void>;
|
||||
onOpenSourceFile?: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -31,10 +46,19 @@ interface AgentChatControlsProps {
|
|||
* Agent Mode does not honor those (ACP owns model/conversation state, no
|
||||
* persistence yet).
|
||||
*/
|
||||
export const AgentChatControls: React.FC<AgentChatControlsProps> = ({ onNewChat }) => {
|
||||
export const AgentChatControls: React.FC<AgentChatControlsProps> = ({
|
||||
onNewChat,
|
||||
chatHistoryItems,
|
||||
onLoadHistory,
|
||||
onLoadChat,
|
||||
onUpdateChatTitle,
|
||||
onDeleteChat,
|
||||
onOpenSourceFile,
|
||||
}) => {
|
||||
const settings = useSettingsValue();
|
||||
const [, setSelectedChain] = useChainType();
|
||||
const isPlusUser = useIsPlusUser();
|
||||
const showHistory = !!(onLoadHistory && onUpdateChatTitle && onDeleteChat);
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-w-full tw-items-center tw-justify-between tw-p-1">
|
||||
|
|
@ -103,6 +127,24 @@ export const AgentChatControls: React.FC<AgentChatControlsProps> = ({ onNewChat
|
|||
<TooltipContent>New Chat</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showHistory && (
|
||||
<Tooltip>
|
||||
<ChatHistoryPopover
|
||||
chatHistory={chatHistoryItems ?? []}
|
||||
onUpdateTitle={onUpdateChatTitle!}
|
||||
onDeleteChat={onDeleteChat!}
|
||||
onLoadChat={onLoadChat}
|
||||
onOpenSourceFile={onOpenSourceFile}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost2" size="icon" title="Chat History" onClick={onLoadHistory}>
|
||||
<History className="tw-size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</ChatHistoryPopover>
|
||||
<TooltipContent>Chat History</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
83
src/main.ts
83
src/main.ts
|
|
@ -1,4 +1,8 @@
|
|||
import { createAgentSessionManager, type AgentSessionManager } from "@/agentMode";
|
||||
import {
|
||||
AGENT_CHAT_FILE_PREFIX,
|
||||
createAgentSessionManager,
|
||||
type AgentSessionManager,
|
||||
} from "@/agentMode";
|
||||
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
|
||||
import ProjectManager from "@/LLMProviders/projectManager";
|
||||
import {
|
||||
|
|
@ -645,6 +649,12 @@ export default class CopilotPlugin extends Plugin {
|
|||
const folderFiles = await listMarkdownFiles(this.app, getSettings().defaultSaveFolder);
|
||||
if (folderFiles.length === 0) return [];
|
||||
|
||||
// Always exclude Agent Mode files — they have their own history list and
|
||||
// share the same save folder via the `agent__` filename prefix.
|
||||
const nonAgentFiles = folderFiles.filter(
|
||||
(file) => !file.basename.startsWith(AGENT_CHAT_FILE_PREFIX)
|
||||
);
|
||||
|
||||
// Get current project ID if in a project
|
||||
const currentProject = getCurrentProject();
|
||||
const currentProjectId = currentProject?.id;
|
||||
|
|
@ -652,11 +662,11 @@ export default class CopilotPlugin extends Plugin {
|
|||
if (currentProjectId) {
|
||||
// In project mode: return only files with this project's ID prefix
|
||||
const projectPrefix = `${currentProjectId}__`;
|
||||
return folderFiles.filter((file) => file.basename.startsWith(projectPrefix));
|
||||
return nonAgentFiles.filter((file) => file.basename.startsWith(projectPrefix));
|
||||
} else {
|
||||
// In non-project mode: return only files without any project ID prefix
|
||||
// This assumes project IDs always use the format projectId__ as prefix
|
||||
return folderFiles.filter((file) => {
|
||||
return nonAgentFiles.filter((file) => {
|
||||
// Check if the filename has any projectId__ prefix pattern
|
||||
return !file.basename.match(/^[a-zA-Z0-9-]+__/);
|
||||
});
|
||||
|
|
@ -845,6 +855,73 @@ export default class CopilotPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List Agent Mode chat history items across all backends. Empty sessions
|
||||
* never persist, so they never appear here. Backends share the same on-disk
|
||||
* folder via the `agent__` filename prefix.
|
||||
*/
|
||||
async getAgentChatHistoryItems(): Promise<ChatHistoryItem[]> {
|
||||
if (!this.agentSessionManager) return [];
|
||||
const persistence = this.agentSessionManager.getPersistenceManager();
|
||||
const files = await persistence.listFiles();
|
||||
return files.map((file) => {
|
||||
const createdAt = extractChatDate(file);
|
||||
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
|
||||
const effectiveLastAccessedAtMs =
|
||||
this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(
|
||||
file.path,
|
||||
persistedLastAccessedAtMs ?? createdAt.getTime()
|
||||
);
|
||||
return {
|
||||
id: file.path,
|
||||
title: extractChatTitle(file),
|
||||
createdAt,
|
||||
lastAccessedAt: new Date(effectiveLastAccessedAtMs),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async loadAgentChatById(fileId: string): Promise<void> {
|
||||
if (!this.agentSessionManager) {
|
||||
throw new Error("Agent Mode is not initialized.");
|
||||
}
|
||||
const file = await resolveFileByPath(this.app, fileId);
|
||||
if (!file) throw new Error("Agent chat file not found.");
|
||||
await this.agentSessionManager.loadSessionFromHistory(file);
|
||||
this.chatHistoryLastAccessedAtManager.touch(fileId);
|
||||
}
|
||||
|
||||
async openAgentChatSourceFile(fileId: string): Promise<void> {
|
||||
const file = this.app.vault.getAbstractFileByPath(fileId);
|
||||
if (file instanceof TFile) {
|
||||
await this.app.workspace.getLeaf(true).openFile(file);
|
||||
return;
|
||||
}
|
||||
if (await this.app.vault.adapter.exists(fileId)) {
|
||||
new Notice(
|
||||
"Cannot open source files from hidden directories. To open chat notes in the editor, save them to a non-hidden folder in settings."
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("Agent chat file not found.");
|
||||
}
|
||||
|
||||
async updateAgentChatTitle(fileId: string, newTitle: string): Promise<void> {
|
||||
if (!this.agentSessionManager) {
|
||||
throw new Error("Agent Mode is not initialized.");
|
||||
}
|
||||
await this.agentSessionManager.getPersistenceManager().updateTopic(fileId, newTitle);
|
||||
new Notice("Chat title updated.");
|
||||
}
|
||||
|
||||
async deleteAgentChatHistory(fileId: string): Promise<void> {
|
||||
if (!this.agentSessionManager) {
|
||||
throw new Error("Agent Mode is not initialized.");
|
||||
}
|
||||
await this.agentSessionManager.getPersistenceManager().deleteFile(fileId);
|
||||
new Notice("Chat deleted.");
|
||||
}
|
||||
|
||||
async handleNewChat() {
|
||||
clearRecordedPromptPayload();
|
||||
await logFileManager.clear();
|
||||
|
|
|
|||
Loading…
Reference in a new issue