From dca6e438950de7cf64bc35763ddbdfb12064c6f8 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 25 Oct 2026 23:40:33 -0500 Subject: [PATCH] feat(ui): serve the archive UI with sample data under npm run dev Outside the Tauri shell every invoke and listen call failed, so npm run dev showed Archive unavailable. Answer them from a fixed in-memory sample archive typed with the lore-ipc DTOs. main.tsx installs the mock only when import.meta.env.DEV is true and the page is not inside Tauri, so production builds drop it and tauri dev keeps the real backend. A test derives the commands from ipc.ts and fails when one has no handler. --- src/ipc.test.ts | 107 ++++++ src/main.tsx | 30 +- src/mock/ipc-mock.ts | 531 ++++++++++++++++++++++++++++++ src/mock/sample-data.ts | 701 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1362 insertions(+), 7 deletions(-) create mode 100644 src/mock/ipc-mock.ts create mode 100644 src/mock/sample-data.ts 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), + ); +}