mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* 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>
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
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([]);
|
|
});
|
|
});
|