feat(agent-mode): show recent chats from native harness history regardless of markdown autosave (#2591)

* feat(agent-mode): show recent chats from native harness history regardless of markdown autosave

Recent chats was built exclusively from autosaved agent__ markdown notes,
so turning Autosave Chat off emptied history even though every harness
still held a resumable session. Decouple the two concerns:

- New plugin-local AgentSessionIndex (.obsidian/plugins/<id>/
  agent-chat-index.json): write-through record of every session with
  user-visible messages (backendId, sessionId, title, createdAt,
  lastAccessedAt), independent of the autosaveChat setting. Works for
  all backends including Claude Code, whose SDK has no listSessions.
- getChatHistoryItems() merges markdown items with index entries,
  de-duplicated on backendId + sessionId (frontmatter already stores
  the session id); merged rows take the fresher lastAccessedAt.
- Opportunistic listSessions sweep of already-running backends
  (never spawns one), filtered to the vault cwd client-side, excluding
  the preloader probe session and untitled/placeholder sessions,
  bounded by a 1.5s timeout.
- Native-only rows get a copilot-agent-session:// id; selecting one
  resumes through the existing loadSession/resumeSession path. Rename
  updates the index (and the live session label); delete tombstones
  the key so the entry can't resurrect from a native sweep, without
  deleting the harness's own session store. Deleting a markdown chat
  tombstones its native twin too.
- Open-source-file on a native row explains there is no note instead
  of erroring.

Closes #151

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep native chat id helpers off the mobile load path; adapter frontmatter fallback for hidden-folder dedup

Two fixes from CI + review:

- main.ts statically value-imported @/agentMode for the native chat id
  helpers, which the mobile-load smoke test correctly rejects (any Agent
  Mode value import drags Node-only modules into the mobile bundle).
  Move the dependency-free id helpers to @/utils/nativeChatId, which
  both main.ts and agentMode/session may import.

- getChatHistoryItems read backendId/sessionId from the metadata cache
  only, so chats saved in hidden folders (never indexed by the cache)
  could not merge with their native twins and showed up twice. Reuse
  readSessionRefFromFile's adapter frontmatter fallback per file, with
  a regression test covering the hidden-folder case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: sweep warm preloader probes so native history shows on first Agent Home open

The listSessions sweep only queried manager-owned backends, which exist
only after a chat starts — so a fresh Agent Home open listed no native
history at all. The preloader's warm probe subprocesses are already
running for every installed backend at plugin load; sweep those too
(read-only RPC, entries not consumed), so codex/opencode session stores
surface immediately without paying any extra spawn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: user renames survive native sweeps; match live sessions by backend+session pair

Addresses two Codex review findings:

- mergeDiscoveredSessions preferred the discovered (agent-store) title,
  so a plugin-side rename of a native entry was clobbered by the next
  listSessions sweep. Track titleSource on index entries (mirroring
  AgentSession.labelSource): setTitle marks user, the write-through
  path rides the live label's source, and discovered merges never
  overwrite a user title.

- loadNativeSessionFromHistory matched live sessions by sessionId
  alone; a cross-backend id collision would focus the wrong tab.
  Require the (backendId, sessionId) pair.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: guard native rename's live-session lookup by backend+session pair

Same cross-backend id-collision class as the loadNativeSessionFromHistory
fix, at the updateChatTitle native branch: getSessionByBackendId matches
by session id alone, so renaming a native entry could setLabel the wrong
backend's live tab (and its index entry via the label autosave). Require
live.backendId === native.backendId before applying the label. Regression
test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: derive native chat title from first user message when no agent title exists

Claude Code's SDK exposes no session-title API, so CC native history rows
all read 'Untitled chat'. Fall back to a title derived from the first user
message (mirroring the markdown autosave filename/topic), recorded as an
overridable agent-sourced title so an opencode/codex summarizer title still
wins later and a user rename always wins. Makes autosave-off history
consistent with autosave-on, where Claude chats already get a readable
title from the first message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): make Recent Chats a searchable management list; drop redundant New chat row

The landing Recent Chats section was open-on-click preview rows with all
management hidden behind a 'View all' popover, plus a 'New chat' row that
duplicated what the landing already is.

- Recent Chats is now a searchable, scrollable list whose rows manage chats
  in place: open, rename (inline), delete (two-step confirm), and open the
  source note. Same affordances as the chat history popover, no separate
  view-all step.
- Go-to-file is conditional: shown only for markdown-saved chats; native
  (autosave-off) sessions have no note, so the action is hidden for them
  (gated on isNativeChatId).
- Removed the 'New chat' row — the home surface is already a new chat, and
  the tab strip '+' / in-tab New Chat cover spawning a tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-mode): rebuild Claude native chat transcript from on-disk session jsonl

Clicking a native (autosave-off) Claude chat in recent chats resumed the
session but showed nothing: the Claude SDK has no transcript API and
resumeSession returns no prior messages, so the chat opened empty and the
UI stayed on the landing — looking like the click did nothing.

Read the CLI's session record at
<config>/projects/<encoded-cwd>/<sessionId>.jsonl (cwd encoded with every
non-alphanumeric char -> '-', honoring CLAUDE_CONFIG_DIR) and parse it into
display messages: user prompts (stripping the <user-message> context
envelope) and assistant prose, skipping tool calls, tool results, meta,
sidechain, and summary records. Wired via an optional
BackendProcess.readPersistedTranscript that the manager calls on native
resume when the session came back empty; ACP backends replay via
loadSession and omit it. Best-effort — a missing/GC'd file degrades to the
resumed-but-blank session rather than failing the open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): render loaded transcript immediately and reuse the empty landing tab

Two history-open bugs:

1. Opening a chat showed a blank tab until you switched tabs and back. The
   manager mutated the store via store.loadMessages, which fires no change
   notification, so the runtime hook (subscribed to the session) only
   re-read on a backend-identity change — i.e. a tab switch. The native
   Claude path made it always reproduce: the await on the on-disk transcript
   read falls between activating the tab and loading messages. Add
   AgentSession.loadDisplayMessages (loadMessages + notifyMessages) and use it
   on both the markdown and native resume paths.

2. Opening a chat always spawned a new tab, even from an empty landing tab.
   absorbIntoEmptyActiveTab now gives the loaded session the empty active
   tab's strip position and closes the empty one, so opening a chat reuses
   the current tab when it has no conversation. A tab with real messages is
   never clobbered (opens a new tab in that case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): three native-history review findings (title source, delete race, claude config dir)

All three are legit P2s from Codex review:

- Native resume reapplied the index title via setLabel(), marking it
  user-sourced and freezing future agent title refreshes for resumed
  opencode/codex sessions. Add AgentSession.restoreLabel(label, source) and
  apply the index title with its recorded titleSource — user renames stay
  sticky, agent/derived titles stay refreshable. Tested both directions.

- Deleting a chat within the ~500ms index-touch debounce window let the
  already-queued flushIndexTouch re-add it after the tombstone was written
  (recordSession clears the tombstone), resurrecting it in Recent Chats.
  cancelPendingIndexTouch clears the pre-delete timer for the matching live
  session before tombstoning; post-delete activity still re-indexes.

- readPersistedTranscript resolved CLAUDE_CONFIG_DIR from process.env only,
  so users who set a custom config dir via Agent Mode env overrides got a
  blank restored chat. Resolve it with the same precedence the SDK is
  spawned with (env overrides > managed env > process.env > ~/.claude).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(agent-mode): match live sessions by backend+session id in one lookup

Codex follow-up: the native open/rename paths found the first live session by
sessionId and checked backendId after, so an (effectively impossible, UUID)
cross-backend id collision where the wrong-backend tab was created first would
hide the correct already-open tab. Add findLiveSession(backendId, sessionId)
that matches both at once and excludes closed sessions; use it in both
loadNativeSessionFromHistory and the updateChatTitle native branch. Existing
collision tests stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(agent-mode): inset the Recent Chats search box from the panel edges

Wrap the search bar in a small (p-1) padded container so it isn't flush
against the section's top/side edges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): adapter frontmatter fallback when routing a clicked chat

loadChatById decided agent-vs-legacy from metadataCache frontmatter only.
A hidden-folder agent note (dot-folder save location) isn't indexed by the
cache, so its mode: agent was invisible and the click routed to the legacy
Copilot chat loader instead of agent resume — even though Recent Chats now
surfaces such notes via the merge's adapter fallback. Read frontmatter via
the adapter when the cache misses before routing. Only runs on a cache miss,
so the common (visible folder) path is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): keep Recent Chats row actions reachable by keyboard

The row's action cluster (open-source/rename/delete) was tw-hidden until
group-hover, so keyboard users could focus the row and open the chat but
never reach the management buttons — they sit out of the tab order while
display:none. Reveal the cluster on group-focus-within too (and hide the
relative time then), so focusing the row exposes the actions and Tab can
move into them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(agent-mode): row Enter/Space opens only when the row itself is focused

After the focus-within a11y fix made the action buttons keyboard-focusable,
pressing Enter/Space on a focused rename/delete/open-source button bubbled
its keydown to the row handler, which also called onOpen — so managing a
chat by keyboard would additionally open/resume it (the buttons stop click
propagation, not keydown). Guard the row handler to act only when the row is
the keydown target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-10 21:35:46 -07:00 committed by GitHub
parent 4844e60e4f
commit f29768c69b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2292 additions and 208 deletions

36
package-lock.json generated
View file

@ -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": [

View file

@ -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

View file

@ -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 `<config>/projects/<encoded-cwd>/<sessionId>.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<AgentChatMessage[]> {
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<string> {
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

View file

@ -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, unknown>): 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 <user-message> envelope, dropping the context block", () => {
const wrapped =
"<copilot-context>\nNotes:\n- a.md\n</copilot-context>\n\n<user-message>\nsummarize a.md\n</user-message>";
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([]);
});
});

View file

@ -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 (`<sessionId>.jsonl` under
* `~/.claude/projects/<encoded-cwd>/`) 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 `<user-message>…</user-message>` envelope so the stored
* prompt (which prepends a `<copilot-context>` 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(/<user-message>\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);
}

View file

@ -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<void> {
if (this.disposed) return Promise.resolve();

View file

@ -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<typeof makeMockBackend>) {
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();

View file

@ -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;

View file

@ -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<string, string>): AgentSessionIndexStorage & {
files: Map<string, string>;
} {
const files = new Map<string, string>(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<Parameters<AgentSessionIndex["recordSession"]>[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);
});
});

View file

@ -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<string, number>;
}
/**
* Minimal file-IO surface the index needs. Production passes the vault
* `DataAdapter` (paths are vault-relative, so the file lands under
* `.obsidian/plugins/<id>/`); tests pass an in-memory fake.
*/
export interface AgentSessionIndexStorage {
exists(path: string): Promise<boolean>;
read(path: string): Promise<string>;
write(path: string, content: string): Promise<void>;
}
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<string, unknown>;
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<string, AgentSessionIndexEntry>();
private tombstones = new Map<string, number>();
private loadPromise: Promise<void> | null = null;
private saveTimer: number | null = null;
// Serializes writes so a slow disk can't interleave two snapshots.
private writeChain: Promise<void> = Promise.resolve();
constructor(
private readonly storage: AgentSessionIndexStorage,
private readonly filePath: string
) {}
/** All known entries, unsorted. Tombstoned sessions are never present. */
async getEntries(): Promise<AgentSessionIndexEntry[]> {
await this.ensureLoaded();
return Array.from(this.entries.values());
}
async getEntry(backendId: BackendId, sessionId: string): Promise<AgentSessionIndexEntry | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
await this.ensureLoaded();
return this.tombstones.has(entryKey(backendId, sessionId));
}
/** Force any pending debounced write to disk. */
async flush(): Promise<void> {
if (this.saveTimer !== null) {
window.clearTimeout(this.saveTimer);
this.saveTimer = null;
this.queueWrite();
}
await this.writeChain;
}
private ensureLoaded(): Promise<void> {
if (!this.loadPromise) {
this.loadPromise = this.loadFromDisk();
}
return this.loadPromise;
}
private async loadFromDisk(): Promise<void> {
try {
if (!(await this.storage.exists(this.filePath))) return;
const raw = JSON.parse(
await this.storage.read(this.filePath)
) as Partial<AgentSessionIndexFile> | 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);
}
}

View file

@ -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<string, string>();
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<string, FakeFrontmatter>;
/** Hidden-folder files: never in the metadata cache, read via adapter. */
hiddenFiles?: Record<string, string>;
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<typeof AgentSessionManager>[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<typeof AgentSessionManager>[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);
});
});

View file

@ -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<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise<T>((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<PermissionDecision>;
@ -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<ChatHistoryItem[]> {
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<void> {
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<void> {
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<string, unknown> | 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<void> {
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<BackendId, BackendProcess>();
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<void> {
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<AgentSession> {
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<void> {
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<void> {
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<void> {
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?.();

View file

@ -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> = {}): 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> = {}): 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();
});
});

View file

@ -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<string, AgentSessionIndexEntry>();
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;
}

View file

@ -681,6 +681,17 @@ export interface BackendProcess {
listSessions(params: ListSessionsInput): Promise<ListSessionsOutput>;
resumeSession(params: ResumeSessionInput): Promise<ResumeSessionOutput>;
loadSession(params: LoadSessionInput): Promise<LoadSessionOutput>;
/**
* 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<AgentChatMessage[]>;
/**
* Whether the backend can route MCP servers of the given transport.
* ACP runtime probes this from the agent's advertised capabilities; the

View file

@ -170,19 +170,6 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
// 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<AgentHomeProps> = ({
onDeleteChat={handleDeleteChat}
onOpenSourceFile={handleOpenSourceFile}
onLoadHistory={handleLoadChatHistory}
onCreate={handleCreateChat}
/>
),
},
@ -290,7 +276,6 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
projects,
chatHistoryItems,
handleProjectComingSoon,
handleCreateChat,
handleLoadChat,
handleUpdateChatTitle,
handleDeleteChat,

View file

@ -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<void>;
onUpdateTitle: (id: string, newTitle: string) => Promise<void>;
onDeleteChat: (id: string) => Promise<void>;
/**
* 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<void>;
/** 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 }> }) => (
<span
@ -81,115 +67,286 @@ ChatIconTile.displayName = "ChatIconTile";
interface RecentChatRowProps {
item: ChatHistoryItem;
/** Open by id; the row fires it and forgets (loads surface their own Notice). */
onOpen: (id: string) => void | Promise<void>;
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) => (
<AgentHomeListRow
label={item.title}
timeMs={item.lastAccessedAt.getTime()}
onClick={() => void onOpen(item.id)}
leading={<ChatIconTile Icon={resolveChatIcon(item) ?? MessageCircle} />}
/>
));
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 (
<div className={cn("tw-flex tw-flex-col tw-divide-y tw-divide-border", className)}>
{onCreate && <AgentHomeCreateRow label="New chat" onClick={onCreate} />}
{total === 0 ? (
<div className="tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">No recent chats</div>
) : (
<>
{inlineItems.map((item) => (
<RecentChatRow key={item.id} item={item} onOpen={onLoadChat} />
))}
{hasOverflow && (
<ChatHistoryPopover
chatHistory={items}
onUpdateTitle={onUpdateTitle}
onDeleteChat={onDeleteChat}
onLoadChat={onLoadChat}
onOpenSourceFile={onOpenSourceFile}
getIcon={resolveChatIcon}
// Full-width row near the pane's lower half: open downward like
// an accordion, left-aligned with the inline rows above. Radix
// flips to "top" if the area below is tight (e.g. mobile keyboard).
side="bottom"
align="start"
>
{/* 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. */}
<div
role="button"
tabIndex={0}
className="tw-flex tw-cursor-pointer tw-items-center tw-justify-between tw-rounded-md tw-px-2 tw-py-1.5 tw-text-xs tw-text-accent tw-transition-colors hover:tw-bg-modifier-hover hover:tw-text-accent-hover"
onClick={() => onLoadHistory?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.currentTarget.click();
}
}}
>
<span>View all chats</span>
<ChevronRight className="tw-size-3 tw-shrink-0" />
</div>
</ChatHistoryPopover>
)}
</>
)}
<div className="tw-flex tw-min-h-9 tw-items-center tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5">
<ChatIconTile Icon={Icon} />
<Input
value={editingTitle}
onChange={(e) => 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();
}}
/>
<Button size="sm" variant="ghost" onClick={onSaveEdit} className="tw-size-5 tw-p-0">
<Check className="tw-size-3" />
</Button>
<Button size="sm" variant="ghost" onClick={onCancelEdit} className="tw-size-5 tw-p-0">
<X className="tw-size-3" />
</Button>
</div>
);
}
);
return (
<div
role="button"
tabIndex={0}
className={cn(
"tw-group tw-flex tw-min-h-9 tw-cursor-pointer tw-items-center tw-gap-2 tw-rounded-md tw-px-2 tw-py-1.5",
"tw-text-left tw-transition-colors hover:tw-bg-modifier-hover"
)}
onClick={() => 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);
}
}}
>
<ChatIconTile Icon={Icon} />
<span
className="tw-min-w-0 tw-flex-1 tw-truncate tw-text-ui-small tw-text-normal"
title={item.title}
>
{item.title}
</span>
{/* 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. */}
<span
className="tw-shrink-0 tw-whitespace-nowrap tw-text-xs tw-text-muted group-focus-within:tw-hidden group-hover:tw-hidden"
title={new Date(item.lastAccessedAt).toLocaleString()}
>
{formatCompactRelativeTime(item.lastAccessedAt.getTime())}
</span>
<div className="tw-hidden tw-shrink-0 tw-items-center tw-gap-1.5 group-focus-within:tw-flex group-hover:tw-flex">
{confirmingDelete ? (
<>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onConfirmDelete(item.id);
}}
className="tw-size-5 tw-p-0 tw-text-error hover:tw-text-error"
title="Confirm delete"
>
<Check className="tw-size-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onCancelDelete();
}}
className="tw-size-5 tw-p-0"
title="Cancel"
>
<X className="tw-size-3" />
</Button>
</>
) : (
<>
{canOpenSourceFile && (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onOpenSourceFile(item.id);
}}
className="tw-size-5 tw-p-0"
title="Open source note"
>
<ArrowUpRight className="tw-size-4" />
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onStartEdit(item.id, item.title);
}}
className="tw-size-5 tw-p-0"
title="Rename"
>
<Edit2 className="tw-size-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onStartDelete(item.id);
}}
className="tw-size-5 tw-p-0 tw-text-error hover:tw-text-error"
title="Delete"
>
<Trash2 className="tw-size-3" />
</Button>
</>
)}
</div>
</div>
);
});
/**
* "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<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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 (
<div className={cn("tw-flex tw-flex-col tw-gap-2", className)}>
{items.length > 0 && (
<div className="tw-p-1">
<SearchBar value={query} onChange={setQuery} placeholder="Search chats..." />
</div>
)}
{filteredItems.length === 0 ? (
<div className="tw-px-2 tw-py-1.5 tw-text-xs tw-text-muted">
{items.length === 0 ? "No recent chats" : "No matching chats"}
</div>
) : (
<ScrollArea className="tw-max-h-80 tw-overflow-y-auto">
<div className="tw-flex tw-flex-col tw-divide-y tw-divide-border">
{filteredItems.map((item) => (
<RecentChatRow
key={item.id}
item={item}
isEditing={editingId === item.id}
editingTitle={editingTitle}
confirmingDelete={confirmDeleteId === item.id}
onOpen={onLoadChat}
onStartEdit={handleStartEdit}
onEditingTitleChange={setEditingTitle}
onSaveEdit={handleSaveEdit}
onCancelEdit={() => setEditingId(null)}
onStartDelete={setConfirmDeleteId}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={() => setConfirmDeleteId(null)}
canOpenSourceFile={!isNativeChatId(item.id)}
onOpenSourceFile={handleOpenSourceFile}
/>
))}
</div>
</ScrollArea>
)}
</div>
);
});
GlobalRecentChatsSection.displayName = "GlobalRecentChatsSection";

View file

@ -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<void> {
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<void> {
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<void> {
const manager = this.requireAgentView();
if (!manager) return;
@ -1182,6 +1225,12 @@ export default class CopilotPlugin extends Plugin {
}
async openChatSourceFile(fileId: string): Promise<void> {
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);

36
src/utils/nativeChatId.ts Normal file
View file

@ -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;
}
}