diff --git a/src/ipc.test.ts b/src/ipc.test.ts index f704a76..07b01be 100644 --- a/src/ipc.test.ts +++ b/src/ipc.test.ts @@ -10,6 +10,8 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({ open: (...a: unknown[]) => open(.. import { addAgentRoot, chooseAgentRootDirectory, + HIGHLIGHT_END, + HIGHLIGHT_START, listDetectedAgents, listFolderSessionsPage, listRepositorySessionsPage, @@ -19,6 +21,8 @@ import { removeAgentRoot, rescan, } from "./ipc"; +import source from "./ipc?raw"; +import { createMockHandlers } from "./mock/ipc-mock"; beforeEach(() => { invoke.mockReset(); @@ -254,3 +258,106 @@ describe("ipc contract", () => { expect(updateResult.current_version).toBe("0.1.0"); }); }); + +// The browser-only sample-data mock must answer every command `ipc.ts` sends. +// The commands are read from the source, so a new `invoke("...")` without a +// handler in `src/mock/ipc-mock.ts` fails here. +describe("browser sample-data mock", () => { + const commands = [ + ...new Set([...source.matchAll(/invoke(?:<[^(]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1])), + ]; + const events = [ + ...new Set([...source.matchAll(/listen(?:<[^(]*>)?\(\s*"([a-z_]+)"/g)].map((m) => m[1])), + ]; + + function makeMock() { + const emitted: Array<[string, unknown]> = []; + const handlers = createMockHandlers((event, payload) => emitted.push([event, payload]), { + now: Date.UTC(2026, 0, 1), + scanStepMs: 0, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const call = async (cmd: string, args: Record = {}): Promise => + handlers[cmd](args); + return { handlers, emitted, call }; + } + + it("finds the commands and events ipc.ts uses", () => { + expect(commands).toContain("list_sessions_page"); + expect(commands).toContain("rescan"); + expect(events).toContain("scan_progress"); + }); + + it("has a handler for every command ipc.ts invokes", () => { + const { handlers } = makeMock(); + expect(commands.filter((cmd) => typeof handlers[cmd] !== "function")).toEqual([]); + }); + + it("wraps search matches in the backend's highlight markers", async () => { + const { call } = makeMock(); + const page = await call("search_page", { query: "rounding", limit: 50, cursor: null, sort: "relevance" }); + expect(page.hits.length).toBeGreaterThan(0); + for (const hit of page.hits) { + expect(hit.snippet).toContain(HIGHLIGHT_START); + expect(hit.snippet).toContain(HIGHLIGHT_END); + } + }); + + it("returns nothing for a word that is not in the sample data", async () => { + const { call } = makeMock(); + const page = await call("search_page", { query: "zzyzxnotaword", limit: 50, cursor: null, sort: "relevance" }); + expect(page).toEqual({ hits: [], next_cursor: null }); + }); + + it("pages search results without repeats and rejects a cursor from another query", async () => { + const { call } = makeMock(); + const args = { query: "tests", limit: 50, sort: "relevance" }; + const first = await call("search_page", { ...args, cursor: null }); + expect(first.next_cursor).not.toBeNull(); + const second = await call("search_page", { ...args, cursor: first.next_cursor }); + expect(second.hits.length).toBeGreaterThan(0); + const ids = [...first.hits, ...second.hits].map((h: { source_id: string }) => h.source_id); + expect(new Set(ids).size).toBe(ids.length); + await expect( + call("search_page", { ...args, query: "other", cursor: first.next_cursor }), + ).rejects.toThrow(/cursor/); + }); + + it("pages sessions newest first by cursor", async () => { + const { call } = makeMock(); + const first = await call("list_sessions_page", { limit: 100, cursor: null }); + const second = await call("list_sessions_page", { limit: 100, cursor: first.next_cursor }); + expect(first.sessions).toHaveLength(100); + expect(second.sessions.length).toBeGreaterThan(0); + const ids = [...first.sessions, ...second.sessions].map((s: { id: string }) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("keeps settings and the backup schedule in memory", async () => { + const { call } = makeMock(); + await call("set_setting", { key: "theme", valueJson: '"dark"' }); + expect(await call("get_setting", { key: "theme" })).toBe('"dark"'); + await call("set_backup_schedule", { interval: "daily", keep: 3 }); + expect(await call("get_backup_schedule")).toEqual({ interval: "daily", keep: 3 }); + }); + + it("emits scan_progress events during a rescan and ends with done", async () => { + const { call, emitted } = makeMock(); + const result = await call("rescan"); + const progress = emitted + .filter(([event]) => event === "scan_progress") + .map(([, payload]) => payload as { done: boolean }); + expect(progress.length).toBeGreaterThan(2); + expect(progress[progress.length - 1].done).toBe(true); + expect(result.discovered).toBeGreaterThan(0); + }); + + it("restores the sample sessions on rescan after forget everything", async () => { + const { call } = makeMock(); + await call("forget_everything"); + expect((await call("list_sessions_page", { limit: 10, cursor: null })).sessions).toEqual([]); + const result = await call("rescan"); + expect(result.ingested).toBe(result.discovered); + expect((await call("list_sessions_page", { limit: 10, cursor: null })).sessions).toHaveLength(10); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index 877dcda..da580d1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,13 +5,29 @@ import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; import "./styles.css"; +/** + * `npm run dev` in a plain browser has no Tauri shell, so serve the archive UI + * from in-memory sample data. The branch needs both checks: `import.meta.env.DEV` + * is false in a production build, so the bundler drops the mock import entirely, + * and `__TAURI_INTERNALS__` is present inside the shell, which keeps + * `npm run tauri dev` on the real backend. + */ +async function installBrowserMock(): Promise { + if (import.meta.env.DEV && !("__TAURI_INTERNALS__" in window)) { + const { installIpcMock } = await import("./mock/ipc-mock"); + installIpcMock(); + } +} + const root = document.getElementById("root"); if (root) { - ReactDOM.createRoot(root).render( - - - - - , - ); + void installBrowserMock().then(() => { + ReactDOM.createRoot(root).render( + + + + + , + ); + }); } diff --git a/src/mock/ipc-mock.ts b/src/mock/ipc-mock.ts new file mode 100644 index 0000000..034c5e0 --- /dev/null +++ b/src/mock/ipc-mock.ts @@ -0,0 +1,531 @@ +// Browser-only stand-in for the Tauri shell. `npm run dev` in a plain browser +// has no Rust backend, so every `invoke` and `listen` call would fail. This +// module answers them from the in-memory archive in `sample-data.ts`. +// +// It is reachable only through the dynamic import in `main.tsx`, which is +// guarded by `import.meta.env.DEV` and a runtime "not inside Tauri" check. A +// production build drops that branch, so this code never ships in the app. +// +// `createMockHandlers` is the handler table keyed by command name. A test in +// `ipc.test.ts` derives the command list from `ipc.ts` and fails if one has no +// handler here. + +import { mockIPC } from "@tauri-apps/api/mocks"; + +import { + HIGHLIGHT_END, + HIGHLIGHT_START, + type DetectedAgent, + type FolderSummary, + type MessagePage, + type RepositorySummary, + type ScanProgress, + type SearchHit, + type SearchPage, + type SessionDetail, + type SessionPage, + type SessionSummary, +} from "../ipc"; +import { + SAMPLE_AGENTS, + SAMPLE_BACKUP_SCHEDULE, + SAMPLE_FOLDERS, + SAMPLE_FOLDER_ASSIGNMENTS, + SAMPLE_REPOSITORIES, + SAMPLE_SETTINGS, + buildSampleSessions, + type SampleSession, +} from "./sample-data"; + +type Args = Record; +export type MockHandler = (args: Args) => unknown; +export type MockEmit = (event: string, payload: unknown) => void; + +export interface MockOptions { + /** Clock anchor for the sample timestamps. Defaults to load time. */ + now?: number; + /** Pause between `scan_progress` events during `rescan`. */ + scanStepMs?: number; +} + +const DETAIL_MESSAGES = 200; +const MAX_LIMIT = 200; +const SAMPLE_ROOT_PICK = "/Users/you/sample-sessions"; +const BACKUP_INTERVALS = ["off", "daily", "weekly"]; + +const asString = (value: unknown): string => (typeof value === "string" ? value : String(value ?? "")); +const asLimit = (value: unknown, fallback: number): number => { + const n = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback; + return Math.min(Math.max(n, 1), MAX_LIMIT); +}; +const asCursor = (value: unknown): string | null => (typeof value === "string" ? value : null); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// ── Search ─────────────────────────────────────────────────────────────── + +const WORD = "[\\p{L}\\p{N}_]"; + +function queryTokens(query: string): string[] { + return [...new Set(query.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])]; +} + +function escapeRegex(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Matches whole words that start with any query token, as FTS5 prefix terms do. */ +function tokenPattern(tokens: string[]): RegExp { + const alternatives = [...tokens].sort((a, b) => b.length - a.length).map(escapeRegex); + return new RegExp(`(? (w.index ?? 0) + w[0].length > Math.max(first, 0)); + const lo = Math.max(0, at - 6); + const hi = Math.min(words.length, at + 7); + const slice = words.length + ? flat.slice(words[lo].index ?? 0, (words[hi - 1].index ?? 0) + words[hi - 1][0].length) + : flat; + const marked = slice.replace(tokenPattern(tokens), (m) => `${HIGHLIGHT_START}${m}${HIGHLIGHT_END}`); + return `${lo > 0 ? "…" : ""}${marked}${hi < words.length ? "…" : ""}`; +} + +interface Doc { + session: SampleSession; + source_kind: string; + source_id: string; + field: string; + text: string; +} + +function* documents(sessions: SampleSession[]): Generator { + for (const session of sessions) { + const title = session.summary.title; + if (title) { + yield { session, source_kind: "session", source_id: session.summary.id, field: "title", text: title }; + } + for (const message of session.messages) { + for (const part of message.parts) { + if (!part.searchable) continue; + if (part.text != null) { + yield { session, source_kind: "message_part", source_id: `${message.id}:${part.ordinal}`, field: "text", text: part.text }; + } else if (part.content_json != null) { + yield { session, source_kind: "message_part", source_id: `${message.id}:${part.ordinal}`, field: "content_json", text: part.content_json }; + } + } + } + for (const [id, patch] of Object.entries(session.patches)) { + yield { session, source_kind: "file_event", source_id: id, field: "patch", text: patch }; + } + } +} + +type SortOrder = "relevance" | "newest" | "oldest"; + +function runSearch(sessions: SampleSession[], query: string, sort: SortOrder): SearchHit[] { + const tokens = queryTokens(query); + if (tokens.length === 0) return []; + const hits: SearchHit[] = []; + for (const doc of documents(sessions)) { + const lowered = doc.text.toLowerCase(); + const counts = tokens.map((token) => countMatches(lowered, token)); + if (counts.some((n) => n === 0)) continue; // every term must match + const bonus = doc.field === "title" ? 2 : 0; + hits.push({ + session_id: doc.session.summary.id, + source_kind: doc.source_kind, + source_id: doc.source_id, + field: doc.field, + snippet: makeSnippet(doc.text, tokens), + rank: -(counts.reduce((a, b) => a + b, 0) + bonus), + title: doc.session.summary.title, + agent_id: doc.session.summary.agent_id, + started_at: doc.session.summary.started_at, + }); + } + const byRecency = (a: SearchHit, b: SearchHit) => (b.started_at ?? 0) - (a.started_at ?? 0); + return hits.sort((a, b) => { + const primary = + sort === "newest" ? byRecency(a, b) : sort === "oldest" ? -byRecency(a, b) : a.rank - b.rank || byRecency(a, b); + return primary || a.source_id.localeCompare(b.source_id); + }); +} + +// ── Paging ─────────────────────────────────────────────────────────────── + +/** Search cursors are bound to the query and sort that made them, like the real ones. */ +function searchOffset(cursor: string | null, query: string, sort: SortOrder): number { + if (cursor === null) return 0; + const [cursorSort, cursorQuery, offset] = [ + cursor.slice(0, cursor.indexOf("|")), + cursor.slice(cursor.indexOf("|") + 1, cursor.lastIndexOf("|")), + Number(cursor.slice(cursor.lastIndexOf("|") + 1)), + ]; + if (cursorSort !== sort || cursorQuery !== query || !Number.isInteger(offset) || offset < 0) { + throw new Error("search cursor does not match this query and sort"); + } + return offset; +} + +/** Session cursors are keyset positions `(started_at, id)` in newest-first order. */ +function sessionPage(sessions: SessionSummary[], limit: number, cursor: string | null): SessionPage { + let rest = sessions; + if (cursor !== null) { + const split = cursor.indexOf("|"); + const startedAt = Number(cursor.slice(0, split)); + const id = cursor.slice(split + 1); + if (split < 1 || !Number.isFinite(startedAt)) throw new Error("invalid session cursor"); + rest = sessions.filter((s) => { + const t = s.started_at ?? 0; + return t < startedAt || (t === startedAt && s.id > id); + }); + } + const page = rest.slice(0, limit); + const last = page[page.length - 1]; + return { + sessions: page, + next_cursor: rest.length > limit && last ? `${last.started_at ?? 0}|${last.id}` : null, + }; +} + +function messageOffset(cursor: string | null): number { + if (cursor === null) return 0; + const offset = Number(cursor.replace(/^m:/, "")); + if (!Number.isInteger(offset) || offset < 0) throw new Error("invalid message cursor"); + return offset; +} + +// ── Markdown export ────────────────────────────────────────────────────── + +function toMarkdown(session: SampleSession): string { + const { summary } = session; + const lines = [`# ${summary.title ?? "(untitled session)"}`, "", `- Agent: ${summary.agent_id}`]; + if (summary.primary_model) lines.push(`- Model: ${summary.primary_model}`); + lines.push(`- Messages: ${summary.message_count}`, ""); + for (const message of session.messages) { + lines.push(`## ${message.role}`, ""); + for (const part of message.parts) { + if (part.text != null) lines.push(part.text, ""); + else if (part.content_json != null) lines.push("```json", part.content_json, "```", ""); + } + } + return lines.join("\n"); +} + +function downloadText(filename: string, text: string): boolean { + if (typeof document === "undefined") return false; + const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + return true; +} + +// ── The handler table ──────────────────────────────────────────────────── + +/** Build a fresh in-memory archive and the handler for every IPC command. */ +export function createMockHandlers( + emit: MockEmit, + { now = Date.now(), scanStepMs = 220 }: MockOptions = {}, +): Record { + let sessions = buildSampleSessions(now); + let folders = SAMPLE_FOLDERS.map((f) => ({ ...f })); + let assignments = new Map(Object.entries(SAMPLE_FOLDER_ASSIGNMENTS)); + let settings = new Map(Object.entries(SAMPLE_SETTINGS)); + let backup = { ...SAMPLE_BACKUP_SCHEDULE }; + let nextFolder = 1; + const customRoots = new Map(); + + const find = (id: unknown) => sessions.find((s) => s.summary.id === asString(id)); + const summaries = () => sessions.map((s) => s.summary); + const inRepository = (repoId: string) => + sessions.filter((s) => s.segments.some((seg) => seg.repository_id === repoId)).map((s) => s.summary); + const inFolder = (folderId: string) => + sessions.filter((s) => assignments.get(s.summary.id) === folderId).map((s) => s.summary); + + const folderSummary = (f: (typeof folders)[number]): FolderSummary => ({ + ...f, + session_count: inFolder(f.id).length, + }); + const repositorySummary = (r: (typeof SAMPLE_REPOSITORIES)[number]): RepositorySummary => ({ + ...r, + session_count: inRepository(r.id).length, + }); + const detectedAgent = (a: (typeof SAMPLE_AGENTS)[number]): DetectedAgent => { + const custom = customRoots.get(a.id) ?? []; + return { + ...a, + roots: [...a.roots, ...custom], + custom_roots: custom, + session_count: sessions.filter((s) => s.summary.agent_id === a.id).length, + }; + }; + + const detail = (s: SampleSession): SessionDetail => ({ + summary: s.summary, + parse_note: s.parse_note, + segments: s.segments, + messages: s.messages.slice(0, DETAIL_MESSAGES), + file_events: s.file_events, + next_message_cursor: s.messages.length > DETAIL_MESSAGES ? `m:${DETAIL_MESSAGES}` : null, + }); + + const searchPageFor = (args: Args): SearchPage => { + const query = asString(args.query); + const sort = (["newest", "oldest"].includes(asString(args.sort)) ? args.sort : "relevance") as SortOrder; + const limit = asLimit(args.limit, 50); + const offset = searchOffset(asCursor(args.cursor), query, sort); + const all = runSearch(sessions, query, sort); + const hits = all.slice(offset, offset + limit); + return { + hits, + next_cursor: offset + limit < all.length ? `${sort}|${query}|${offset + limit}` : null, + }; + }; + + const forget = (list: SampleSession[]) => ({ + blobs_removed: list.reduce((n, s) => n + Object.keys(s.patches).length, 0), + source_paths: list.map((s) => s.source_path), + }); + + return { + // Sessions and repositories + list_detected_agents: () => SAMPLE_AGENTS.map(detectedAgent), + list_sessions: (a) => summaries().slice(0, asLimit(a.limit, 50)), + list_sessions_page: (a) => sessionPage(summaries(), asLimit(a.limit, 50), asCursor(a.cursor)), + list_repositories: () => + SAMPLE_REPOSITORIES.map(repositorySummary).sort( + (x, y) => y.session_count - x.session_count || x.display_name.localeCompare(y.display_name), + ), + list_repository_sessions: (a) => inRepository(asString(a.id)).slice(0, asLimit(a.limit, 50)), + list_repository_sessions_page: (a) => + sessionPage(inRepository(asString(a.id)), asLimit(a.limit, 50), asCursor(a.cursor)), + get_session: (a) => { + const found = find(a.id); + return found ? detail(found) : null; + }, + list_session_messages_page: (a): MessagePage => { + const found = find(a.id); + if (!found) return { messages: [], next_cursor: null }; + const limit = asLimit(a.limit, 200); + const offset = messageOffset(asCursor(a.cursor)); + return { + messages: found.messages.slice(offset, offset + limit), + next_cursor: offset + limit < found.messages.length ? `m:${offset + limit}` : null, + }; + }, + get_git_snapshot: (a) => find(a.id)?.git ?? [], + get_file_patch: (a) => { + const id = asString(a.id); + for (const s of sessions) if (id in s.patches) return s.patches[id]; + return null; + }, + session_secret_count: (a) => find(a.id)?.secret_count ?? 0, + relink_segment_repository: (a) => { + const repo = SAMPLE_REPOSITORIES.find((r) => r.id === asString(a.repositoryId)); + if (!repo) throw new Error(`unknown repository ${asString(a.repositoryId)}`); + for (const s of sessions) { + const segment = s.segments.find((seg) => seg.id === asString(a.segmentId)); + if (segment) { + segment.repository_id = repo.id; + segment.resolution_confidence = "high"; + return null; + } + } + throw new Error(`unknown segment ${asString(a.segmentId)}`); + }, + + // Agent roots + add_agent_root: (a) => { + const agent = asString(a.agentId); + const path = asString(a.path); + const current = customRoots.get(agent) ?? []; + if (!current.includes(path)) customRoots.set(agent, [...current, path]); + return null; + }, + remove_agent_root: (a) => { + const agent = asString(a.agentId); + customRoots.set( + agent, + (customRoots.get(agent) ?? []).filter((p) => p !== asString(a.path)), + ); + return null; + }, + + // Folders + list_folders: () => + folders + .map(folderSummary) + .sort((x, y) => x.position - y.position || x.name.localeCompare(y.name)), + create_folder: (a) => { + const name = asString(a.name).trim().slice(0, 80); + if (!name) throw new Error("A folder needs a name."); + const folder = { + id: `folder-new-${nextFolder++}`, + name, + position: folders.reduce((max, f) => Math.max(max, f.position), -1) + 1, + }; + folders = [...folders, folder]; + return folderSummary(folder); + }, + rename_folder: (a) => { + const name = asString(a.name).trim().slice(0, 80); + if (!name) throw new Error("A folder needs a name."); + folders = folders.map((f) => (f.id === asString(a.id) ? { ...f, name } : f)); + return null; + }, + delete_folder: (a) => { + const id = asString(a.id); + folders = folders.filter((f) => f.id !== id); + assignments = new Map([...assignments].filter(([, folder]) => folder !== id)); + return null; + }, + set_session_folder: (a) => { + const sessionId = asString(a.sessionId); + if (a.folderId == null) assignments.delete(sessionId); + else assignments.set(sessionId, asString(a.folderId)); + return null; + }, + list_folder_sessions_page: (a) => + sessionPage(inFolder(asString(a.id)), asLimit(a.limit, 50), asCursor(a.cursor)), + + // Search + search: (a) => runSearch(sessions, asString(a.query), "relevance").slice(0, asLimit(a.limit, 50)), + search_page: (a) => searchPageFor(a), + + // Export and forget + export_session_markdown: (a) => { + const found = find(a.id); + return found ? toMarkdown(found) : null; + }, + save_session_export: (a) => { + const found = find(a.id); + return found ? downloadText(`${found.summary.id}.md`, toMarkdown(found)) : false; + }, + forget_session: (a) => { + const found = find(a.id); + sessions = sessions.filter((s) => s !== found); + assignments.delete(asString(a.id)); + return forget(found ? [found] : []); + }, + forget_everything: () => { + const report = forget(sessions); + sessions = []; + folders = []; + assignments = new Map(); + settings = new Map(); + return report; + }, + + // Scanning: the sample logs stay "on disk", so a rescan after "forget + // everything" re-ingests them, and an unchanged archive is all skipped. + rescan: async () => { + const all = buildSampleSessions(now); + const present = new Set(sessions.map((s) => s.summary.id)); + const chunk = Math.ceil(all.length / 4); + const tally: ScanProgress = { discovered: all.length, ingested: 0, skipped: 0, failed: 0, enriched: 0, done: false }; + for (let i = 0; i < all.length; i += chunk) { + for (const session of all.slice(i, i + chunk)) { + if (present.has(session.summary.id)) { + tally.skipped += 1; + } else { + sessions = [...sessions, session]; + tally.ingested += 1; + if (session.segments.some((seg) => seg.repository_id !== null)) tally.enriched += 1; + } + } + sessions.sort( + (x, y) => (y.summary.started_at ?? 0) - (x.summary.started_at ?? 0) || x.summary.id.localeCompare(y.summary.id), + ); + emit("scan_progress", { ...tally }); + await sleep(scanStepMs); + } + emit("scan_progress", { ...tally, done: true }); + const { discovered, ingested, skipped, failed, enriched } = tally; + return { discovered, ingested, skipped, failed, enriched }; + }, + + // Settings and backups + get_setting: (a) => settings.get(asString(a.key)) ?? null, + set_setting: (a) => { + settings.set(asString(a.key), asString(a.valueJson)); + return null; + }, + get_backup_schedule: () => ({ ...backup }), + set_backup_schedule: (a) => { + const interval = asString(a.interval); + if (!BACKUP_INTERVALS.includes(interval)) throw new Error(`unknown backup interval "${interval}"`); + backup = { interval, keep: Math.max(1, Math.floor(Number(a.keep) || 1)) }; + return null; + }, + backup_now: () => null, + + // Usage and updates + get_token_totals: () => ({ + total_input_tokens: sessions.reduce((n, s) => n + (s.summary.total_input_tokens ?? 0), 0), + total_output_tokens: sessions.reduce((n, s) => n + (s.summary.total_output_tokens ?? 0), 0), + total_cache_tokens: sessions.reduce((n, s) => n + (s.summary.total_cache_tokens ?? 0), 0), + est_cost_usd: sessions.reduce((n, s) => n + (s.summary.est_cost_usd ?? 0), 0), + }), + check_for_updates: () => ({ + update_available: false, + current_version: "0.0.0-sample", + latest_version: null, + release_notes: null, + published_at: null, + }), + + // The native folder picker (`@tauri-apps/plugin-dialog`). + "plugin:dialog|open": () => SAMPLE_ROOT_PICK, + }; +} + +// ── Installation ───────────────────────────────────────────────────────── + +/** + * Route `invoke` and `listen` to the sample archive. Events use a small bus of + * our own: the stock `shouldMockEvents` unlisten matches on the wrong field and + * leaves dead callbacks behind, which warns on every later emit. + */ +export function installIpcMock(options?: MockOptions): void { + const listeners = new Map>(); + const emit: MockEmit = (event, payload) => { + const internals = (window as unknown as { + __TAURI_INTERNALS__: { runCallback: (id: number, data: unknown) => void }; + }).__TAURI_INTERNALS__; + for (const id of listeners.get(event) ?? []) { + internals.runCallback(id, { event, id, payload }); + } + }; + const handlers = createMockHandlers(emit, options); + + mockIPC(async (cmd, payload) => { + const args = (payload ?? {}) as Args; + switch (cmd) { + case "plugin:event|listen": { + const id = args.handler as number; + listeners.set(asString(args.event), (listeners.get(asString(args.event)) ?? new Set()).add(id)); + return id; + } + case "plugin:event|unlisten": + listeners.get(asString(args.event))?.delete(args.eventId as number); + return null; + case "plugin:event|emit": + emit(asString(args.event), args.payload); + return null; + } + const handler = Object.prototype.hasOwnProperty.call(handlers, cmd) ? handlers[cmd] : undefined; + if (!handler) throw new Error(`The browser sample-data mock has no handler for "${cmd}".`); + return handler(args); + }); +} diff --git a/src/mock/sample-data.ts b/src/mock/sample-data.ts new file mode 100644 index 0000000..bb04569 --- /dev/null +++ b/src/mock/sample-data.ts @@ -0,0 +1,701 @@ +// One fixed in-memory archive for the browser-only dev mock (`npm run dev` +// outside the Tauri shell). Every record is typed with the generated +// `lore-ipc` DTOs, so a contract change breaks this file at typecheck time. +// +// The content is deterministic. Only the clock anchor moves: each timestamp is +// `now` minus a fixed offset, so "2h ago" stays "2h ago" whenever the page loads. +// +// This module is reachable only from `ipc-mock.ts`, which `main.tsx` imports +// behind `import.meta.env.DEV`, so none of it ships in a production build. + +import type { + BackupScheduleDto, + DetectedAgent, + FileEventDto, + FolderSummary, + GitObservationDto, + MessageDto, + MessagePartDto, + RepositorySummary, + SegmentDto, + SessionSummary, +} from "../ipc"; + +const MIN = 60_000; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +/** Everything the mock knows about one archived session. */ +export interface SampleSession { + summary: SessionSummary; + parse_note: string | null; + segments: SegmentDto[]; + /** The full timeline; `get_session` returns only the first page of it. */ + messages: MessageDto[]; + file_events: FileEventDto[]; + git: GitObservationDto[]; + /** Recorded patch text keyed by file event id. */ + patches: Record; + secret_count: number; + /** The original agent log, reported by `forget_session` (Lore never deletes it). */ + source_path: string; +} + +export type SampleRepository = Omit; +export type SampleFolder = Omit; + +export const SAMPLE_REPOSITORIES: SampleRepository[] = [ + { + id: "repo-acme-storefront", + display_name: "acme-storefront", + identity_confidence: "confirmed", + primary_path: "/Users/you/code/acme-storefront", + is_missing: false, + worktree_count: 2, + }, + { + id: "repo-pricing-service", + display_name: "pricing-service", + identity_confidence: "high", + primary_path: "/Users/you/code/pricing-service", + is_missing: false, + worktree_count: 1, + }, + { + id: "repo-notes-site", + display_name: "notes-site", + identity_confidence: "medium", + primary_path: "/Users/you/code/notes-site", + is_missing: false, + worktree_count: 1, + }, + { + id: "repo-legacy-admin", + display_name: "legacy-admin", + identity_confidence: "low", + primary_path: "/Users/you/code/legacy-admin", + is_missing: true, + worktree_count: 0, + }, +]; + +/** Folders and which sessions start filed in them. */ +export const SAMPLE_FOLDERS: SampleFolder[] = [ + { id: "folder-billing", name: "Billing rework", position: 0 }, + { id: "folder-reading", name: "Worth re-reading", position: 1 }, +]; +export const SAMPLE_FOLDER_ASSIGNMENTS: Record = { + "ses-claude-rounding": "folder-billing", + "ses-codex-reducer": "folder-billing", + "ses-claude-retry": "folder-billing", + "ses-claude-webhook": "folder-reading", +}; + +export const SAMPLE_AGENTS: Omit[] = [ + { + id: "claude-code", + display_name: "Claude Code", + installed: true, + version: "2.1.0", + roots: ["/Users/you/.claude/projects"], + custom_roots: [], + }, + { + id: "codex", + display_name: "Codex", + installed: true, + version: "0.4.2", + roots: ["/Users/you/.codex/sessions"], + custom_roots: [], + }, +]; + +export const SAMPLE_SETTINGS: Record = { + theme: JSON.stringify("system"), + "ui.density": JSON.stringify("comfortable"), +}; + +export const SAMPLE_BACKUP_SCHEDULE: BackupScheduleDto = { interval: "weekly", keep: 7 }; + +// ── Builders ───────────────────────────────────────────────────────────── + +type Agent = "claude-code" | "codex"; +type Turn = readonly [role: "user" | "assistant", text: string, tool?: ToolCall]; +interface ToolCall { + name: string; + input: Record; +} +interface FileSpec { + path: string; + kind: string; + added: number; + removed: number; + patch?: string; +} +interface SessionSpec { + id: string; + agent: Agent; + title: string; + ago: number; + minutes: number; + repo: string | null; + turns: readonly Turn[]; + files?: readonly FileSpec[]; + status?: "ok" | "partial" | "failed"; + note?: string; + secrets?: number; +} + +const MODEL: Record = { + "claude-code": "claude-sonnet-4-5", + codex: "gpt-5-codex", +}; +const PROVIDER: Record = { "claude-code": "anthropic", codex: "openai" }; +const BRANCH: Record = { + "repo-acme-storefront": "feature/checkout-totals", + "repo-pricing-service": "main", + "repo-notes-site": "docs/quick-start", + "repo-legacy-admin": "master", +}; + +function fakeSha(seed: string): string { + let h = 2166136261; + let out = ""; + for (let round = 0; out.length < 40; round += 1) { + for (let i = 0; i < seed.length; i += 1) { + h = Math.imul(h ^ (seed.charCodeAt(i) + round), 16777619) >>> 0; + } + out += h.toString(16).padStart(8, "0"); + } + return out.slice(0, 40); +} + +function toMessage(sessionId: string, seq: number, turn: Turn, ts: number, agent: Agent): MessageDto { + const [role, text, tool] = turn; + const parts: MessagePartDto[] = [ + { ordinal: 0, kind: "text", text, content_json: null, searchable: true }, + ]; + if (tool) { + parts.push({ + ordinal: 1, + kind: "tool_use", + text: null, + content_json: JSON.stringify(tool), + searchable: true, + }); + } + return { + id: `${sessionId}-m${seq}`, + seq, + role, + event_kind: "message", + is_sidechain: false, + ts, + model: role === "assistant" ? MODEL[agent] : null, + parts, + }; +} + +function build(spec: SessionSpec, now: number, repos: SampleRepository[]): SampleSession { + const started = now - spec.ago; + const ended = started + spec.minutes * MIN; + const step = (spec.minutes * MIN) / Math.max(spec.turns.length, 1); + const messages = spec.turns.map((turn, seq) => + toMessage(spec.id, seq, turn, Math.round(started + seq * step), spec.agent), + ); + const toolCalls = messages.filter((m) => m.parts.some((p) => p.kind === "tool_use")).length; + const repo = repos.find((r) => r.id === spec.repo) ?? null; + + const fileEvents: FileEventDto[] = []; + const patches: Record = {}; + (spec.files ?? []).forEach((file, index) => { + const id = `${spec.id}-f${index + 1}`; + fileEvents.push({ + id, + path: file.path, + change_kind: file.kind, + old_path: null, + lines_added: file.added, + lines_removed: file.removed, + source: spec.agent === "codex" ? "agent_tool_input" : "agent_patch", + has_patch: file.patch !== undefined, + }); + if (file.patch !== undefined) patches[id] = file.patch; + }); + + const inputTokens = messages.length * 1_850; + const outputTokens = messages.length * 540; + const summary: SessionSummary = { + id: spec.id, + agent_id: spec.agent, + title: spec.title, + started_at: started, + ended_at: ended, + message_count: messages.length, + tool_call_count: toolCalls, + primary_model: MODEL[spec.agent], + parse_status: spec.status ?? "ok", + total_input_tokens: inputTokens, + total_output_tokens: outputTokens, + total_cache_tokens: inputTokens * 3, + est_cost_usd: Number(((inputTokens * 3 + outputTokens * 15) / 1_000_000).toFixed(4)), + }; + + const segments: SegmentDto[] = [ + { + id: `${spec.id}-seg1`, + seq_start: 0, + seq_end: Math.max(messages.length - 1, 0), + cwd: repo?.primary_path ?? null, + model: MODEL[spec.agent], + provider: PROVIDER[spec.agent], + repository_id: repo?.id ?? null, + resolution_confidence: repo ? "high" : "unresolved", + }, + ]; + + const git: GitObservationDto[] = repo + ? [ + { + segment_id: segments[0].id, + source: "agent_recorded", + event_ts: started, + observed_at: started, + temporal_confidence: "exact_event", + branch: BRANCH[repo.id] ?? "main", + commit_sha: null, + remote_url_norm: `github.com/acme/${repo.display_name}`, + is_dirty: null, + commit_exists: null, + }, + { + segment_id: segments[0].id, + source: "lore_captured", + event_ts: null, + observed_at: ended + 2 * MIN, + temporal_confidence: "near_event", + branch: BRANCH[repo.id] ?? "main", + commit_sha: fakeSha(spec.id), + remote_url_norm: `github.com/acme/${repo.display_name}`, + is_dirty: spec.files !== undefined, + commit_exists: null, + }, + { + segment_id: segments[0].id, + source: "lore_reverified", + event_ts: null, + observed_at: ended + HOUR, + temporal_confidence: "retrospective", + branch: BRANCH[repo.id] ?? "main", + commit_sha: fakeSha(spec.id), + remote_url_norm: `github.com/acme/${repo.display_name}`, + is_dirty: false, + commit_exists: !repo.is_missing, + }, + ] + : []; + + return { + summary, + parse_note: spec.note ?? null, + segments, + messages, + file_events: fileEvents, + git, + patches, + secret_count: spec.secrets ?? 0, + source_path: + spec.agent === "claude-code" + ? `/Users/you/.claude/projects/${spec.repo ?? "scratch"}/${spec.id}.jsonl` + : `/Users/you/.codex/sessions/${spec.id}.jsonl`, + }; +} + +// ── Featured sessions (hand-written) ───────────────────────────────────── + +const MONEY_PATCH = `--- a/src/lib/money.ts ++++ b/src/lib/money.ts +@@ -1,6 +1,9 @@ + const USD = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); + + /** Format integer cents as a localized price. */ + export function formatPrice(cents: number): string { +- return USD.format(cents / 100); ++ // Round half away from zero so 0.5 cents never renders as a free item. ++ const rounded = Math.sign(cents) * Math.round(Math.abs(cents)); ++ return USD.format(rounded / 100); + } +`; + +const MONEY_TEST_PATCH = `--- a/src/lib/money.test.ts ++++ b/src/lib/money.test.ts +@@ -4,3 +4,8 @@ describe("formatPrice", () => { + it("formats whole dollars", () => { + expect(formatPrice(1200)).toBe("$12.00"); + }); ++ ++ it("rounds fractional cents away from zero", () => { ++ expect(formatPrice(0.5)).toBe("$0.01"); ++ expect(formatPrice(-0.5)).toBe("-$0.01"); ++ }); + }); +`; + +const RETRY_PATCH = `--- a/src/client.rs ++++ b/src/client.rs +@@ -18,9 +18,22 @@ impl PricingClient { +- pub async fn quote(&self, cart: &Cart) -> Result { +- self.http.post(&self.url).json(cart).send().await?.json().await +- } ++ pub async fn quote(&self, cart: &Cart) -> Result { ++ let mut delay = Duration::from_millis(100); ++ for attempt in 0..MAX_ATTEMPTS { ++ match self.http.post(&self.url).json(cart).send().await { ++ Ok(resp) if resp.status().is_success() => return Ok(resp.json().await?), ++ Ok(resp) if !resp.status().is_server_error() => return Err(Error::Rejected(resp.status())), ++ _ if attempt + 1 == MAX_ATTEMPTS => break, ++ _ => tokio::time::sleep(delay).await, ++ } ++ delay *= 2; ++ } ++ Err(Error::Unavailable) ++ } + } +`; + +const REDUCER_PATCH = `--- a/src/checkout/Cart.tsx ++++ b/src/checkout/Cart.tsx +@@ -1,12 +1,24 @@ +-import { useMemo, useState } from "react"; ++import { useMemo, useReducer } from "react"; + + import { formatPrice } from "../lib/money"; ++import { cartReducer } from "./cartReducer"; + +-const [lines, setLines] = useState(SAMPLE); ++const [lines, dispatch] = useReducer(cartReducer, SAMPLE); +`; + +const FEATURED: SessionSpec[] = [ + { + id: "ses-claude-rounding", + agent: "claude-code", + title: "Fix rounding bug in checkout totals", + ago: 2 * HOUR, + minutes: 38, + repo: "repo-acme-storefront", + files: [ + { path: "src/lib/money.ts", kind: "edit", added: 3, removed: 1, patch: MONEY_PATCH }, + { path: "src/lib/money.test.ts", kind: "edit", added: 5, removed: 0, patch: MONEY_TEST_PATCH }, + ], + turns: [ + ["user", "The checkout total is off by a cent when a coupon applies. Can you find out why?"], + [ + "assistant", + "I'll start with how the cart turns cents into a displayed price.", + { name: "Read", input: { file_path: "src/lib/money.ts" } }, + ], + [ + "assistant", + "Found it. formatPrice divides raw cents by 100 without rounding, so a discounted line of 0.5 cents renders as a free item and the total drifts.", + ], + ["user", "Fix it, and add a test that would have caught it."], + [ + "assistant", + "I rounded half away from zero before formatting and added a regression test for positive and negative fractional cents.", + { name: "Edit", input: { file_path: "src/lib/money.ts" } }, + ], + [ + "assistant", + "The unit tests pass and the coupon example now totals correctly.", + { name: "Bash", input: { command: "npm test -- money" } }, + ], + ], + }, + { + id: "ses-claude-retry", + agent: "claude-code", + title: "Add retry with backoff to the pricing client", + ago: 1 * DAY + 3 * HOUR, + minutes: 52, + repo: "repo-pricing-service", + secrets: 1, + files: [{ path: "src/client.rs", kind: "edit", added: 13, removed: 3, patch: RETRY_PATCH }], + turns: [ + ["user", "Quotes fail whenever the pricing service restarts. Add retries with exponential backoff."], + [ + "assistant", + "I'll retry only on connection errors and 5xx responses, doubling the delay from 100ms, and give up after four attempts.", + { name: "Edit", input: { file_path: "src/client.rs" } }, + ], + ["user", "Does it leak the API key into the retry logs?"], + [ + "assistant", + "No. The request URL is logged without its query string, and Lore flagged one key in the pasted config, which is redacted here as [REDACTED].", + ], + [ + "assistant", + "All client tests pass, including one that simulates a restart mid-request.", + { name: "Bash", input: { command: "cargo test -p pricing client" } }, + ], + ], + }, + { + id: "ses-codex-reducer", + agent: "codex", + title: "Migrate cart state to a reducer", + ago: 2 * DAY + 5 * HOUR, + minutes: 27, + repo: "repo-acme-storefront", + files: [ + { path: "src/checkout/Cart.tsx", kind: "edit", added: 8, removed: 4, patch: REDUCER_PATCH }, + { path: "src/checkout/cartReducer.ts", kind: "create", added: 34, removed: 0 }, + ], + turns: [ + ["user", "Cart.tsx has five useState calls that must stay in sync. Move them into a reducer."], + [ + "assistant", + "I'll model add, remove, and set-quantity as actions so the cart can only move between valid states.", + { name: "apply_patch", input: { path: "src/checkout/cartReducer.ts" } }, + ], + [ + "assistant", + "The component now dispatches actions. I kept the exported props unchanged so callers do not move.", + { name: "apply_patch", input: { path: "src/checkout/Cart.tsx" } }, + ], + ["user", "Run the checkout tests."], + ["assistant", "Checkout tests pass: 14 passed, 0 failed.", { name: "shell", input: { command: "npm test -- checkout" } }], + ], + }, + { + id: "ses-codex-flaky", + agent: "codex", + title: "Investigate flaky checkout test", + ago: 3 * DAY + 2 * HOUR, + minutes: 44, + repo: "repo-acme-storefront", + turns: [ + ["user", "The checkout coupon test fails about one run in ten on CI. Why?"], + [ + "assistant", + "The test reads the clock twice, so a run that crosses a second boundary expires the coupon mid-test.", + { name: "shell", input: { command: "npm test -- coupon --repeat 50" } }, + ], + ["user", "Freeze the clock in that test."], + ["assistant", "Done. The test now uses fake timers, and 200 repeated runs passed."], + ], + }, + { + id: "ses-claude-webhook", + agent: "claude-code", + title: "Explain the webhook signature check", + ago: 4 * DAY, + minutes: 12, + repo: "repo-pricing-service", + turns: [ + ["user", "Walk me through how the webhook signature check works before I touch it."], + [ + "assistant", + "The sender signs the raw request body with a shared secret using HMAC-SHA256 and puts the hex digest in a header. The handler recomputes the digest and compares the two in constant time.", + ], + ["user", "Why constant time?"], + [ + "assistant", + "A normal string comparison returns at the first differing byte, so response timing would reveal how much of a forged signature was correct.", + ], + ], + }, + { + id: "ses-claude-quickstart", + agent: "claude-code", + title: "Write a README quick start", + ago: 5 * DAY + 6 * HOUR, + minutes: 19, + repo: "repo-notes-site", + files: [{ path: "README.md", kind: "edit", added: 22, removed: 2 }], + turns: [ + ["user", "Our README has no quick start. Draft one that gets a new contributor running in five minutes."], + [ + "assistant", + "I added install, dev server, and test commands in the order a new contributor needs them, and a short troubleshooting list.", + { name: "Edit", input: { file_path: "README.md" } }, + ], + ], + }, + { + id: "ses-codex-release-notes", + agent: "codex", + title: "Draft release notes for 2.4", + ago: 6 * DAY + 1 * HOUR, + minutes: 16, + repo: "repo-acme-storefront", + turns: [ + ["user", "Summarize the merged pull requests since 2.3 into release notes."], + [ + "assistant", + "Highlights: coupon rounding fix, cart reducer migration, and a faster checkout page. I grouped the rest under maintenance.", + ], + ], + }, + { + id: "ses-claude-csv", + agent: "claude-code", + title: "Port admin export to CSV streaming", + ago: 9 * DAY, + minutes: 61, + repo: "repo-legacy-admin", + status: "partial", + note: "Skipped 3 records Lore could not safely normalize.", + turns: [ + ["user", "The admin export loads every row into memory. Stream it as CSV instead."], + [ + "assistant", + "I replaced the array build with a row iterator that writes each line as it arrives, so memory stays flat.", + { name: "Edit", input: { file_path: "admin/export.py" } }, + ], + ["user", "Does it still escape commas inside names?"], + ["assistant", "Yes, the csv writer quotes any field containing a comma, a quote, or a newline."], + ], + }, + { + id: "ses-codex-deploy", + agent: "codex", + title: "Triage failing deploy pipeline", + ago: 12 * DAY, + minutes: 8, + repo: "repo-pricing-service", + status: "failed", + note: "Unrecognized log format; only the first records were readable.", + turns: [ + ["user", "The deploy job fails at the migration step. What changed?"], + ["assistant", "The migration lock timed out because a long report query held a table lock."], + ], + }, + { + id: "ses-claude-regex", + agent: "claude-code", + title: "Quick question about regex lookbehind", + ago: 14 * DAY, + minutes: 4, + repo: null, + turns: [ + ["user", "Does JavaScript support lookbehind in regular expressions?"], + [ + "assistant", + "Yes, in every current browser and in Node 10 and later. Variable-length lookbehind works too, unlike in many other engines.", + ], + ], + }, + { + id: "ses-codex-rename", + agent: "codex", + title: "Rename the sku field across the codebase", + ago: 16 * DAY, + minutes: 23, + repo: null, + turns: [ + ["user", "Rename sku to product_code everywhere, including the database column."], + [ + "assistant", + "I renamed it in the types, the API handlers, and the fixtures, and wrote a migration that renames the column without dropping data.", + { name: "shell", input: { command: "git grep -l sku" } }, + ], + ], + }, +]; + +// ── A long timeline, to exercise message paging ────────────────────────── + +const LONG_STEPS = [ + "backfill the orders table in batches of 5,000 rows", + "verify row counts against the replica", + "swap the read path to the new column", + "drop the shadow index once traffic is clean", +]; + +function longMigration(): SessionSpec { + const turns: Turn[] = [ + ["user", "Plan the zero-downtime migration for the orders table, then walk me through it step by step."], + ]; + for (let step = 1; step <= 250; step += 1) { + const action = LONG_STEPS[(step - 1) % LONG_STEPS.length]; + turns.push( + step % 2 === 1 + ? ["assistant", `Step ${step}: ${action}. Nothing blocks writes while this runs.`] + : ["user", `Step ${step - 1} looks good. Continue.`], + ); + } + return { + id: "ses-claude-migration", + agent: "claude-code", + title: "Zero-downtime migration plan for the orders table", + ago: 20 * DAY, + minutes: 190, + repo: "repo-pricing-service", + turns, + }; +} + +// ── Filler sessions, so the list and search both need a second page ────── + +const VERBS: [string, string][] = [ + ["tidy", "tidied"], + ["review", "reviewed"], + ["profile", "profiled"], + ["document", "documented"], + ["simplify", "simplified"], +]; +const NOUNS = [ + "retry helper", + "feature flag loader", + "invoice template", + "session cookie", + "image resizer", + "search index", + "audit log", + "rate limiter", + "config parser", + "email digest", + "price formatter", + "login form", +]; +const FILLER_REPOS = [ + "repo-acme-storefront", + "repo-pricing-service", + "repo-notes-site", + null, +]; +const FILLER_COUNT = 110; + +function fillerSessions(): SessionSpec[] { + const out: SessionSpec[] = []; + for (let i = 0; i < FILLER_COUNT; i += 1) { + const [verb, past] = VERBS[i % VERBS.length]; + const noun = NOUNS[(i * 7) % NOUNS.length]; + const agent: Agent = i % 3 === 0 ? "codex" : "claude-code"; + out.push({ + id: `ses-sample-${String(i + 1).padStart(3, "0")}`, + agent, + title: `${verb[0].toUpperCase()}${verb.slice(1)} the ${noun}`, + ago: 7 * DAY + i * 11 * HOUR + (i % 5) * 17 * MIN, + minutes: 5 + (i % 9) * 3, + repo: FILLER_REPOS[i % FILLER_REPOS.length], + turns: [ + ["user", `Can you ${verb} the ${noun}? It keeps coming up in review.`], + ["assistant", `Done. I ${past} the ${noun} and ran the tests; everything passes.`], + ], + }); + } + return out; +} + +/** The whole archive, newest first, anchored to `now`. */ +export function buildSampleSessions(now: number = Date.now()): SampleSession[] { + return [...FEATURED, longMigration(), ...fillerSessions()] + .map((spec) => build(spec, now, SAMPLE_REPOSITORIES)) + .sort( + (a, b) => + (b.summary.started_at ?? 0) - (a.summary.started_at ?? 0) || + a.summary.id.localeCompare(b.summary.id), + ); +}