diff --git a/package-lock.json b/package-lock.json index 4b6b7e9a..6b06c247 100644 --- a/package-lock.json +++ b/package-lock.json @@ -198,9 +198,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -214,9 +211,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -230,9 +224,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -246,9 +237,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -7439,9 +7427,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7456,9 +7441,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7473,9 +7455,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7490,9 +7469,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7507,9 +7483,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7524,9 +7497,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7541,9 +7511,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7558,9 +7525,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 7ca046d6..c250426f 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -9,6 +9,7 @@ import { backendRegistry, listBackendDescriptors } from "./backends/registry"; import type { BackendId } from "./session/types"; import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager"; import { AgentModelPreloader } from "./session/AgentModelPreloader"; +import { AgentSessionIndex } from "./session/AgentSessionIndex"; import { AgentSessionManager } from "./session/AgentSessionManager"; import { shouldUseMiyo } from "@/miyo/miyoUtils"; import { SkillManager } from "./skills"; @@ -143,6 +144,17 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen const skillManager = SkillManager.initialize(app, collectAgentSkillsDirsProjectRel()); const preloader = new AgentModelPreloader(app, plugin, (id) => backendRegistry[id]); const persistenceManager = new AgentChatPersistenceManager(app); + // Plugin-local (per-vault) record of resumable backend sessions, so recent + // chats can list and resume sessions that were never saved as markdown. + const vaultAdapter = app.vault.adapter; + const sessionIndex = new AgentSessionIndex( + { + exists: (p) => vaultAdapter.exists(p), + read: (p) => vaultAdapter.read(p), + write: (p, c) => vaultAdapter.write(p, c), + }, + `${app.vault.configDir}/plugins/${plugin.manifest.id}/agent-chat-index.json` + ); // Mutable ref breaks the construction cycle: the prompter needs the // manager, but handlers only fire after a session exists, which can't // happen before assignment below. @@ -159,6 +171,7 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen resolveDescriptor: (id) => backendRegistry[id], modelPreloader: preloader, persistenceManager, + sessionIndex, }); managerRef = manager; // Skill-set changes restart the affected backend when its descriptor diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index c022ac3a..14b91812 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -22,9 +22,14 @@ import { type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { App } from "obsidian"; +import { readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { v4 as uuidv4 } from "uuid"; import { translateBackendState } from "@/agentMode/session/translateBackendState"; +import { parseClaudeTranscript } from "./claudeSessionTranscript"; import type { + AgentChatMessage, BackendConfigOption, BackendDescriptor, BackendProcess, @@ -513,6 +518,54 @@ export class ClaudeSdkBackendProcess implements BackendProcess { throw new MethodUnsupportedError("session/list"); } + /** + * Rebuild a session's display transcript by reading the Claude CLI's on-disk + * record at `/projects//.jsonl`. The SDK + * exposes no transcript API and `resumeSession` returns no prior messages, + * so this is how a native (autosave-off) Claude chat shows its conversation + * when reopened from recent chats. Best-effort: a missing/GC'd file or a + * custom config dir we can't resolve degrades to an empty transcript (the + * session still resumes; only the visible scrollback is absent). + * + * `CLAUDE_CONFIG_DIR` is resolved with the SAME precedence the SDK is + * spawned with (`env overrides` > managed env > `process.env`), so a user + * who points Claude at a custom config dir via Agent Mode's env overrides + * still gets their transcript read from the right place. The project dir + * name is the cwd with every non-alphanumeric character replaced by `-`, + * matching the CLI's own encoding. + */ + async readPersistedTranscript(params: { + sessionId: SessionId; + cwd: string; + }): Promise { + try { + const configDir = (await this.resolveClaudeConfigDir()).trim(); + const projectDir = params.cwd.replace(/[^a-zA-Z0-9]/g, "-"); + const file = path.join(configDir, "projects", projectDir, `${params.sessionId}.jsonl`); + const text = await readFile(file, "utf8"); + return parseClaudeTranscript(text); + } catch (err) { + logWarn(`[AgentMode] could not read Claude transcript for ${params.sessionId}`, err); + return []; + } + } + + /** + * The Claude config dir the spawned SDK actually uses: env overrides win + * over managed env, which win over the ambient `process.env`, falling back + * to `~/.claude`. Mirrors the `options.env` layering in {@link prompt}. + */ + private async resolveClaudeConfigDir(): Promise { + const envOverrides = this.opts.getEnvOverrides?.() ?? {}; + const managedEnv = (await this.opts.getManagedEnv?.()) ?? {}; + return ( + envOverrides.CLAUDE_CONFIG_DIR || + managedEnv.CLAUDE_CONFIG_DIR || + process.env.CLAUDE_CONFIG_DIR || + path.join(os.homedir(), ".claude") + ); + } + /** * Rehydrate a previously-persisted SDK session. Registers a `SessionState` * entry keyed by `params.sessionId` with `firstPromptStarted: true` so the diff --git a/src/agentMode/sdk/claudeSessionTranscript.test.ts b/src/agentMode/sdk/claudeSessionTranscript.test.ts new file mode 100644 index 00000000..343c3c85 --- /dev/null +++ b/src/agentMode/sdk/claudeSessionTranscript.test.ts @@ -0,0 +1,91 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { parseClaudeTranscript } from "./claudeSessionTranscript"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +const TS = "2026-06-07T04:12:23.160Z"; + +function line(obj: Record): string { + return JSON.stringify(obj); +} + +describe("parseClaudeTranscript", () => { + it("keeps user prompts and assistant prose in order, skipping CLI noise", () => { + const jsonl = [ + line({ type: "queue-operation" }), + line({ type: "user", timestamp: TS, message: { role: "user", content: "first prompt" } }), + line({ type: "attachment", message: { role: null, content: null } }), + line({ type: "ai-title" }), + line({ + type: "assistant", + timestamp: TS, + message: { role: "assistant", content: [{ type: "text", text: "here is the answer" }] }, + }), + // pure tool-use assistant turn — no prose, skipped + line({ + type: "assistant", + message: { role: "assistant", content: [{ type: "tool_use", id: "t1" }] }, + }), + // tool_result comes back as a user record with array content — not user input + line({ + type: "user", + message: { role: "user", content: [{ type: "tool_result", content: "ok" }] }, + }), + line({ type: "user", timestamp: TS, message: { role: "user", content: "second prompt" } }), + ].join("\n"); + + const messages = parseClaudeTranscript(jsonl); + expect(messages.map((m) => [m.sender, m.message])).toEqual([ + [USER_SENDER, "first prompt"], + [AI_SENDER, "here is the answer"], + [USER_SENDER, "second prompt"], + ]); + expect(messages[0].timestamp?.epoch).toBe(Date.parse(TS)); + expect(messages.map((m) => m.id)).toEqual([ + "claude-loaded-0", + "claude-loaded-1", + "claude-loaded-2", + ]); + }); + + it("unwraps the envelope, dropping the context block", () => { + const wrapped = + "\nNotes:\n- a.md\n\n\n\nsummarize a.md\n"; + const jsonl = line({ type: "user", message: { role: "user", content: wrapped } }); + expect(parseClaudeTranscript(jsonl)[0].message).toBe("summarize a.md"); + }); + + it("concatenates multiple assistant text blocks and ignores tool_use between them", () => { + const jsonl = line({ + type: "assistant", + message: { + role: "assistant", + content: [ + { type: "text", text: "part one" }, + { type: "tool_use", id: "t1" }, + { type: "text", text: "part two" }, + ], + }, + }); + expect(parseClaudeTranscript(jsonl)[0].message).toBe("part one\n\npart two"); + }); + + it("skips meta, sidechain, summary records and unparseable lines", () => { + const jsonl = [ + line({ type: "user", isMeta: true, message: { role: "user", content: "meta" } }), + line({ + type: "assistant", + isSidechain: true, + message: { role: "assistant", content: [{ type: "text", text: "subagent" }] }, + }), + line({ type: "summary", summary: "a summary" }), + "{ not valid json", + "", + ].join("\n"); + expect(parseClaudeTranscript(jsonl)).toEqual([]); + }); +}); diff --git a/src/agentMode/sdk/claudeSessionTranscript.ts b/src/agentMode/sdk/claudeSessionTranscript.ts new file mode 100644 index 00000000..e04a6b9e --- /dev/null +++ b/src/agentMode/sdk/claudeSessionTranscript.ts @@ -0,0 +1,97 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import { formatDateTime } from "@/utils"; +import type { AgentChatMessage } from "@/agentMode/session/types"; + +/** + * Parse a Claude Code CLI session transcript (`.jsonl` under + * `~/.claude/projects//`) into display-only Agent Mode messages. + * + * The Claude Agent SDK has no session-list/transcript API, and `resumeSession` + * only re-feeds context to the model on the next turn — it returns no prior + * messages. So for a native (autosave-off) Claude chat, this on-disk transcript + * is the only way to rebuild the visible conversation when the user reopens it + * from recent chats. Mirrors the markdown loader: sender + text only, no tool + * calls / thoughts. + * + * Each line is one JSON record. We keep only genuine user prompts and assistant + * prose, skipping everything else the CLI logs: + * - `type: "user"` with a **string** content → a typed prompt. (Array content + * on a user record is a `tool_result`, not user input — skipped.) + * - `type: "assistant"` → concatenated `text` blocks (tool_use / thinking + * blocks dropped); skipped entirely when the turn was pure tool use. + * - `isMeta` / `isSidechain` records, summaries, attachments, queue ops, + * ai-title, system → skipped. + * + * Best-effort: unparseable lines are ignored rather than aborting the parse. + */ +export function parseClaudeTranscript(jsonlText: string): AgentChatMessage[] { + const messages: AgentChatMessage[] = []; + const lines = jsonlText.split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + let entry: ClaudeTranscriptEntry; + try { + entry = JSON.parse(line) as ClaudeTranscriptEntry; + } catch { + continue; + } + if (entry.isMeta === true || entry.isSidechain === true) continue; + const content = entry.message?.content; + + let sender: string | null = null; + let text = ""; + if (entry.type === "user" && typeof content === "string") { + sender = USER_SENDER; + text = stripUserMessageWrapper(content).trim(); + } else if (entry.type === "assistant" && Array.isArray(content)) { + sender = AI_SENDER; + text = content + .filter( + (b): b is { type: "text"; text: string } => + b?.type === "text" && typeof b.text === "string" + ) + .map((b) => b.text) + .join("\n\n") + .trim(); + } + if (!sender || !text) continue; + + messages.push({ + id: `claude-loaded-${messages.length}`, + sender, + message: text, + isVisible: true, + timestamp: toTimestamp(entry.timestamp), + }); + } + return messages; +} + +interface ClaudeTranscriptEntry { + type?: string; + isMeta?: boolean; + isSidechain?: boolean; + timestamp?: unknown; + message?: { + role?: string; + content?: string | Array<{ type?: string; text?: string }>; + }; +} + +/** + * Unwrap the plugin's `` envelope so the stored + * prompt (which prepends a `` block when notes are attached) + * displays as just what the user typed. Returns the input unchanged when no + * wrapper is present (prompts sent without attached context aren't wrapped). + */ +function stripUserMessageWrapper(content: string): string { + const match = content.match(/\n?([\s\S]*?)\n?<\/user-message>/); + return match ? match[1] : content; +} + +function toTimestamp(raw: unknown): AgentChatMessage["timestamp"] { + if (typeof raw !== "string") return null; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return null; + return formatDateTime(date); +} diff --git a/src/agentMode/session/AgentModelPreloader.ts b/src/agentMode/session/AgentModelPreloader.ts index 7216132d..d9ff3a80 100644 --- a/src/agentMode/session/AgentModelPreloader.ts +++ b/src/agentMode/session/AgentModelPreloader.ts @@ -140,6 +140,19 @@ export class AgentModelPreloader { return entry; } + /** + * Snapshot of the still-warm probe processes, for read-only RPC sweeps + * (the history surface's `listSessions`). Unlike {@link takeWarm} this + * does NOT consume the entries — the preloader keeps ownership, and the + * manager can still adopt the proc later. + */ + getWarmProcs(): Array<{ backendId: BackendId; proc: BackendProcess }> { + return Array.from(this.warm.entries(), ([backendId, entry]) => ({ + backendId, + proc: entry.proc, + })); + } + /** Best-effort probe; failures are logged and swallowed. Dedupes per backend. */ preload(backendId: BackendId): Promise { if (this.disposed) return Promise.resolve(); diff --git a/src/agentMode/session/AgentSession.test.ts b/src/agentMode/session/AgentSession.test.ts index 9100df5e..858b27d3 100644 --- a/src/agentMode/session/AgentSession.test.ts +++ b/src/agentMode/session/AgentSession.test.ts @@ -238,6 +238,76 @@ describe("buildPromptBlocks", () => { }); }); +describe("AgentSession.loadDisplayMessages", () => { + it("replaces the transcript and notifies subscribers so an open view re-renders", () => { + const mock = makeMockBackend(); + const session = new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + const onMessagesChanged = jest.fn(); + session.subscribe({ onMessagesChanged, onStatusChanged: () => {} }); + + session.loadDisplayMessages([ + { + id: "m0", + sender: USER_SENDER, + message: "earlier prompt", + isVisible: true, + timestamp: null, + }, + { id: "m1", sender: AI_SENDER, message: "earlier reply", isVisible: true, timestamp: null }, + ]); + + // The missing notification here was the bug: store.loadMessages alone left + // a freshly-activated tab blank until a tab switch forced a re-read. + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + expect(session.hasUserVisibleMessages()).toBe(true); + expect(session.store.getDisplayMessages().map((m) => m.message)).toEqual([ + "earlier prompt", + "earlier reply", + ]); + }); +}); + +describe("AgentSession.restoreLabel", () => { + function makeResumedSession(mock: ReturnType) { + return new AgentSession({ + backend: mock.asBackend, + backendSessionId: "acp-1", + internalId: "internal-1", + backendId: "opencode", + }); + } + + it("an agent-sourced restored title can still be refreshed by later agent updates", () => { + const mock = makeMockBackend(); + const session = makeResumedSession(mock); + session.restoreLabel("Discovered title", "agent"); + expect(session.getLabel()).toBe("Discovered title"); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Newer agent title" }, + }); + expect(session.getLabel()).toBe("Newer agent title"); + }); + + it("a user-sourced restored title is sticky against later agent updates", () => { + const mock = makeMockBackend(); + const session = makeResumedSession(mock); + session.restoreLabel("My rename", "user"); + + mock.emit({ + sessionId: "acp-1", + update: { sessionUpdate: "session_info_update", title: "Agent title" }, + }); + expect(session.getLabel()).toBe("My rename"); + }); +}); + describe("AgentSession.sendPrompt", () => { it("appends user + placeholder synchronously and resolves on stopReason", async () => { const mock = makeMockBackend(); diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts index 42b3f004..24e28e32 100644 --- a/src/agentMode/session/AgentSession.ts +++ b/src/agentMode/session/AgentSession.ts @@ -2,6 +2,7 @@ import { AI_SENDER, USER_SENDER, WEB_SELECTED_TEXT_TAG } from "@/constants"; import { logInfo, logWarn } from "@/logger"; import { AgentMessageStore } from "@/agentMode/session/AgentMessageStore"; import { + AgentChatMessage, AgentMessagePart, AgentQuestionAnswers, AgentToolCallOutput, @@ -44,8 +45,10 @@ import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing"; * Prefix opencode uses for placeholder titles before its title-summarizer * agent runs. Treating these as "no title" prevents the tab from briefly * showing "New session - 2026-…" before the LLM-generated label arrives. + * Exported so the manager's `listSessions` history sweep applies the same + * "placeholder is not a real title" rule. */ -const DEFAULT_TITLE_PREFIX = "New session"; +export const DEFAULT_TITLE_PREFIX = "New session"; /** * Memory backstop for tool output kept in long-lived React state — NOT a * display cap. At ~256KB it sits far above any realistic command/search/file @@ -624,6 +627,19 @@ export class AgentSession { return this.store.getDisplayMessages().length > 0; } + /** + * Replace the display transcript from persisted history (a markdown note or + * a backend's on-disk session store) and notify subscribers so an already- + * open chat view re-renders immediately. `store.loadMessages` alone mutates + * state without firing `onMessagesChanged`, so a freshly-activated tab would + * otherwise render blank until the next backend-identity change (e.g. the + * user switching tabs and back). + */ + loadDisplayMessages(messages: AgentChatMessage[]): void { + this.store.loadMessages(messages); + this.notifyMessages(); + } + /** * User-supplied label for this session (shown in the tab strip). `null` * means "no label" — the UI falls back to a positional default like @@ -633,6 +649,16 @@ export class AgentSession { return this.label; } + /** + * Who set the current label: `"user"` (Rename), `"agent"` + * (`session_info_update` / title poll), or `null` when unlabeled. The + * session index records this so a user rename survives native + * `listSessions` sweeps the same way it survives agent retitles here. + */ + getLabelSource(): "user" | "agent" | null { + return this.labelSource; + } + setLabel(label: string | null): void { const next = label?.trim() ? label.trim() : null; if (next === this.label) return; @@ -646,6 +672,18 @@ export class AgentSession { * No-op when the user has already renamed this session — Rename wins so * later agent-side title revisions don't blow away the user's choice. */ + /** + * Apply a label restored from persisted history with its original source. + * A user-renamed title is reapplied as a sticky user rename; an agent or + * derived title is applied agent-sourced, so a resumed opencode/codex + * session can still refresh its title from later `session_info_update` / + * title-poll updates instead of being frozen as if the user had renamed it. + */ + restoreLabel(label: string, source: "user" | "agent"): void { + if (source === "user") this.setLabel(label); + else this.applyAgentLabel(label); + } + private applyAgentLabel(label: string | null | undefined): void { if (this.labelSource === "user") return; const next = label?.trim() ? label.trim() : null; diff --git a/src/agentMode/session/AgentSessionIndex.test.ts b/src/agentMode/session/AgentSessionIndex.test.ts new file mode 100644 index 00000000..4f1c41ea --- /dev/null +++ b/src/agentMode/session/AgentSessionIndex.test.ts @@ -0,0 +1,191 @@ +import { buildNativeChatId, isNativeChatId, parseNativeChatId } from "@/utils/nativeChatId"; +import { AgentSessionIndex, type AgentSessionIndexStorage } from "./AgentSessionIndex"; + +jest.mock("@/logger", () => ({ + logInfo: jest.fn(), + logWarn: jest.fn(), + logError: jest.fn(), +})); + +const INDEX_PATH = "config/plugins/copilot/agent-chat-index.json"; + +function makeStorage(initial?: Record): AgentSessionIndexStorage & { + files: Map; +} { + const files = new Map(Object.entries(initial ?? {})); + return { + files, + exists: async (p) => files.has(p), + read: async (p) => { + const content = files.get(p); + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }, + write: async (p, c) => { + files.set(p, c); + }, + }; +} + +function entry(overrides: Partial[0]> = {}) { + return { + backendId: "opencode", + sessionId: "s1", + title: "Refactor the daily template", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + ...overrides, + }; +} + +describe("native chat id helpers", () => { + it("round-trips backendId and sessionId, including separators in the session id", () => { + const id = buildNativeChatId("codex", "abc/def:ghi"); + expect(isNativeChatId(id)).toBe(true); + expect(parseNativeChatId(id)).toEqual({ backendId: "codex", sessionId: "abc/def:ghi" }); + }); + + it("rejects non-native and malformed ids", () => { + expect(isNativeChatId("chats/agent__foo.md")).toBe(false); + expect(parseNativeChatId("chats/agent__foo.md")).toBeNull(); + expect(parseNativeChatId("copilot-agent-session://no-separator")).toBeNull(); + expect(parseNativeChatId("copilot-agent-session://backend/")).toBeNull(); + }); +}); + +describe("AgentSessionIndex", () => { + it("records sessions and lists them", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry()); + await index.recordSession(entry({ backendId: "codex", sessionId: "s2", title: null })); + const entries = await index.getEntries(); + expect(entries).toHaveLength(2); + expect(await index.getEntry("opencode", "s1")).toMatchObject({ + title: "Refactor the daily template", + }); + }); + + it("recordSession keeps earliest createdAt, latest lastAccessed, and known title", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry({ createdAtMs: 1_000, lastAccessedAtMs: 5_000 })); + await index.recordSession(entry({ title: null, createdAtMs: 3_000, lastAccessedAtMs: 4_000 })); + expect(await index.getEntry("opencode", "s1")).toMatchObject({ + title: "Refactor the daily template", + createdAtMs: 1_000, + lastAccessedAtMs: 5_000, + }); + }); + + it("deleteSession tombstones the key so discovered sessions stay suppressed", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry()); + await index.deleteSession("opencode", "s1"); + expect(await index.getEntries()).toHaveLength(0); + expect(await index.isTombstoned("opencode", "s1")).toBe(true); + + await index.mergeDiscoveredSessions([entry()]); + expect(await index.getEntries()).toHaveLength(0); + }); + + it("recordSession clears a tombstone — live activity reflects fresh intent", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.deleteSession("opencode", "s1"); + await index.recordSession(entry()); + expect(await index.isTombstoned("opencode", "s1")).toBe(false); + expect(await index.getEntries()).toHaveLength(1); + }); + + it("mergeDiscoveredSessions never moves lastAccessed backwards", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry({ lastAccessedAtMs: 9_000 })); + await index.mergeDiscoveredSessions([ + entry({ title: "Agent generated title", lastAccessedAtMs: 4_000 }), + ]); + expect(await index.getEntry("opencode", "s1")).toMatchObject({ + title: "Agent generated title", + lastAccessedAtMs: 9_000, + }); + }); + + it("setTitle renames an entry and touch bumps recency", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry({ lastAccessedAtMs: 1 })); + await index.setTitle("opencode", "s1", "Renamed"); + await index.touch("opencode", "s1"); + const updated = await index.getEntry("opencode", "s1"); + expect(updated?.title).toBe("Renamed"); + expect(updated?.lastAccessedAtMs).toBeGreaterThan(1); + }); + + it("a user rename survives discovered-session merges; agent titles stay refreshable", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + await index.recordSession(entry({ title: "Agent title", titleSource: "agent" })); + await index.setTitle("opencode", "s1", "My rename"); + await index.mergeDiscoveredSessions([entry({ title: "Agent title v2" })]); + expect(await index.getEntry("opencode", "s1")).toMatchObject({ + title: "My rename", + titleSource: "user", + }); + + // Without a user rename the agent store's fresher title wins. + await index.recordSession( + entry({ sessionId: "s2", title: "Agent title", titleSource: "agent" }) + ); + await index.mergeDiscoveredSessions([entry({ sessionId: "s2", title: "Agent title v2" })]); + expect((await index.getEntry("opencode", "s2"))?.title).toBe("Agent title v2"); + }); + + it("a user-sourced live label survives discovered-session merges via recordSession", async () => { + const index = new AgentSessionIndex(makeStorage(), INDEX_PATH); + // A tab rename on a live session reaches the index through the + // write-through path (recordSession), not setTitle. + await index.recordSession(entry({ title: "Tab rename", titleSource: "user" })); + await index.mergeDiscoveredSessions([entry({ title: "Agent original" })]); + expect((await index.getEntry("opencode", "s1"))?.title).toBe("Tab rename"); + }); + + it("the user title marker round-trips through persistence", async () => { + const storage = makeStorage(); + const first = new AgentSessionIndex(storage, INDEX_PATH); + await first.recordSession(entry()); + await first.setTitle("opencode", "s1", "My rename"); + await first.flush(); + const second = new AgentSessionIndex(storage, INDEX_PATH); + await second.mergeDiscoveredSessions([entry({ title: "Agent original" })]); + expect((await second.getEntry("opencode", "s1"))?.title).toBe("My rename"); + }); + + it("persists across instances via flush and ignores corrupt files", async () => { + const storage = makeStorage(); + const first = new AgentSessionIndex(storage, INDEX_PATH); + await first.recordSession(entry()); + await first.deleteSession("codex", "gone"); + await first.flush(); + expect(storage.files.get(INDEX_PATH)).toContain("s1"); + + const second = new AgentSessionIndex(storage, INDEX_PATH); + expect(await second.getEntry("opencode", "s1")).toMatchObject({ sessionId: "s1" }); + expect(await second.isTombstoned("codex", "gone")).toBe(true); + + const corrupt = new AgentSessionIndex(makeStorage({ [INDEX_PATH]: "not json {" }), INDEX_PATH); + expect(await corrupt.getEntries()).toEqual([]); + }); + + it("drops malformed entries on load instead of failing the whole index", async () => { + const storage = makeStorage({ + [INDEX_PATH]: JSON.stringify({ + version: 1, + entries: [ + entry(), + { backendId: "", sessionId: "x", createdAtMs: 1, lastAccessedAtMs: 1 }, + { backendId: "codex", sessionId: "y" }, + "garbage", + ], + tombstones: { "codex:z": "not-a-number" }, + }), + }); + const index = new AgentSessionIndex(storage, INDEX_PATH); + expect(await index.getEntries()).toHaveLength(1); + expect(await index.isTombstoned("codex", "z")).toBe(false); + }); +}); diff --git a/src/agentMode/session/AgentSessionIndex.ts b/src/agentMode/session/AgentSessionIndex.ts new file mode 100644 index 00000000..7a2421d1 --- /dev/null +++ b/src/agentMode/session/AgentSessionIndex.ts @@ -0,0 +1,303 @@ +import { logWarn } from "@/logger"; +import type { BackendId } from "./types"; + +/** + * One resumable agent session known to the plugin, independent of whether a + * markdown note was saved for it. Recorded write-through as sessions are used + * (and opportunistically from a backend's native `listSessions`), so the + * recent-chats list works with `autosaveChat` off and without spawning a + * backend just to enumerate history. + */ +export interface AgentSessionIndexEntry { + backendId: BackendId; + sessionId: string; + /** Last known user-visible title (agent label or user rename), or null. */ + title: string | null; + /** + * Who set `title`. `"user"` titles win over anything a native + * `listSessions` sweep discovers — the agent store keeps its original + * title (we never mutate it), so without this marker a plugin-side rename + * would be clobbered on the next sweep. Mirrors `AgentSession`'s + * `labelSource` semantics. Absent ≙ agent-sourced / unknown. + */ + titleSource?: "user" | "agent"; + createdAtMs: number; + lastAccessedAtMs: number; +} + +interface AgentSessionIndexFile { + version: 1; + entries: AgentSessionIndexEntry[]; + /** + * Keys of sessions the user deleted from recent chats, mapped to deletion + * time. Deleting never touches the backend's own session store (it is + * shared with the CLI outside Obsidian), so a tombstone is what keeps the + * entry from resurrecting on the next native `listSessions` merge. + */ + tombstones: Record; +} + +/** + * Minimal file-IO surface the index needs. Production passes the vault + * `DataAdapter` (paths are vault-relative, so the file lands under + * `.obsidian/plugins//`); tests pass an in-memory fake. + */ +export interface AgentSessionIndexStorage { + exists(path: string): Promise; + read(path: string): Promise; + write(path: string, content: string): Promise; +} + +const SAVE_DEBOUNCE_MS = 500; +/** Keep the on-disk file bounded; prune least-recently-accessed beyond this. */ +const MAX_ENTRIES = 500; +const MAX_TOMBSTONES = 500; + +function entryKey(backendId: string, sessionId: string): string { + return `${backendId}:${sessionId}`; +} + +function sanitizeEntry(raw: unknown): AgentSessionIndexEntry | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record; + if (typeof r.backendId !== "string" || !r.backendId.trim()) return null; + if (typeof r.sessionId !== "string" || !r.sessionId.trim()) return null; + const createdAtMs = typeof r.createdAtMs === "number" && r.createdAtMs > 0 ? r.createdAtMs : null; + const lastAccessedAtMs = + typeof r.lastAccessedAtMs === "number" && r.lastAccessedAtMs > 0 ? r.lastAccessedAtMs : null; + if (!createdAtMs && !lastAccessedAtMs) return null; + const title = typeof r.title === "string" && r.title.trim() ? r.title.trim() : null; + return { + backendId: r.backendId, + sessionId: r.sessionId, + title, + titleSource: + title && (r.titleSource === "user" || r.titleSource === "agent") ? r.titleSource : undefined, + createdAtMs: createdAtMs ?? lastAccessedAtMs!, + lastAccessedAtMs: lastAccessedAtMs ?? createdAtMs!, + }; +} + +/** + * Plugin-local, per-vault store of resumable Agent Mode sessions plus + * tombstones for user-deleted ones. This is the source of truth that lets + * recent chats list a session without a markdown note and without a live + * backend; native `listSessions` results are merged in as enrichment. + * + * All mutators lazily load the file on first use and persist with a short + * debounce; `flush()` forces a pending write (call it on plugin unload). + */ +export class AgentSessionIndex { + private entries = new Map(); + private tombstones = new Map(); + private loadPromise: Promise | null = null; + private saveTimer: number | null = null; + // Serializes writes so a slow disk can't interleave two snapshots. + private writeChain: Promise = Promise.resolve(); + + constructor( + private readonly storage: AgentSessionIndexStorage, + private readonly filePath: string + ) {} + + /** All known entries, unsorted. Tombstoned sessions are never present. */ + async getEntries(): Promise { + await this.ensureLoaded(); + return Array.from(this.entries.values()); + } + + async getEntry(backendId: BackendId, sessionId: string): Promise { + await this.ensureLoaded(); + return this.entries.get(entryKey(backendId, sessionId)) ?? null; + } + + /** + * Write-through upsert from live session activity. Clears any tombstone for + * the key — the user is actively chatting on this session, so a previous + * delete no longer reflects intent. Keeps the earliest `createdAtMs` and the + * latest `lastAccessedAtMs`; a null `title` never clobbers a known one. + */ + async recordSession(entry: AgentSessionIndexEntry): Promise { + await this.ensureLoaded(); + const key = entryKey(entry.backendId, entry.sessionId); + this.tombstones.delete(key); + const existing = this.entries.get(key); + const keepExistingTitle = entry.title == null; + this.entries.set(key, { + backendId: entry.backendId, + sessionId: entry.sessionId, + title: keepExistingTitle ? (existing?.title ?? null) : entry.title, + titleSource: keepExistingTitle ? existing?.titleSource : entry.titleSource, + createdAtMs: Math.min(entry.createdAtMs, existing?.createdAtMs ?? entry.createdAtMs), + lastAccessedAtMs: Math.max( + entry.lastAccessedAtMs, + existing?.lastAccessedAtMs ?? entry.lastAccessedAtMs + ), + }); + this.scheduleSave(); + } + + /** + * Merge sessions discovered via a backend's native `listSessions`. Unlike + * {@link recordSession} this respects tombstones (a deleted chat must not + * resurrect just because the backend still stores it), never moves + * `lastAccessedAtMs` backwards, and never overwrites a user-renamed title + * — discovered titles are agent-store originals. + */ + async mergeDiscoveredSessions(entries: AgentSessionIndexEntry[]): Promise { + await this.ensureLoaded(); + let changed = false; + for (const entry of entries) { + const key = entryKey(entry.backendId, entry.sessionId); + if (this.tombstones.has(key)) continue; + const existing = this.entries.get(key); + const keepExistingTitle = existing?.titleSource === "user" || entry.title == null; + const next: AgentSessionIndexEntry = { + backendId: entry.backendId, + sessionId: entry.sessionId, + title: keepExistingTitle ? (existing?.title ?? null) : entry.title, + titleSource: keepExistingTitle ? existing?.titleSource : "agent", + createdAtMs: Math.min(entry.createdAtMs, existing?.createdAtMs ?? entry.createdAtMs), + lastAccessedAtMs: Math.max( + entry.lastAccessedAtMs, + existing?.lastAccessedAtMs ?? entry.lastAccessedAtMs + ), + }; + if ( + !existing || + existing.title !== next.title || + existing.createdAtMs !== next.createdAtMs || + existing.lastAccessedAtMs !== next.lastAccessedAtMs + ) { + this.entries.set(key, next); + changed = true; + } + } + if (changed) this.scheduleSave(); + } + + /** + * Rename support for native-only entries (no frontmatter to patch). Marks + * the title user-sourced so discovered-session merges can't clobber it. + */ + async setTitle(backendId: BackendId, sessionId: string, title: string): Promise { + await this.ensureLoaded(); + const key = entryKey(backendId, sessionId); + const existing = this.entries.get(key); + if (!existing) return; + const trimmed = title.trim(); + this.entries.set(key, { + ...existing, + title: trimmed || null, + titleSource: trimmed ? "user" : undefined, + }); + this.scheduleSave(); + } + + /** Bump `lastAccessedAtMs` (e.g. when the chat is reopened from history). */ + async touch(backendId: BackendId, sessionId: string): Promise { + await this.ensureLoaded(); + const key = entryKey(backendId, sessionId); + const existing = this.entries.get(key); + if (!existing) return; + this.entries.set(key, { ...existing, lastAccessedAtMs: Date.now() }); + this.scheduleSave(); + } + + /** + * Remove the entry and tombstone the key so native merges don't bring it + * back. The backend's own session store is deliberately left untouched. + * Safe to call for keys that were never indexed (e.g. deleting a markdown + * chat whose native twin should stay suppressed). + */ + async deleteSession(backendId: BackendId, sessionId: string): Promise { + await this.ensureLoaded(); + const key = entryKey(backendId, sessionId); + this.entries.delete(key); + this.tombstones.set(key, Date.now()); + this.scheduleSave(); + } + + async isTombstoned(backendId: BackendId, sessionId: string): Promise { + await this.ensureLoaded(); + return this.tombstones.has(entryKey(backendId, sessionId)); + } + + /** Force any pending debounced write to disk. */ + async flush(): Promise { + if (this.saveTimer !== null) { + window.clearTimeout(this.saveTimer); + this.saveTimer = null; + this.queueWrite(); + } + await this.writeChain; + } + + private ensureLoaded(): Promise { + if (!this.loadPromise) { + this.loadPromise = this.loadFromDisk(); + } + return this.loadPromise; + } + + private async loadFromDisk(): Promise { + try { + if (!(await this.storage.exists(this.filePath))) return; + const raw = JSON.parse( + await this.storage.read(this.filePath) + ) as Partial | null; + if (!raw || typeof raw !== "object") return; + for (const candidate of Array.isArray(raw.entries) ? raw.entries : []) { + const entry = sanitizeEntry(candidate); + if (entry) this.entries.set(entryKey(entry.backendId, entry.sessionId), entry); + } + if (raw.tombstones && typeof raw.tombstones === "object") { + for (const [key, value] of Object.entries(raw.tombstones)) { + if (typeof value === "number" && value > 0) this.tombstones.set(key, value); + } + } + } catch (e) { + // A corrupt index degrades to "no native history" rather than failing + // the whole recent-chats surface; the next save rewrites a clean file. + logWarn(`[AgentMode] failed to load agent session index at ${this.filePath}`, e); + } + } + + private scheduleSave(): void { + if (this.saveTimer !== null) window.clearTimeout(this.saveTimer); + this.saveTimer = window.setTimeout(() => { + this.saveTimer = null; + this.queueWrite(); + }, SAVE_DEBOUNCE_MS); + } + + private queueWrite(): void { + const snapshot = this.serialize(); + this.writeChain = this.writeChain + .then(() => this.storage.write(this.filePath, snapshot)) + .catch((e) => { + logWarn(`[AgentMode] failed to write agent session index at ${this.filePath}`, e); + }); + } + + private serialize(): string { + let entries = Array.from(this.entries.values()); + if (entries.length > MAX_ENTRIES) { + entries = entries + .sort((a, b) => b.lastAccessedAtMs - a.lastAccessedAtMs) + .slice(0, MAX_ENTRIES); + this.entries = new Map(entries.map((e) => [entryKey(e.backendId, e.sessionId), e])); + } + let tombstonePairs = Array.from(this.tombstones.entries()); + if (tombstonePairs.length > MAX_TOMBSTONES) { + tombstonePairs = tombstonePairs.sort((a, b) => b[1] - a[1]).slice(0, MAX_TOMBSTONES); + this.tombstones = new Map(tombstonePairs); + } + const file: AgentSessionIndexFile = { + version: 1, + entries, + tombstones: Object.fromEntries(tombstonePairs), + }; + return JSON.stringify(file, null, 2); + } +} diff --git a/src/agentMode/session/AgentSessionManager.test.ts b/src/agentMode/session/AgentSessionManager.test.ts index 3ee2ff37..bfb0897c 100644 --- a/src/agentMode/session/AgentSessionManager.test.ts +++ b/src/agentMode/session/AgentSessionManager.test.ts @@ -3,8 +3,10 @@ * subprocess and the AgentSession factory are mocked so we can exercise * session-pool invariants without touching ACP or spawning a child process. */ -import { FileSystemAdapter, App } from "obsidian"; +import { FileSystemAdapter, App, TFile } from "obsidian"; import { AgentSession } from "./AgentSession"; +import { buildNativeChatId } from "@/utils/nativeChatId"; +import { AgentSessionIndex } from "./AgentSessionIndex"; import { AgentSessionManager } from "./AgentSessionManager"; import { setSettings as mockedSetSettings } from "@/settings/model"; import type { BackendDescriptor } from "./types"; @@ -158,6 +160,7 @@ function buildManager(): AgentSessionManager { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; return new AgentSessionManager( buildApp(), @@ -233,6 +236,7 @@ describe("AgentSessionManager.createSession", () => { cache.delete(id); }), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = buildDescriptor(); const mgr = new AgentSessionManager( @@ -559,6 +563,7 @@ describe("AgentSessionManager.restartBackend", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = { ...buildDescriptor(), @@ -597,6 +602,7 @@ describe("AgentSessionManager.restartBackend", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = { ...buildDescriptor(), @@ -633,6 +639,7 @@ describe("AgentSessionManager.restartBackend", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = { ...buildDescriptor(), @@ -691,6 +698,7 @@ describe("AgentSessionManager.restartBackend", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = { ...buildDescriptor(), @@ -1018,6 +1026,7 @@ describe("AgentSessionManager.applySelection", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const mgr = new AgentSessionManager( buildApp(), @@ -1109,6 +1118,7 @@ describe("AgentSessionManager.applySelection", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const mgr = new AgentSessionManager( buildApp(), @@ -1162,6 +1172,7 @@ describe("AgentSessionManager.onInstallStateChanged", () => { setCached: jest.fn(), clearCached: jest.fn(), takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => []), }; const descriptor = { ...buildDescriptor(), @@ -1262,3 +1273,336 @@ function readPersistedDefault( } return backends[backendId]?.defaultModel; } + +describe("AgentSessionManager chat history aggregation", () => { + // The mapped obsidian mock's TFile is a jest constructor taking a path; + // instances must come from the same constructor the production code + // `instanceof`-checks against, hence the import (not jest.requireMock). + const MockTFile = TFile as unknown as new (path: string) => TFile & { + stat: { ctime: number; mtime: number }; + }; + + interface FakeFrontmatter { + epoch?: number; + topic?: string; + backendId?: string; + sessionId?: string; + lastAccessedAt?: number; + } + + function makeIndexStorage() { + const files = new Map(); + return { + exists: async (p: string) => files.has(p), + read: async (p: string) => { + const content = files.get(p); + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }, + write: async (p: string, c: string) => { + files.set(p, c); + }, + }; + } + + function buildHistoryHarness(opts?: { + files?: Record; + /** Hidden-folder files: never in the metadata cache, read via adapter. */ + hiddenFiles?: Record; + listSessions?: jest.Mock; + /** When set, the preloader exposes a warm opencode probe proc with this listSessions. */ + warmListSessions?: jest.Mock; + probeSessionId?: string; + }) { + const frontmatterByPath = opts?.files ?? {}; + const hiddenByPath = opts?.hiddenFiles ?? {}; + const tfiles = [...Object.keys(frontmatterByPath), ...Object.keys(hiddenByPath)].map((p) => { + const f = new MockTFile(p); + f.stat = { ctime: 1_000, mtime: 1_000, size: 0 }; + return f; + }); + const adapter = new (FileSystemAdapter as unknown as new (basePath: string) => unknown)( + "/vault" + ) as { read: jest.Mock }; + adapter.read.mockImplementation(async (p: string) => { + const content = hiddenByPath[p]; + if (content === undefined) throw new Error(`ENOENT: ${p}`); + return content; + }); + const app = { + vault: { + adapter, + getAbstractFileByPath: (p: string) => + tfiles.find((f: { path: string }) => f.path === p) ?? null, + }, + metadataCache: { + getFileCache: (file: { path: string }) => { + const fm = frontmatterByPath[file.path]; + return fm ? { frontmatter: fm } : null; + }, + }, + } as unknown as App; + const plugin = { + manifest: { version: "1.0.0" }, + getChatHistoryLastAccessedAtManager: () => ({ + getEffectiveLastUsedAt: (_path: string, fallback: number) => fallback, + }), + }; + const persistence = { + getAgentChatHistoryFiles: jest.fn(async () => tfiles), + updateTopic: jest.fn(async () => undefined), + deleteFile: jest.fn(async () => undefined), + }; + const index = new AgentSessionIndex(makeIndexStorage(), "plugins/copilot/index.json"); + const descriptor = { + ...buildDescriptor(), + getProbeSessionId: jest.fn(() => opts?.probeSessionId), + } as unknown as BackendDescriptor; + if (opts?.listSessions) { + (descriptor as unknown as { createBackendProcess: jest.Mock }).createBackendProcess = jest.fn( + () => ({ ...makeMockBackendProcess(), listSessions: opts.listSessions }) + ); + } + const manager = new AgentSessionManager( + app, + plugin as unknown as ConstructorParameters[1], + { + permissionPrompter: jest.fn(), + resolveDescriptor: (id) => (id === "opencode" ? descriptor : undefined), + modelPreloader: { + getCachedBackendState: jest.fn(() => null), + preload: jest.fn(async () => undefined), + refresh: jest.fn(() => null), + subscribe: jest.fn(() => () => {}), + shutdown: jest.fn(), + setCached: jest.fn(), + clearCached: jest.fn(), + takeWarm: jest.fn(() => null), + getWarmProcs: jest.fn(() => + opts?.warmListSessions + ? [ + { + backendId: "opencode", + proc: { ...makeMockBackendProcess(), listSessions: opts.warmListSessions }, + }, + ] + : [] + ), + } as unknown as ConstructorParameters[2]["modelPreloader"], + persistenceManager: persistence as unknown as ConstructorParameters< + typeof AgentSessionManager + >[2]["persistenceManager"], + sessionIndex: index, + } + ); + return { manager, index, persistence }; + } + + it("merges markdown and native entries, de-duplicated on backend session id", async () => { + const { manager, index } = buildHistoryHarness({ + files: { + "chats/agent__a.md": { + epoch: 1_000, + topic: "Saved chat", + backendId: "opencode", + sessionId: "s1", + lastAccessedAt: 2_000, + }, + }, + }); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Saved chat", + createdAtMs: 1_000, + lastAccessedAtMs: 5_000, + }); + await index.recordSession({ + backendId: "opencode", + sessionId: "s2", + title: "Native only chat", + createdAtMs: 3_000, + lastAccessedAtMs: 4_000, + }); + + const items = await manager.getChatHistoryItems(); + expect(items).toHaveLength(2); + const markdown = items.find((i) => i.id === "chats/agent__a.md"); + expect(markdown).toBeDefined(); + // De-dup lifted the markdown item's recency to the fresher native side. + expect(markdown?.lastAccessedAt.getTime()).toBe(5_000); + const native = items.find((i) => i.id !== "chats/agent__a.md"); + expect(native?.id).toBe(buildNativeChatId("opencode", "s2")); + expect(native?.title).toBe("Native only chat"); + expect(native?.backendId).toBe("opencode"); + }); + + 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 + // the markdown row can't merge with its native twin. + const { manager, index } = buildHistoryHarness({ + hiddenFiles: { + ".copilot/chats/agent__hidden.md": + '---\nepoch: 1000\nmode: agent\nbackendId: opencode\nsessionId: "s1"\n---\n\n**user**: hi', + }, + }); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Hidden twin", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + const items = await manager.getChatHistoryItems(); + expect(items).toHaveLength(1); + expect(items[0]?.id).toBe(".copilot/chats/agent__hidden.md"); + }); + + it("lists native sessions when no markdown notes exist (autosave off)", async () => { + const { manager, index } = buildHistoryHarness(); + await index.recordSession({ + backendId: "codex", + sessionId: "s9", + title: "Codex chat", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + const items = await manager.getChatHistoryItems(); + expect(items).toHaveLength(1); + expect(items[0]?.id).toBe(buildNativeChatId("codex", "s9")); + }); + + it("deleting a native entry tombstones it without touching persistence", async () => { + const { manager, index, persistence } = buildHistoryHarness(); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Doomed", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + await manager.deleteChatHistory(buildNativeChatId("opencode", "s1")); + expect(await manager.getChatHistoryItems()).toHaveLength(0); + expect(await index.isTombstoned("opencode", "s1")).toBe(true); + expect(persistence.deleteFile).not.toHaveBeenCalled(); + }); + + it("deleting a markdown chat also tombstones its native twin", async () => { + const { manager, index, persistence } = buildHistoryHarness({ + files: { + "chats/agent__a.md": { + epoch: 1_000, + backendId: "opencode", + sessionId: "s1", + }, + }, + }); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Twin", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + await manager.deleteChatHistory("chats/agent__a.md"); + expect(persistence.deleteFile).toHaveBeenCalledWith("chats/agent__a.md"); + expect(await index.isTombstoned("opencode", "s1")).toBe(true); + }); + + it("renaming a native entry updates the index title", async () => { + const { manager, index } = buildHistoryHarness(); + await index.recordSession({ + backendId: "opencode", + sessionId: "s1", + title: "Old title", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + await manager.updateChatTitle(buildNativeChatId("opencode", "s1"), "New title"); + expect((await index.getEntry("opencode", "s1"))?.title).toBe("New 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"); + const liveId = live.getBackendSessionId()!; + await index.recordSession({ + backendId: "codex", + sessionId: liveId, + title: "Codex entry", + createdAtMs: 1_000, + lastAccessedAtMs: 2_000, + }); + + // Renaming the codex native entry whose id collides with the live + // opencode session must NOT relabel the opencode tab. + await manager.updateChatTitle(buildNativeChatId("codex", liveId), "Codex renamed"); + expect((await index.getEntry("codex", liveId))?.title).toBe("Codex renamed"); + expect(live.setLabel).not.toHaveBeenCalled(); + }); + + it("matches live sessions by the (backendId, sessionId) pair, not session id alone", async () => { + const { manager } = buildHistoryHarness(); + const session = await manager.createSession("opencode"); + const liveId = session.getBackendSessionId()!; + + // Same backend + session id: focuses the existing tab. + await expect(manager.loadNativeSessionFromHistory("opencode", liveId)).resolves.toBe(session); + + // Same session id on a DIFFERENT backend must not focus the opencode + // tab — it falls through to the resume path (which here fails on the + // unknown backend rather than silently hijacking the wrong session). + await expect(manager.loadNativeSessionFromHistory("codex", liveId)).rejects.toThrow(); + expect(manager.getActiveSession()).toBe(session); + }); + + it("sweeps the preloader's warm probe procs before any chat starts a backend", async () => { + const warmListSessions = jest.fn(async () => ({ + sessions: [ + { + sessionId: "pre-existing", + cwd: "/vault", + title: "Chat from before this app session", + updatedAt: new Date(6_000).toISOString(), + }, + ], + })); + const { manager } = buildHistoryHarness({ warmListSessions }); + // No createSession call: the manager owns no backend, only the warm + // probe exists — the first Agent Home open must still surface history. + const items = await manager.getChatHistoryItems(); + expect(warmListSessions).toHaveBeenCalledWith({ cwd: "/vault" }); + expect(items).toHaveLength(1); + expect(items[0]?.id).toBe(buildNativeChatId("opencode", "pre-existing")); + }); + + it("sweeps running backends' listSessions into history, scoped to the vault cwd", async () => { + const listSessions = jest.fn(async () => ({ + sessions: [ + { + sessionId: "in-vault", + cwd: "/vault", + title: "Real chat", + updatedAt: new Date(7_000).toISOString(), + }, + { sessionId: "other-vault", cwd: "/elsewhere", title: "Foreign chat", updatedAt: null }, + { sessionId: "untitled", cwd: "/vault", title: null, updatedAt: null }, + { sessionId: "placeholder", cwd: "/vault", title: "New session - 1", updatedAt: null }, + { sessionId: "probe-1", cwd: "/vault", title: "Probe", updatedAt: null }, + ], + })); + const { manager } = buildHistoryHarness({ listSessions, probeSessionId: "probe-1" }); + // Spawning a session is what registers (and starts) the backend; the + // sweep only ever queries already-running backends. + await manager.createSession("opencode"); + + const items = await manager.getChatHistoryItems(); + expect(listSessions).toHaveBeenCalledWith({ cwd: "/vault" }); + const native = items.filter((i) => i.id.startsWith("copilot-agent-session://")); + expect(native).toHaveLength(1); + expect(native[0]?.id).toBe(buildNativeChatId("opencode", "in-vault")); + expect(native[0]?.title).toBe("Real chat"); + expect(native[0]?.lastAccessedAt.getTime()).toBe(7_000); + }); +}); diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index f973e446..485ce086 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -5,11 +5,19 @@ import { getSettings, setSettings } from "@/settings/model"; import { err2String } from "@/utils"; import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; import { fileToHistoryItem } 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 } from "./AgentSession"; +import { AgentSession, ATTENTION_TRIGGER_STATUSES, DEFAULT_TITLE_PREFIX } from "./AgentSession"; import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; import type { AgentModelPreloader, WarmBackend } from "./AgentModelPreloader"; +import { parseNativeChatId } from "@/utils/nativeChatId"; +import type { AgentSessionIndex } from "./AgentSessionIndex"; +import { + deriveChatTitleFromMessages, + mergeChatHistoryItems, + type MarkdownChatEntry, +} from "./chatHistoryMerge"; import { MethodUnsupportedError } from "./errors"; import { resolveMcpServers } from "./mcpResolver"; import { replayPersistedMode } from "./replayPersistedMode"; @@ -30,6 +38,40 @@ import type { } from "./types"; const AUTOSAVE_DEBOUNCE_MS = 500; +/** + * Upper bound on the opportunistic `listSessions` sweep that enriches the + * recent-chats list from already-running backends. The session index answers + * the list on its own, so a slow agent must never hold the popover hostage. + */ +const LIST_SESSIONS_TIMEOUT_MS = 1_500; + +/** Resolve with `fallback` if `promise` hasn't settled within `ms`. */ +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return new Promise((resolve) => { + const timer = window.setTimeout(() => resolve(fallback), ms); + promise.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + () => { + window.clearTimeout(timer); + resolve(fallback); + } + ); + }); +} + +/** + * Compare two cwd strings for "same directory" after stripping trailing + * separators. Agents echo back the cwd we handed them, so an exact + * normalized match is the right level of strictness — anything looser + * risks leaking another vault's sessions into this vault's history. + */ +function isSameCwd(a: string, b: string): boolean { + const norm = (p: string) => p.replace(/[/\\]+$/, ""); + return norm(a) === norm(b); +} export type PermissionPrompter = (req: PermissionPrompt) => Promise; @@ -62,6 +104,13 @@ export interface AgentSessionManagerOptions { * barrel in `agentMode/index.ts`. */ persistenceManager?: AgentChatPersistenceManager; + /** + * Plugin-local index of resumable backend sessions, the markdown-free half + * of the recent-chats list. Optional only so legacy callers (tests) can + * omit it; production wiring always supplies one via the barrel in + * `agentMode/index.ts`. + */ + sessionIndex?: AgentSessionIndex; } /** @@ -106,6 +155,7 @@ export class AgentSessionManager { // "must be cleaned up when the session is detached": // - `path`: persisted file (set after first successful save) // - `timer`: pending debounce timer + // - `indexTimer`: pending session-index write-through debounce timer // - `unsub`: tear-down for the auto-save `session.subscribe()` // - `signature`: last serialized snapshot, for no-op skipping // - `modelCacheUnsub`: tear-down for the model-cache mirror subscription @@ -115,6 +165,7 @@ export class AgentSessionManager { { path?: string; timer?: number; + indexTimer?: number; unsub?: () => void; signature?: string; modelCacheUnsub?: () => void; @@ -143,32 +194,227 @@ export class AgentSessionManager { } /** - * List every persisted Agent Mode chat as a `ChatHistoryItem` ranked using - * the plugin's shared in-memory `lastAccessedAt` tracker. Returns `[]` when - * persistence isn't configured. + * List every known Agent Mode chat as a `ChatHistoryItem`: markdown-saved + * notes (ranked using the plugin's shared in-memory `lastAccessedAt` + * tracker) merged with the session index's native-store entries, de-duped + * on `backendId + sessionId`. Native entries exist independently of the + * `autosaveChat` setting, so the list survives autosave being off. Before + * merging, already-running backends are swept via `listSessions` (bounded + * by {@link LIST_SESSIONS_TIMEOUT_MS}) so chats created outside the plugin + * surface too — a backend is never spawned just to enumerate history. */ async getChatHistoryItems(): Promise { const persistence = this.opts.persistenceManager; - if (!persistence) return []; - const files = await persistence.getAgentChatHistoryFiles(); - const tracker = this.plugin.getChatHistoryLastAccessedAtManager(); - return files.map((file) => fileToHistoryItem(this.app, file, tracker)); + const index = this.opts.sessionIndex; + if (!persistence && !index) return []; + + let markdownEntries: MarkdownChatEntry[] = []; + if (persistence) { + const files = await persistence.getAgentChatHistoryFiles(); + const tracker = this.plugin.getChatHistoryLastAccessedAtManager(); + markdownEntries = await Promise.all( + files.map(async (file) => { + // readSessionRefFromFile falls back to an adapter read for files in + // hidden save folders, which the metadata cache never indexes — a + // cache-only read would leave those rows unmergeable and duplicate + // their native twins. + const ref = await this.readSessionRefFromFile(file.path); + return { + item: fileToHistoryItem(this.app, file, tracker), + backendId: ref?.backendId, + sessionId: ref?.sessionId, + }; + }) + ); + } + if (!index) return markdownEntries.map((e) => e.item); + + await this.refreshNativeSessionsFromBackends(); + const nativeEntries = await index.getEntries(); + return mergeChatHistoryItems(markdownEntries, nativeEntries); } - /** Update the user-visible title (frontmatter `topic`) of a saved chat. */ + /** + * Update the user-visible title of a saved chat — frontmatter `topic` for + * markdown chats, the index entry (plus the live session's label, when the + * session is open) for native-store entries. + */ async updateChatTitle(fileId: string, newTitle: string): Promise { + const native = parseNativeChatId(fileId); + if (native) { + const index = this.opts.sessionIndex; + if (!index) throw new Error("Agent session index is not configured."); + 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); + return; + } const persistence = this.opts.persistenceManager; if (!persistence) throw new Error("Agent chat persistence is not configured."); await persistence.updateTopic(fileId, newTitle); } - /** Delete a saved chat by file path. */ + /** + * Delete a chat from history. Native-store entries are tombstoned in the + * index (the backend's own session store is left untouched — it's shared + * with the CLI outside Obsidian). Markdown chats are trashed AND their + * backend session is tombstoned, so the native twin doesn't reappear on + * the next merge. + */ async deleteChatHistory(fileId: string): Promise { + const index = this.opts.sessionIndex; + const native = parseNativeChatId(fileId); + if (native) { + if (!index) throw new Error("Agent session index is not configured."); + this.cancelPendingIndexTouch(native.backendId, native.sessionId); + await index.deleteSession(native.backendId, native.sessionId); + return; + } const persistence = this.opts.persistenceManager; if (!persistence) throw new Error("Agent chat persistence is not configured."); + if (index) { + const ref = await this.readSessionRefFromFile(fileId); + if (ref) { + this.cancelPendingIndexTouch(ref.backendId, ref.sessionId); + await index.deleteSession(ref.backendId, ref.sessionId); + } + } await persistence.deleteFile(fileId); } + /** + * Drop any debounced index write-through still pending for a live session + * with this (backendId, sessionId). Without this, deleting a chat inside the + * ~500ms debounce window of a recent message/label change lets the already- + * queued `flushIndexTouch` fire after the tombstone is written — its + * `recordSession` clears the tombstone and re-adds the deleted chat to + * Recent Chats. Activity *after* the delete still re-indexes normally (a + * deliberate "the user is using it again" signal); only the pre-delete + * timer is cancelled. + */ + private cancelPendingIndexTouch(backendId: BackendId, sessionId: string): void { + for (const session of this.sessions.values()) { + if (session.backendId !== backendId) continue; + if (session.getBackendSessionId() !== sessionId) continue; + const state = this.sessionState.get(session.internalId); + if (state?.indexTimer) { + window.clearTimeout(state.indexTimer); + state.indexTimer = undefined; + } + } + } + + /** + * 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. + */ + private async readSessionRefFromFile( + fileId: string + ): Promise<{ backendId: BackendId; sessionId: string } | null> { + let fm: Record | undefined; + const file = this.app.vault.getAbstractFileByPath(fileId); + if (file instanceof TFile) { + fm = this.app.metadataCache.getFileCache(file)?.frontmatter; + } + if (!fm) { + try { + fm = (await readFrontmatterViaAdapter(this.app, fileId)) ?? undefined; + } catch { + 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 }; + } + + /** + * Sweep already-running backends' native session stores into the index. + * "Running" includes the preloader's warm probe subprocesses — they're + * spawned for every installed backend at plugin load, so sweeping them + * surfaces codex/opencode history on the very first Agent Home open, + * before any chat has started a manager-owned backend. Strictly + * opportunistic: never spawns a backend, swallows per-backend failures, + * and is capped by {@link LIST_SESSIONS_TIMEOUT_MS} so the history + * surface stays responsive when an agent is slow to answer. + */ + private async refreshNativeSessionsFromBackends(): Promise { + const index = this.opts.sessionIndex; + if (!index) return; + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return; + const vaultBasePath = adapter.getBasePath(); + // Manager-owned procs win over warm probes for the same backend id — + // they're the same subprocess lineage, but the manager's entry is the + // one whose lifecycle we control. + const procs = new Map(); + for (const { backendId, proc } of this.preloader.getWarmProcs()) { + if (proc.isRunning()) procs.set(backendId, proc); + } + for (const [backendId, proc] of this.backends) { + if (proc.isRunning()) procs.set(backendId, proc); + } + if (procs.size === 0) return; + const sweeps = Array.from(procs, ([backendId, proc]) => + this.sweepNativeSessions(backendId, proc, vaultBasePath) + ); + await withTimeout( + Promise.allSettled(sweeps).then(() => undefined), + LIST_SESSIONS_TIMEOUT_MS, + undefined + ); + } + + /** + * Merge one backend's `listSessions` result into the index. Filters to + * this vault's cwd (agent-side cwd filtering is not trusted — a stray + * session from another vault must never leak into this vault's history), + * skips the preloader's probe session, and requires a real title so the + * sweep can't surface empty placeholder sessions. + */ + private async sweepNativeSessions( + backendId: BackendId, + proc: BackendProcess, + vaultBasePath: string + ): Promise { + const index = this.opts.sessionIndex; + if (!index) return; + let sessions; + try { + ({ sessions } = await proc.listSessions({ cwd: vaultBasePath })); + } catch (err) { + if (!(err instanceof MethodUnsupportedError)) { + logWarn(`[AgentMode] listSessions sweep failed for ${backendId}`, err); + } + return; + } + const probeSessionId = this.opts + .resolveDescriptor(backendId) + ?.getProbeSessionId?.(getSettings()); + const now = Date.now(); + const discovered = []; + for (const s of sessions) { + if (!isSameCwd(s.cwd, vaultBasePath)) continue; + if (probeSessionId && s.sessionId === probeSessionId) continue; + const title = s.title?.trim(); + if (!title || title.startsWith(DEFAULT_TITLE_PREFIX)) continue; + const updatedAtMs = s.updatedAt ? Date.parse(s.updatedAt) : NaN; + const timestamp = Number.isFinite(updatedAtMs) && updatedAtMs > 0 ? updatedAtMs : now; + discovered.push({ + backendId, + sessionId: s.sessionId, + title, + createdAtMs: timestamp, + lastAccessedAtMs: timestamp, + }); + } + if (discovered.length > 0) await index.mergeDiscoveredSessions(discovered); + } + /** * Return the active `AgentSession` if one exists, otherwise create one. * Used by the router to lazily seed the first session on chain switch. @@ -743,6 +989,23 @@ export class AgentSessionManager { return null; } + /** + * Live (non-closed) session matching BOTH `backendId` and the backend + * `sessionId`. Matching the full identity at once — rather than finding the + * first session by `sessionId` and checking the backend after — keeps an + * (effectively impossible, UUID) cross-backend id collision from hiding the + * correct already-open tab. Used by the native-history open/rename paths. + */ + private findLiveSession(backendId: BackendId, sessionId: string): AgentSession | null { + for (const session of this.sessions.values()) { + if (session.backendId !== backendId) continue; + if (session.getBackendSessionId() !== sessionId) continue; + if (session.getStatus() === "closed") continue; + return session; + } + return null; + } + getChatUIState(id: string): AgentChatUIState | null { return this.chatUIStates.get(id) ?? null; } @@ -839,6 +1102,8 @@ export class AgentSessionManager { this.listeners.clear(); this.preloadStatus.clear(); this.preloader.shutdown(); + // Push any debounced index write to disk before the plugin unloads. + await this.opts.sessionIndex?.flush(); } /** @@ -868,6 +1133,10 @@ export class AgentSessionManager { state.path = undefined; } + // Captured before we create the loaded session (which becomes active) so + // we can replace an empty landing tab in place instead of leaving it. + const previousActiveId = this.activeSessionId; + const loaded = await this.opts.persistenceManager.loadFile(file); let session: AgentSession | null = null; @@ -878,13 +1147,124 @@ export class AgentSessionManager { session = await this.createSession(loaded.backendId); } - session.store.loadMessages(loaded.messages); + session.loadDisplayMessages(loaded.messages); if (loaded.label) session.setLabel(loaded.label); this.getSessionState(session.internalId).path = file.path; + if (loaded.sessionId) { + // Keep the native twin's recency in step with the markdown side so the + // merged history ranks this chat correctly after a reopen. + void this.opts.sessionIndex?.touch(loaded.backendId, loaded.sessionId); + } + this.absorbIntoEmptyActiveTab(session, previousActiveId); this.notify(); return session; } + /** + * When a history item is opened while the active tab is an empty landing + * (no user-visible messages), give the loaded session that tab's strip + * position and close the empty one — so opening a chat doesn't leave a + * stray blank tab behind. A tab with a real conversation is never + * clobbered; the loaded chat opens as a new tab in that case. + */ + private absorbIntoEmptyActiveTab(loaded: AgentSession, previousActiveId: string | null): void { + if (!previousActiveId || previousActiveId === loaded.internalId) return; + const previous = this.sessions.get(previousActiveId); + if (!previous || previous.hasUserVisibleMessages()) return; + const oldIdx = Array.from(this.sessions.keys()).indexOf(previousActiveId); + if (oldIdx >= 0) { + this.moveMapEntry(this.sessions, loaded.internalId, oldIdx); + this.moveMapEntry(this.chatUIStates, loaded.internalId, oldIdx); + } + // Background close: the loaded session is already active, so closing the + // empty one won't reassign the active pointer. + void this.closeSession(previousActiveId).catch((e) => + logWarn(`[AgentMode] closing empty tab during history load failed`, e) + ); + } + + /** + * Open a chat that exists only in a backend's native session store (no + * markdown note). If a live session is already bound to that backend + * session id, focus it; otherwise resume through the same path markdown + * history uses. Unlike `loadSessionFromHistory` there is no fresh-session + * fallback — silently opening an empty chat would misread as data loss, so + * the failure surfaces to the caller instead. + * + * The transcript is not rebuilt from the backend store (the resume path + * restores agent-side context only), so the chat may open visually empty + * while the agent still remembers the conversation on the next turn. + */ + async loadNativeSessionFromHistory( + backendId: BackendId, + sessionId: SessionId + ): Promise { + if (this.disposed) { + throw new Error("AgentSessionManager has been shut down"); + } + // Identity is the (backendId, sessionId) pair — searched together so an + // id collision across backends can't hide the correct already-open tab. + const existing = this.findLiveSession(backendId, sessionId); + if (existing) { + this.setActiveSession(existing.internalId); + return existing; + } + // Captured before the resumed session becomes active so we can replace an + // empty landing tab in place rather than spawning a new one. + const previousActiveId = this.activeSessionId; + const session = await this.tryResumeSessionFromHistory(backendId, sessionId); + if (!session) { + throw new Error(`Could not resume session ${sessionId} from the ${backendId} session store.`); + } + // Rebuild the visible transcript for backends that resume without + // replaying it (Claude SDK reads its on-disk session jsonl). ACP backends + // replay through `loadSession`, so they don't implement this and the + // session already has its messages. Best-effort: an empty result leaves + // the resumed-but-blank session as-is rather than failing the open. + await this.hydrateResumedTranscript(session, backendId, sessionId); + const index = this.opts.sessionIndex; + if (index) { + const entry = await index.getEntry(backendId, sessionId); + // Reapply with the recorded source: a user rename stays sticky, but an + // agent/derived title is agent-sourced so a resumed opencode/codex + // session can still refresh its title from later agent updates. + if (entry?.title) { + session.restoreLabel(entry.title, entry.titleSource === "user" ? "user" : "agent"); + } + await index.touch(backendId, sessionId); + } + this.absorbIntoEmptyActiveTab(session, previousActiveId); + this.notify(); + return session; + } + + /** + * Load a resumed session's display transcript from the backend's on-disk + * store when the backend supports it and the session came back empty. + * No-op for backends that replay via `loadSession` (they have no + * `readPersistedTranscript`) or when the store can't be reached. + */ + private async hydrateResumedTranscript( + session: AgentSession, + backendId: BackendId, + sessionId: SessionId + ): Promise { + const proc = this.backends.get(backendId); + if (!proc?.readPersistedTranscript) return; + if (session.store.getDisplayMessages().length > 0) return; + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return; + try { + const transcript = await proc.readPersistedTranscript({ + sessionId, + cwd: adapter.getBasePath(), + }); + if (transcript.length > 0) session.loadDisplayMessages(transcript); + } catch (e) { + logWarn(`[AgentMode] could not hydrate transcript for ${sessionId}`, e); + } + } + /** * Spin up an `AgentSession` bound to an existing backend session id. Prefers * `loadSession` (ACP replays the transcript through the backend) over @@ -985,9 +1365,16 @@ export class AgentSessionManager { private attachAutoSave(session: AgentSession): void { const persistence = this.opts.persistenceManager; - if (!persistence) return; + const index = this.opts.sessionIndex; + if (!persistence && !index) return; - const trigger = () => this.scheduleAutoSave(session); + // The markdown auto-save is gated on `settings.autosaveChat` inside + // `scheduleAutoSave`; the index write-through is not — history must keep + // tracking the session even when the user opted out of markdown notes. + const trigger = () => { + this.scheduleAutoSave(session); + this.scheduleIndexTouch(session); + }; const unsubscribe = session.subscribe({ onMessagesChanged: trigger, onStatusChanged: () => {}, @@ -1008,6 +1395,54 @@ export class AgentSessionManager { }, AUTOSAVE_DEBOUNCE_MS); } + private scheduleIndexTouch(session: AgentSession): void { + if (!this.opts.sessionIndex) return; + const state = this.getSessionState(session.internalId); + if (state.indexTimer) window.clearTimeout(state.indexTimer); + state.indexTimer = window.setTimeout(() => { + state.indexTimer = undefined; + this.flushIndexTouch(session).catch((e) => + logWarn(`[AgentMode] session-index update failed for ${session.internalId}`, e) + ); + }, AUTOSAVE_DEBOUNCE_MS); + } + + /** + * Record this session in the index so it appears in recent chats whether + * or not a markdown note exists. Skips sessions that haven't produced a + * user-visible message yet — a freshly-opened empty tab isn't history. + */ + private async flushIndexTouch(session: AgentSession): Promise { + const index = this.opts.sessionIndex; + if (!index) return; + if (!this.sessions.has(session.internalId)) return; + const sessionId = session.getBackendSessionId(); + if (!sessionId) return; + const messages = session.store.getDisplayMessages(); + if (messages.length === 0) return; + const now = Date.now(); + // Prefer the agent/user label; fall back to a title derived from the + // first user message so chats that have no agent title (every Claude + // Code chat — its SDK has no title API) don't read "Untitled chat". + // The derived title is recorded agent-sourced, so an opencode/codex + // summarizer title still overrides it later, and a user rename always wins. + const label = session.getLabel(); + const title = label ?? deriveChatTitleFromMessages(messages); + const titleSource: "user" | "agent" | undefined = !title + ? undefined + : label && session.getLabelSource() === "user" + ? "user" + : "agent"; + await index.recordSession({ + backendId: session.backendId, + sessionId, + title, + titleSource, + createdAtMs: messages[0]?.timestamp?.epoch ?? now, + lastAccessedAtMs: now, + }); + } + /** * Manual save entry point. Writes the active session via the same code path * auto-save uses, but ignores `settings.autosaveChat`. Returns the on-disk @@ -1069,6 +1504,15 @@ export class AgentSessionManager { */ private async drainAutoSave(session: AgentSession): Promise { const state = this.sessionState.get(session.internalId); + if (state?.indexTimer) { + window.clearTimeout(state.indexTimer); + state.indexTimer = undefined; + try { + await this.flushIndexTouch(session); + } catch (e) { + logWarn(`[AgentMode] drain session-index update failed for ${session.internalId}`, e); + } + } if (!state?.timer) return; window.clearTimeout(state.timer); state.timer = undefined; @@ -1083,6 +1527,7 @@ export class AgentSessionManager { const state = this.sessionState.get(internalId); if (!state) return; if (state.timer) window.clearTimeout(state.timer); + if (state.indexTimer) window.clearTimeout(state.indexTimer); state.unsub?.(); state.modelCacheUnsub?.(); state.attentionUnsub?.(); diff --git a/src/agentMode/session/chatHistoryMerge.test.ts b/src/agentMode/session/chatHistoryMerge.test.ts new file mode 100644 index 00000000..106f914b --- /dev/null +++ b/src/agentMode/session/chatHistoryMerge.test.ts @@ -0,0 +1,133 @@ +import { AI_SENDER, USER_SENDER } from "@/constants"; +import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; +import { buildNativeChatId } from "@/utils/nativeChatId"; +import type { AgentSessionIndexEntry } from "./AgentSessionIndex"; +import { + deriveChatTitleFromMessages, + mergeChatHistoryItems, + UNTITLED_NATIVE_CHAT, +} from "./chatHistoryMerge"; +import type { AgentChatMessage } from "./types"; + +function msg(sender: string, message: string): AgentChatMessage { + return { + id: `${sender}-${message.slice(0, 4)}`, + sender, + message, + isVisible: true, + } as AgentChatMessage; +} + +function mdItem(overrides: Partial = {}): ChatHistoryItem { + return { + id: "chats/agent__20260601_120000__topic.md", + title: "Saved chat", + createdAt: new Date(1_000), + lastAccessedAt: new Date(2_000), + backendId: "opencode", + ...overrides, + }; +} + +function nativeEntry(overrides: Partial = {}): AgentSessionIndexEntry { + return { + backendId: "opencode", + sessionId: "s1", + title: "Native chat", + createdAtMs: 1_500, + lastAccessedAtMs: 2_500, + ...overrides, + }; +} + +describe("mergeChatHistoryItems", () => { + it("a session saved as markdown AND present natively appears exactly once, as the markdown item", () => { + const item = mdItem(); + const merged = mergeChatHistoryItems( + [{ item, backendId: "opencode", sessionId: "s1" }], + [nativeEntry({ sessionId: "s1" })] + ); + expect(merged).toHaveLength(1); + expect(merged[0]?.id).toBe(item.id); + expect(merged[0]?.title).toBe("Saved chat"); + }); + + it("the merged item takes whichever side was accessed more recently", () => { + const item = mdItem({ lastAccessedAt: new Date(2_000) }); + const [fresherNative] = mergeChatHistoryItems( + [{ item, backendId: "opencode", sessionId: "s1" }], + [nativeEntry({ lastAccessedAtMs: 9_000 })] + ); + expect(fresherNative?.lastAccessedAt.getTime()).toBe(9_000); + + const [fresherMarkdown] = mergeChatHistoryItems( + [ + { + item: mdItem({ lastAccessedAt: new Date(9_500) }), + backendId: "opencode", + sessionId: "s1", + }, + ], + [nativeEntry({ lastAccessedAtMs: 9_000 })] + ); + expect(fresherMarkdown?.lastAccessedAt.getTime()).toBe(9_500); + }); + + it("native-only sessions become items with the encoded native id and backend icon hint", () => { + const merged = mergeChatHistoryItems([], [nativeEntry({ backendId: "codex" })]); + expect(merged).toHaveLength(1); + expect(merged[0]?.id).toBe(buildNativeChatId("codex", "s1")); + expect(merged[0]?.backendId).toBe("codex"); + expect(merged[0]?.createdAt.getTime()).toBe(1_500); + }); + + it("untitled native sessions fall back to a placeholder title", () => { + const merged = mergeChatHistoryItems([], [nativeEntry({ title: null })]); + expect(merged[0]?.title).toBe(UNTITLED_NATIVE_CHAT); + }); + + it("different sessions on different backends never merge, even with the same session id", () => { + const merged = mergeChatHistoryItems( + [{ item: mdItem(), backendId: "opencode", sessionId: "s1" }], + [nativeEntry({ backendId: "codex", sessionId: "s1" })] + ); + expect(merged).toHaveLength(2); + }); + + it("markdown chats without a sessionId in frontmatter pass through unmerged", () => { + const merged = mergeChatHistoryItems([{ item: mdItem() }], [nativeEntry({ sessionId: "s1" })]); + expect(merged).toHaveLength(2); + }); +}); + +describe("deriveChatTitleFromMessages", () => { + it("uses the first user message", () => { + const title = deriveChatTitleFromMessages([ + msg(USER_SENDER, "Summarize today's meeting notes"), + msg(AI_SENDER, "Sure, here is a summary…"), + ]); + expect(title).toBe("Summarize today's meeting notes"); + }); + + it("skips a leading assistant/system message and collapses whitespace", () => { + const title = deriveChatTitleFromMessages([ + msg(AI_SENDER, "How can I help?"), + msg(USER_SENDER, " refactor\n the parser "), + ]); + expect(title).toBe("refactor the parser"); + }); + + it("unwraps wikilinks and elides long messages", () => { + const long = `Look at [[Project Plan]] and tell me everything that is wrong with the current approach in detail`; + const title = deriveChatTitleFromMessages([msg(USER_SENDER, long)]); + expect(title).toContain("Look at Project Plan"); + expect(title!.endsWith("…")).toBe(true); + expect(title!.length).toBeLessThanOrEqual(61); // 60 chars + ellipsis + }); + + it("returns null when there is no usable user text", () => { + expect(deriveChatTitleFromMessages([])).toBeNull(); + expect(deriveChatTitleFromMessages([msg(AI_SENDER, "only assistant")])).toBeNull(); + expect(deriveChatTitleFromMessages([msg(USER_SENDER, " ")])).toBeNull(); + }); +}); diff --git a/src/agentMode/session/chatHistoryMerge.ts b/src/agentMode/session/chatHistoryMerge.ts new file mode 100644 index 00000000..3730994a --- /dev/null +++ b/src/agentMode/session/chatHistoryMerge.ts @@ -0,0 +1,91 @@ +import { USER_SENDER } from "@/constants"; +import type { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; +import { buildNativeChatId } from "@/utils/nativeChatId"; +import type { AgentSessionIndexEntry } from "./AgentSessionIndex"; +import type { AgentChatMessage } from "./types"; + +/** + * A markdown-persisted chat plus the backend session identity from its + * frontmatter (absent for chats saved before resume was wired up, whose + * native twin therefore can't be matched). + */ +export interface MarkdownChatEntry { + item: ChatHistoryItem; + backendId?: string; + sessionId?: string; +} + +/** Last-resort row title when a native session has no title to show at all. */ +export const UNTITLED_NATIVE_CHAT = "Untitled chat"; + +/** Max length of a title derived from the first user message before eliding. */ +const MAX_DERIVED_TITLE_CHARS = 60; + +/** + * Derive a readable title from a chat's first user message, mirroring what the + * markdown autosave path already does for note filenames. Used as the native + * index title when no agent-generated label exists yet — notably for Claude + * Code, whose SDK exposes no session-title API, so without this every CC chat + * in recent history would read "Untitled chat". Stored as an overridable + * (agent-sourced) title so an opencode/codex summarizer title still wins later. + * Returns null when there's no usable user text. + */ +export function deriveChatTitleFromMessages(messages: AgentChatMessage[]): string | null { + const firstUser = messages.find((m) => m.sender === USER_SENDER && m.message.trim()); + if (!firstUser) return null; + const text = firstUser.message + .replace(/\[\[([^\]]+)\]\]/g, "$1") // show wikilink target text, not the brackets + .replace(/\s+/g, " ") + .trim(); + if (!text) return null; + if (text.length <= MAX_DERIVED_TITLE_CHARS) return text; + return `${text.slice(0, MAX_DERIVED_TITLE_CHARS).trimEnd()}…`; +} + +/** + * Merge markdown-saved chats with native-store sessions into one de-duplicated + * history list. Identity is `backendId + sessionId`: a session that was also + * autosaved as markdown appears once, as the markdown item (it carries the + * user-facing title and the openable source note), with its recency lifted to + * whichever side was touched last. Native-only sessions become synthetic items + * whose id encodes the (backendId, sessionId) pair for the resume router. + * + * Ordering is left to the consumers (the popover and the landing section each + * apply their own sort strategy), matching `getChatHistoryItems`'s existing + * contract of returning unsorted items. + */ +export function mergeChatHistoryItems( + markdownEntries: MarkdownChatEntry[], + nativeEntries: AgentSessionIndexEntry[] +): ChatHistoryItem[] { + const nativeByKey = new Map(); + for (const entry of nativeEntries) { + nativeByKey.set(`${entry.backendId}:${entry.sessionId}`, entry); + } + + const merged: ChatHistoryItem[] = []; + for (const { item, backendId, sessionId } of markdownEntries) { + const key = backendId && sessionId ? `${backendId}:${sessionId}` : null; + const twin = key ? nativeByKey.get(key) : undefined; + if (twin && key) { + nativeByKey.delete(key); + if (twin.lastAccessedAtMs > item.lastAccessedAt.getTime()) { + merged.push({ ...item, lastAccessedAt: new Date(twin.lastAccessedAtMs) }); + continue; + } + } + merged.push(item); + } + + for (const entry of nativeByKey.values()) { + merged.push({ + id: buildNativeChatId(entry.backendId, entry.sessionId), + title: entry.title ?? UNTITLED_NATIVE_CHAT, + createdAt: new Date(entry.createdAtMs), + lastAccessedAt: new Date(entry.lastAccessedAtMs), + backendId: entry.backendId, + }); + } + + return merged; +} diff --git a/src/agentMode/session/types.ts b/src/agentMode/session/types.ts index 22f29840..8dac3b2c 100644 --- a/src/agentMode/session/types.ts +++ b/src/agentMode/session/types.ts @@ -681,6 +681,17 @@ export interface BackendProcess { listSessions(params: ListSessionsInput): Promise; resumeSession(params: ResumeSessionInput): Promise; loadSession(params: LoadSessionInput): Promise; + /** + * Optional: rebuild a session's display transcript from the backend's own + * on-disk store, for resuming a native (no-markdown) history entry. The + * Claude SDK implements this by reading its CLI session jsonl, since + * `resumeSession` returns no prior messages; ACP backends replay the + * transcript through `loadSession` instead and omit this. + */ + readPersistedTranscript?(params: { + sessionId: SessionId; + cwd: string; + }): Promise; /** * Whether the backend can route MCP servers of the given transport. * ACP runtime probes this from the agent's advertised capabilities; the diff --git a/src/agentMode/ui/AgentHome.tsx b/src/agentMode/ui/AgentHome.tsx index 261f70bb..00323eae 100644 --- a/src/agentMode/ui/AgentHome.tsx +++ b/src/agentMode/ui/AgentHome.tsx @@ -170,19 +170,6 @@ const AgentHomeInternal: React.FC = ({ // it's unreachable while the tab is disabled. const handleProjectComingSoon = useCallback(() => {}, []); - // Landing "New chat": spin up a fresh session in a new tab, the same path the - // tab strip's "+" uses. Guard on getIsStarting() like handleNewChat above and - // AgentTabStrip's "+" — createSession() sets the starting flag synchronously - // (before its first await), so a second click while a create is in flight is - // a no-op instead of spawning a duplicate session/tab. - const handleCreateChat = useCallback(() => { - if (manager.getIsStarting()) return; - manager.createSession().catch((e) => { - logError("[AgentMode] createSession failed", e); - new Notice("Failed to start a new chat. Please try again."); - }); - }, [manager]); - const modelPickerOverride = useAgentModelPicker(manager); const modePickerOverride = useAgentModePicker(manager); @@ -263,7 +250,6 @@ const AgentHomeInternal: React.FC = ({ onDeleteChat={handleDeleteChat} onOpenSourceFile={handleOpenSourceFile} onLoadHistory={handleLoadChatHistory} - onCreate={handleCreateChat} /> ), }, @@ -290,7 +276,6 @@ const AgentHomeInternal: React.FC = ({ projects, chatHistoryItems, handleProjectComingSoon, - handleCreateChat, handleLoadChat, handleUpdateChatTitle, handleDeleteChat, diff --git a/src/agentMode/ui/GlobalRecentChatsSection.tsx b/src/agentMode/ui/GlobalRecentChatsSection.tsx index 733bd820..62b86be1 100644 --- a/src/agentMode/ui/GlobalRecentChatsSection.tsx +++ b/src/agentMode/ui/GlobalRecentChatsSection.tsx @@ -1,45 +1,37 @@ -import { - AgentHomeCreateRow, - AgentHomeListRow, - INLINE_LIMIT, -} from "@/agentMode/ui/AgentHomeSection"; import { backendRegistry } from "@/agentMode/backends/registry"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { SearchBar } from "@/components/ui/SearchBar"; +import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover"; import { cn } from "@/lib/utils"; -import { - ChatHistoryItem, - ChatHistoryPopover, -} from "@/components/chat-components/ChatHistoryPopover"; +import { isNativeChatId } from "@/utils/nativeChatId"; +import { formatCompactRelativeTime } from "@/utils/formatRelativeTime"; import { sortByStrategy } from "@/utils/recentUsageManager"; -import { ChevronRight, MessageCircle } from "lucide-react"; -import React, { memo, useMemo } from "react"; +import { ArrowUpRight, Check, Edit2, MessageCircle, Trash2, X } from "lucide-react"; +import React, { memo, useCallback, useEffect, useMemo, useState } from "react"; interface GlobalRecentChatsSectionProps { - /** Recent chats supplied by core (global getChatHistoryItems). Pure display. */ + /** Recent chats supplied by core (global getChatHistoryItems). */ items: ChatHistoryItem[]; - /** - * Open a chat by id — drives both the inline rows and the View-all popover, so - * "open a chat" has one id-based entry point (same handler the conversation - * control bar uses). The View-all popover handlers below come from the same - * `useAgentHistoryControls`, so the landing reuses the full - * {@link ChatHistoryPopover} (search, time grouping, rename, delete, - * open-source) without any new backend work. - */ + /** Open a chat by id (markdown path or native session id). */ onLoadChat: (id: string) => Promise; onUpdateTitle: (id: string, newTitle: string) => Promise; onDeleteChat: (id: string) => Promise; + /** + * Open the chat's source note. Only meaningful for markdown-saved chats; + * native (autosave-off) entries have no file, so the row hides the action. + */ onOpenSourceFile: (id: string) => Promise; - /** Refresh the items when the popover opens (mirrors the control-bar button). */ + /** Refresh the items (called once when the section mounts). */ onLoadHistory?: () => void; - /** Optional create action — renders a "New chat" row atop the list. */ - onCreate?: () => void; className?: string; } /** - * Brand icon for the backend a chat ran on, mirroring the chat history popover's - * resolver. Returns `undefined` for legacy chats without a `backendId`; the - * popover then falls back to its own generic icon (`MessageCircle`), and the - * inline rows below fall back to the same icon so the two surfaces match. + * Brand icon for the backend a chat ran on. Returns `undefined` for legacy + * chats without a `backendId`, in which case the row falls back to a generic + * message glyph. */ function resolveChatIcon( item: ChatHistoryItem @@ -47,13 +39,9 @@ function resolveChatIcon( return item.backendId ? backendRegistry[item.backendId]?.Icon : undefined; } -// Most-recent-first, via the same `sortByStrategy("recent")` the Projects list -// uses — so the inline preview orders identically (primary: last-used desc, -// falling back to created; ties broken by name then created). The section is -// literally "Recent Chats", so the inline preview is pinned to "recent"; the -// View-all popover follows the user's configurable `chatHistorySortStrategy` -// (same as everywhere else the popover renders). Upstream `getChatHistoryItems()` -// returns vault-scan order, so the inline sort lives here. +// Most-recent-first, matching the rest of the landing (last-used desc, falling +// back to created; ties broken by name then created). Upstream +// `getChatHistoryItems()` returns vault-scan order, so the sort lives here. function sortChatsByRecent(items: ChatHistoryItem[]): ChatHistoryItem[] { return sortByStrategy(items, "recent", { getName: (item) => item.title, @@ -63,11 +51,9 @@ function sortChatsByRecent(items: ChatHistoryItem[]): ChatHistoryItem[] { } /** - * Neutral tile holding the chat's backend brand glyph (or the generic fallback), - * sized to match the project tiles so both lists share one leading-slot width — - * their labels then line up when you switch tabs, and it matches the "New chat" - * create row's tile. Projects are color-coded by id; chats aren't, so this uses a - * single muted surface rather than a hued tint. + * Neutral tile holding the chat's backend brand glyph (or the generic + * fallback), sized to match the project tiles so the two shelf tabs share one + * leading-slot width. */ const ChatIconTile = memo(({ Icon }: { Icon: React.ComponentType<{ className?: string }> }) => ( void | Promise; + isEditing: boolean; + editingTitle: string; + confirmingDelete: boolean; + onOpen: (id: string) => void; + onStartEdit: (id: string, title: string) => void; + onEditingTitleChange: (title: string) => void; + onSaveEdit: () => void; + onCancelEdit: () => void; + onStartDelete: (id: string) => void; + onConfirmDelete: (id: string) => void; + onCancelDelete: () => void; + /** Whether this chat has a source note to open (markdown-saved only). */ + canOpenSourceFile: boolean; + onOpenSourceFile: (id: string) => void; } -const RecentChatRow = memo(({ item, onOpen }: RecentChatRowProps) => ( - void onOpen(item.id)} - leading={} - /> -)); -RecentChatRow.displayName = "RecentChatRow"; - /** - * "Recent Chats" section for the Agent Home landing (design A.2). The inline - * preview is read-only; the View-all opens the full management popover. - * - * Shows the {@link INLINE_LIMIT} most-recent chats inline; the "View all" trigger - * opens the full {@link ChatHistoryPopover} so the user can search, rename, - * delete, and open the source file — the same management surface as the - * conversation-state control bar. Pure presentation: the data source and all - * mutations are owned by core. Named with the `Global` prefix so PR2 can - * introduce a per-project `Project Chats` variant without collision. + * One chat row: click to open, hover to reveal go-to-file (markdown only), + * rename (inline edit), and delete (two-step confirm). Mirrors the chat + * history popover's row affordances so the landing surface manages chats + * directly instead of deferring everything to a separate popover. */ -export const GlobalRecentChatsSection = memo( - ({ - items, - onLoadChat, - onUpdateTitle, - onDeleteChat, - onOpenSourceFile, - onLoadHistory, - onCreate, - className, - }: GlobalRecentChatsSectionProps): React.ReactElement => { - // Sort once for the inline preview; the popover re-sorts the full list by - // the user's configured strategy, so it reads `items` directly below. - const sortedItems = useMemo(() => sortChatsByRecent(items), [items]); - const inlineItems = useMemo(() => sortedItems.slice(0, INLINE_LIMIT), [sortedItems]); - const total = items.length; - const hasOverflow = total > INLINE_LIMIT; +const RecentChatRow = memo(function RecentChatRow({ + item, + isEditing, + editingTitle, + confirmingDelete, + onOpen, + onStartEdit, + onEditingTitleChange, + onSaveEdit, + onCancelEdit, + onStartDelete, + onConfirmDelete, + onCancelDelete, + canOpenSourceFile, + onOpenSourceFile, +}: RecentChatRowProps): React.ReactElement { + const Icon = resolveChatIcon(item) ?? MessageCircle; + if (isEditing) { return ( -
- {onCreate && } - {total === 0 ? ( -
No recent chats
- ) : ( - <> - {inlineItems.map((item) => ( - - ))} - {hasOverflow && ( - - {/* Same "View all" trigger shape as the Projects section (div - role=button); pl-6 aligns under these rows' single-glyph chat - icons (the Projects list uses pl-8 to clear its wider tiles). - Radix merges its toggle - onClick onto this child; Enter/Space dispatch a click so the - popover opens for keyboard users without this row owning the - popover's open state. - - DESIGN NOTE: onLoadHistory runs on every toggle (open *and* - close), because Radix fires the merged onClick both ways and - this row can't see the popover's open state. That's an - intentional, harmless refresh — the same pattern the - conversation control bar uses on its History button - (AgentChatControls). A refresh on close just re-reads the - same vault history; loadChatHistory is mounted-guarded and - self-correcting, so a fast open/close race only momentarily - shows near-identical data. A "refresh only on open" fix would - need ChatHistoryPopover to expose onOpenChange — not worth - touching the shared base for this. If a future review flags - this again, point them at this note. */} -
onLoadHistory?.()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.currentTarget.click(); - } - }} - > - View all chats - -
-
- )} - - )} +
+ + onEditingTitleChange(e.target.value)} + className="!tw-h-6 tw-flex-1" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") onSaveEdit(); + else if (e.key === "Escape") onCancelEdit(); + }} + /> + +
); } -); + + return ( +
onOpen(item.id)} + onKeyDown={(e) => { + // Only the row itself opens on Enter/Space. Without this, a keydown on + // a focused action button (rename/delete/open-source) bubbles up here + // and would also open the chat — the buttons stop click propagation, + // not keydown. + if (e.target !== e.currentTarget) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(item.id); + } + }} + > + + + {item.title} + + + {/* Relative time by default; the action cluster replaces it on hover or + keyboard focus so a narrow sidebar doesn't have to fit both. The + `group-focus-within` path keeps the actions reachable for keyboard + users (focusing the row reveals them, so Tab can move into them) — + on hover alone they'd stay `display:none` and out of the tab order. */} + + {formatCompactRelativeTime(item.lastAccessedAt.getTime())} + +
+ {confirmingDelete ? ( + <> + + + + ) : ( + <> + {canOpenSourceFile && ( + + )} + + + + )} +
+
+ ); +}); + +/** + * "Recent Chats" section for the Agent Home landing. A searchable, scrollable + * list whose rows manage chats in place — open, rename, delete, and (for + * markdown-saved chats only) open the source note — the same affordances as the + * chat history popover, without a separate "view all" step. Native + * (autosave-off) sessions appear here too; they just have no source note. + */ +export const GlobalRecentChatsSection = memo(function GlobalRecentChatsSection({ + items, + onLoadChat, + onUpdateTitle, + onDeleteChat, + onOpenSourceFile, + onLoadHistory, + className, +}: GlobalRecentChatsSectionProps): React.ReactElement { + const [query, setQuery] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editingTitle, setEditingTitle] = useState(""); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + // Refresh once when the section first mounts (i.e. the user opened the + // Recent Chats tab), mirroring the old popover's refresh-on-open. + useEffect(() => { + onLoadHistory?.(); + }, [onLoadHistory]); + + const sortedItems = useMemo(() => sortChatsByRecent(items), [items]); + const filteredItems = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return sortedItems; + return sortedItems.filter((item) => item.title.toLowerCase().includes(q)); + }, [sortedItems, query]); + + const handleStartEdit = useCallback((id: string, title: string) => { + setConfirmDeleteId(null); + setEditingId(id); + setEditingTitle(title); + }, []); + + const handleSaveEdit = useCallback(() => { + const trimmed = editingTitle.trim(); + const id = editingId; + setEditingId(null); + if (!id || !trimmed) return; + void onUpdateTitle(id, trimmed); + }, [editingId, editingTitle, onUpdateTitle]); + + const handleConfirmDelete = useCallback( + (id: string) => { + setConfirmDeleteId(null); + void onDeleteChat(id); + }, + [onDeleteChat] + ); + + const handleOpenSourceFile = useCallback( + (id: string) => { + void onOpenSourceFile(id); + }, + [onOpenSourceFile] + ); + + return ( +
+ {items.length > 0 && ( +
+ +
+ )} + {filteredItems.length === 0 ? ( +
+ {items.length === 0 ? "No recent chats" : "No matching chats"} +
+ ) : ( + +
+ {filteredItems.map((item) => ( + setEditingId(null)} + onStartDelete={setConfirmDeleteId} + onConfirmDelete={handleConfirmDelete} + onCancelDelete={() => setConfirmDeleteId(null)} + canOpenSourceFile={!isNativeChatId(item.id)} + onOpenSourceFile={handleOpenSourceFile} + /> + ))} +
+
+ )} +
+ ); +}); GlobalRecentChatsSection.displayName = "GlobalRecentChatsSection"; diff --git a/src/main.ts b/src/main.ts index 079cf02b..e2e0628a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,7 @@ import type { AgentSessionManager } from "@/agentMode"; +// Deep import (not the barrel): these run on the load path for every +// platform, and the barrel pulls Node-only modules that crash mobile. +import { isNativeChatId, parseNativeChatId } from "@/utils/nativeChatId"; import { BrevilabsClient } from "@/LLMProviders/brevilabsClient"; import ProjectManager from "@/LLMProviders/projectManager"; import { @@ -104,6 +107,7 @@ import { RecentUsageManager } from "@/utils/recentUsageManager"; import { listMarkdownFiles, patchFrontmatter, + readFrontmatterViaAdapter, resolveFileByPath, trashFile, } from "@/utils/vaultAdapterUtils"; @@ -1156,17 +1160,56 @@ export default class CopilotPlugin extends Plugin { } async loadChatById(fileId: string): Promise { + if (isNativeChatId(fileId)) { + await this.loadNativeAgentChat(fileId); + return; + } const file = await resolveFileByPath(this.app, fileId); if (!file) throw new Error("Chat file not found."); - const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; - if (frontmatter?.mode === AGENT_CHAT_MODE) { + // Hidden-folder notes (e.g. a dot-folder save location) aren't indexed by + // metadataCache, so fall back to an adapter read before deciding this + // isn't an agent chat — otherwise a hidden agent note that Recent Chats + // surfaces would misroute to the legacy chat loader instead of resuming + // the agent session. + const cachedMode = this.app.metadataCache.getFileCache(file)?.frontmatter?.mode; + let mode = typeof cachedMode === "string" ? cachedMode : undefined; + if (!mode) { + try { + const fm = await readFrontmatterViaAdapter(this.app, file.path); + if (typeof fm?.mode === "string") mode = fm.mode; + } catch { + // Leave mode undefined; routes to the legacy loader below. + } + } + if (mode === AGENT_CHAT_MODE) { await this.loadAgentChatHistory(file); return; } await this.loadChatHistory(file); } + /** + * Open a chat that lives only in a backend's native session store (recent + * chats entry with no markdown note). Resumes through the agent manager; + * recency tracking is handled by the session index rather than file + * frontmatter. + */ + private async loadNativeAgentChat(chatId: string): Promise { + const ref = parseNativeChatId(chatId); + if (!ref) throw new Error("Chat not found."); + const manager = this.requireAgentView(); + if (!manager) return; + const leaf = await this.activateAgentView(); + if (!leaf) return; + + await manager.loadNativeSessionFromHistory(ref.backendId, ref.sessionId); + + if (this.isCopilotAgentView(leaf.view)) { + leaf.view.updateView(); + } + } + private async loadAgentChatHistory(file: TFile): Promise { const manager = this.requireAgentView(); if (!manager) return; @@ -1182,6 +1225,12 @@ export default class CopilotPlugin extends Plugin { } async openChatSourceFile(fileId: string): Promise { + if (isNativeChatId(fileId)) { + new Notice( + "This chat has no saved note. Turn on Autosave Chat to save chats as notes in your vault." + ); + return; + } const file = this.app.vault.getAbstractFileByPath(fileId); if (file instanceof TFile) { await this.app.workspace.getLeaf(true).openFile(file); diff --git a/src/utils/nativeChatId.ts b/src/utils/nativeChatId.ts new file mode 100644 index 00000000..e1a24936 --- /dev/null +++ b/src/utils/nativeChatId.ts @@ -0,0 +1,36 @@ +/** + * Id scheme for recent-chats entries that live only in a backend's native + * session store (no markdown note to use as the id). Kept dependency-free in + * the host layer (directly under `src/agentMode/`, NOT in the barrel or + * `session/`) because `main.ts` routes these ids at load time on every + * platform — importing the `@/agentMode` barrel there would pull Node-only + * modules into the mobile bundle and crash a Node-less runtime. + */ + +/** `ChatHistoryItem.id` prefix marking a native-store (no markdown file) entry. */ +export const NATIVE_CHAT_ID_PREFIX = "copilot-agent-session://"; + +/** Encode a (backendId, sessionId) pair as a history-item id. */ +export function buildNativeChatId(backendId: string, sessionId: string): string { + return `${NATIVE_CHAT_ID_PREFIX}${backendId}/${encodeURIComponent(sessionId)}`; +} + +export function isNativeChatId(id: string): boolean { + return id.startsWith(NATIVE_CHAT_ID_PREFIX); +} + +/** Inverse of {@link buildNativeChatId}. Returns null for malformed ids. */ +export function parseNativeChatId(id: string): { backendId: string; sessionId: string } | null { + if (!isNativeChatId(id)) return null; + const rest = id.slice(NATIVE_CHAT_ID_PREFIX.length); + const sep = rest.indexOf("/"); + if (sep <= 0 || sep === rest.length - 1) return null; + try { + return { + backendId: rest.slice(0, sep), + sessionId: decodeURIComponent(rest.slice(sep + 1)), + }; + } catch { + return null; + } +}