diff --git a/package-lock.json b/package-lock.json index 28458e9..c108a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", "react": "^18.3.1", @@ -1044,6 +1046,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index 39045dc..e03eb07 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "tauri": "tauri" }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.7.2", "react": "^18.3.1", diff --git a/src/App.test.tsx b/src/App.test.tsx index d405818..b6a4c10 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,88 +1,62 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const scan = vi.hoisted(() => ({ - handler: null as null | ((progress: { - discovered: number; - ingested: number; - skipped: number; - failed: number; - enriched: number; - done: boolean; - }) => void), -})); +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ScanProgress } from "./ipc"; + +const events = vi.hoisted(() => ({ scan: null as null | ((p: ScanProgress) => void) })); vi.mock("./ipc", () => ({ - listDetectedAgents: vi.fn().mockResolvedValue([]), - chooseAgentRootDirectory: vi.fn().mockResolvedValue(null), - addAgentRoot: vi.fn().mockResolvedValue(undefined), - removeAgentRoot: vi.fn().mockResolvedValue(undefined), - listRepositories: vi.fn().mockResolvedValue([]), - listSessionsPage: vi.fn().mockResolvedValue({ sessions: [], next_cursor: null }), - listRepositorySessionsPage: vi.fn().mockResolvedValue({ - sessions: [], - next_cursor: null, - }), - listFolders: vi.fn().mockResolvedValue([]), - listFolderSessionsPage: vi.fn().mockResolvedValue({ sessions: [], next_cursor: null }), - createFolder: vi.fn().mockResolvedValue({ id: "f", name: "f", session_count: 0, position: 0 }), - renameFolder: vi.fn().mockResolvedValue(undefined), - deleteFolder: vi.fn().mockResolvedValue(undefined), - setSessionFolder: vi.fn().mockResolvedValue(undefined), - getSession: vi.fn().mockResolvedValue(null), - getGitSnapshot: vi.fn().mockResolvedValue([]), - getFilePatch: vi.fn().mockResolvedValue(null), - sessionSecretCount: vi.fn().mockResolvedValue(0), - exportSessionMarkdown: vi.fn().mockResolvedValue(""), - forgetSession: vi.fn().mockResolvedValue({ blobs_removed: 0, source_paths: [] }), - forgetEverything: vi.fn().mockResolvedValue({ blobs_removed: 0, source_paths: [] }), - search: vi.fn().mockResolvedValue([]), - searchPage: vi.fn().mockResolvedValue({ hits: [], next_cursor: null }), - getSetting: vi.fn().mockResolvedValue(null), - setSetting: vi.fn().mockResolvedValue(undefined), - getBackupSchedule: vi.fn().mockResolvedValue({ interval: "off", keep: 7 }), - setBackupSchedule: vi.fn().mockResolvedValue(undefined), - backupNow: vi.fn().mockResolvedValue(undefined), - rescan: vi.fn().mockResolvedValue({}), - onScanProgress: vi.fn().mockImplementation(async (handler) => { - scan.handler = handler; - return () => {}; - }), HIGHLIGHT_START: "\u{e000}", HIGHLIGHT_END: "\u{e001}", + listDetectedAgents: vi.fn(), + listRepositories: vi.fn(), + listFolders: vi.fn(), + listSessionsPage: vi.fn(), + listRepositorySessionsPage: vi.fn(), + listFolderSessionsPage: vi.fn(), + listSessionMessagesPage: vi.fn(), + getSession: vi.fn(), + getGitSnapshot: vi.fn(), + sessionSecretCount: vi.fn(), + getFilePatch: vi.fn(), + searchPage: vi.fn(), + rescan: vi.fn(), + onScanProgress: vi.fn(async (handler: (p: ScanProgress) => void) => { + events.scan = handler; + return () => {}; + }), + chooseAgentRootDirectory: vi.fn(), + addAgentRoot: vi.fn(), + removeAgentRoot: vi.fn(), + createFolder: vi.fn(), + renameFolder: vi.fn(), + deleteFolder: vi.fn(), + setSessionFolder: vi.fn(), + exportSessionMarkdown: vi.fn(), + saveSessionExport: vi.fn(), + forgetSession: vi.fn(), + forgetEverything: vi.fn(), + getBackupSchedule: vi.fn(), + setBackupSchedule: vi.fn(), + backupNow: vi.fn(), })); import App from "./App"; -import { - addAgentRoot, - chooseAgentRootDirectory, - forgetEverything, - getSession, - listDetectedAgents, - listFolders, - listRepositories, - listRepositorySessionsPage, - listSessionsPage, - rescan, - removeAgentRoot, - searchPage, - type DetectedAgent, - type RepositorySummary, - type SessionPage, - type SessionDetail, - type SessionSummary, -} from "./ipc"; - -function summary(id: string, title: string): SessionSummary { +import * as ipc from "./ipc"; +import type { DetectedAgent, MessageDto, SearchHit, SessionDetail, SessionSummary } from "./ipc"; + +const m = vi.mocked(ipc); + +function session(id: string, title: string, agent = "claude-code"): SessionSummary { return { id, - agent_id: "codex", + agent_id: agent, title, started_at: null, ended_at: null, - message_count: 0, - tool_call_count: 0, - primary_model: null, + message_count: 3, + tool_call_count: 1, + primary_model: "claude-sonnet-4-5", parse_status: "ok", total_input_tokens: null, total_output_tokens: null, @@ -91,769 +65,349 @@ function summary(id: string, title: string): SessionSummary { }; } -function detail(session: SessionSummary): SessionDetail { +function message(seq: number, role: string, parts: MessageDto["parts"]): MessageDto { + return { id: `m${seq}`, seq, role, event_kind: "message", is_sidechain: false, ts: null, model: null, parts }; +} + +function detail(summary: SessionSummary): SessionDetail { return { - summary: session, + summary, parse_note: null, segments: [], - messages: [], - file_events: [], + messages: [ + message(1, "user", [{ ordinal: 0, kind: "text", text: "Make the retry backoff deterministic.", content_json: null, searchable: true }]), + message(2, "assistant", [ + { + ordinal: 0, + kind: "tool_use", + text: null, + content_json: JSON.stringify({ type: "tool_use", name: "Bash", input: { command: "cargo test sync" } }), + searchable: false, + }, + ]), + ], + file_events: [ + { + id: "fe1", + path: "src/sync/worker.rs", + change_kind: "edit", + old_path: null, + lines_added: 1, + lines_removed: 1, + source: "agent_patch", + has_patch: true, + }, + ], next_message_cursor: null, }; } -function repository(id: string, name: string): RepositorySummary { +function hit(sessionId: string, title: string, snippet: string): SearchHit { return { - id, - display_name: name, - identity_confidence: "confirmed", - primary_path: null, - is_missing: false, - session_count: 1, - worktree_count: 1, + session_id: sessionId, + source_kind: "message_part", + source_id: `p-${sessionId}`, + field: "text", + snippet, + rank: -1, + title, + agent_id: "codex", + started_at: null, }; } -function deferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} +const agents: DetectedAgent[] = [ + { id: "claude-code", display_name: "Claude Code", installed: true, version: null, session_count: 2, roots: ["~/.claude/projects"], custom_roots: [] }, +]; + +const alpha = session("a", "Fix flaky retry backoff"); +const beta = session("b", "Port dotfiles to nix", "codex"); + +beforeEach(() => { + m.listDetectedAgents.mockResolvedValue(agents); + m.listRepositories.mockResolvedValue([ + { id: "repo-lore", display_name: "lore", identity_confidence: "confirmed", primary_path: null, is_missing: false, session_count: 1, worktree_count: 1 }, + ]); + m.listFolders.mockResolvedValue([{ id: "f1", name: "Release prep", session_count: 0, position: 0 }]); + m.listSessionsPage.mockResolvedValue({ sessions: [alpha, beta], next_cursor: null }); + m.listRepositorySessionsPage.mockResolvedValue({ sessions: [alpha], next_cursor: null }); + m.listFolderSessionsPage.mockResolvedValue({ sessions: [], next_cursor: null }); + m.getSession.mockImplementation(async (id) => detail(id === "b" ? beta : alpha)); + m.getGitSnapshot.mockResolvedValue([]); + m.sessionSecretCount.mockResolvedValue(0); + m.searchPage.mockResolvedValue({ hits: [], next_cursor: null }); + m.rescan.mockResolvedValue({ discovered: 0, ingested: 0, skipped: 0, failed: 0, enriched: 0 }); + m.getFilePatch.mockResolvedValue("@@ -1,2 +1,2 @@\n-let jitter = random();\n+let jitter = self.rng.gen();\n keep();\n"); + m.setSessionFolder.mockResolvedValue(undefined); + m.forgetSession.mockResolvedValue({ blobs_removed: 2, source_paths: [] }); + m.getBackupSchedule.mockResolvedValue({ interval: "off", keep: 7 }); +}); afterEach(() => { - vi.mocked(listDetectedAgents).mockResolvedValue([]); - vi.mocked(chooseAgentRootDirectory).mockResolvedValue(null); - vi.mocked(addAgentRoot).mockResolvedValue(undefined); - vi.mocked(removeAgentRoot).mockResolvedValue(undefined); - vi.mocked(listRepositories).mockResolvedValue([]); - vi.mocked(listSessionsPage).mockResolvedValue({ sessions: [], next_cursor: null }); - vi.mocked(listRepositorySessionsPage).mockResolvedValue({ - sessions: [], - next_cursor: null, - }); - vi.mocked(getSession).mockResolvedValue(null); - vi.mocked(rescan).mockClear(); - vi.mocked(searchPage).mockResolvedValue({ hits: [], next_cursor: null }); + vi.clearAllMocks(); + events.scan = null; }); -describe("App shell", () => { - it("renders the three-pane shell heading and a rescan control", async () => { - render(); - expect( - await screen.findByRole("heading", { name: "Lore", level: 1 }), - ).toBeTruthy(); - expect(screen.getByRole("button", { name: /rescan/i })).toBeTruthy(); - // Panes present: repositories nav and sessions listbox. - expect(screen.getByRole("navigation", { name: /repositories/i })).toBeTruthy(); - }); - - it("shows loading rather than a false empty archive while initial queries settle", async () => { - const agents = deferred(); - vi.mocked(listDetectedAgents).mockReturnValueOnce(agents.promise); +async function renderLoaded() { + render(); + await screen.findByRole("option", { name: /Fix flaky retry backoff/ }); +} - render(); +function typeQuery(text: string) { + fireEvent.change(screen.getByRole("textbox", { name: "Search sessions" }), { target: { value: text } }); +} - expect(screen.queryByRole("heading", { name: /your agent history/i })).toBeNull(); - expect(screen.getByText("Loading archive…")).toBeTruthy(); +describe("browsing", () => { + it("lists the newest sessions and opens one into the reader", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Port dotfiles to nix/ })); - await act(async () => agents.resolve([])); - expect( - await screen.findByRole("heading", { name: "Your agent history, searchable." }), - ).toBeTruthy(); + const reader = await screen.findByRole("article", { name: "Port dotfiles to nix" }); + expect(within(reader).getByText("Make the retry backoff deterministic.")).toBeTruthy(); + expect(within(reader).getByText("cargo test sync")).toBeTruthy(); + expect(screen.getByRole("option", { name: /Port dotfiles to nix/ }).getAttribute("aria-selected")).toBe("true"); }); - it("offers a truthful local-first onboarding path for an empty archive", async () => { - vi.mocked(listDetectedAgents).mockResolvedValue([ - { - id: "claude-code", - display_name: "Claude Code", - installed: true, - version: null, - session_count: 0, - roots: ["/Users/dev/.claude/projects"], - custom_roots: [], - }, - { - id: "codex", - display_name: "Codex", - installed: false, - version: null, - session_count: 0, - roots: ["/Users/dev/.codex/sessions"], - custom_roots: [], - }, - ]); - - render(); + it("browses one repository from the scope picker", async () => { + m.listSessionsPage.mockResolvedValue({ sessions: [alpha, beta], next_cursor: null }); + await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: /All sessions/ })); + fireEvent.click(await screen.findByRole("option", { name: "lore, repository" })); - expect( - await screen.findByRole("heading", { name: "Your agent history, searchable." }), - ).toBeTruthy(); - expect(screen.getByText("/Users/dev/.claude/projects")).toBeTruthy(); - expect(screen.getByText("/Users/dev/.codex/sessions")).toBeTruthy(); - expect(screen.getByText(/local and read-only/i)).toBeTruthy(); - expect(screen.getByText("Claude Code").closest("li")?.textContent).toContain("detected"); - expect(screen.getByText("Codex").closest("li")?.textContent).toContain("not detected"); - - vi.mocked(chooseAgentRootDirectory).mockResolvedValueOnce("/Volumes/archive/codex"); - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Choose Codex folder" })); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(chooseAgentRootDirectory).toHaveBeenCalledWith("Codex"); - expect(addAgentRoot).toHaveBeenCalledWith("codex", "/Volumes/archive/codex"); - expect(screen.getByRole("status").textContent).toContain("Codex folder added"); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Scan agent history" })); - await Promise.resolve(); - }); - expect(rescan).toHaveBeenCalledTimes(1); - expect(screen.getByRole("button", { name: "Scanning agent history…" })).toBeTruthy(); - - fireEvent.click(screen.getByRole("button", { name: "Review privacy and agents" })); - expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); + await waitFor(() => expect(screen.queryByRole("option", { name: /Port dotfiles/ })).toBeNull()); + expect(screen.getByRole("heading", { name: "lore" })).toBeTruthy(); + expect(m.listRepositorySessionsPage).toHaveBeenCalledWith("repo-lore", 100, null); }); - it("never relabels a failed initial load as an empty archive", async () => { - vi.mocked(listDetectedAgents).mockRejectedValueOnce(new Error("archive unavailable")); + it("appends an older page when asked", async () => { + m.listSessionsPage + .mockResolvedValueOnce({ sessions: [alpha], next_cursor: "c1" }) + .mockResolvedValueOnce({ sessions: [beta], next_cursor: null }); + await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: "Load older sessions" })); - render(); - - expect((await screen.findByRole("alert")).textContent).toContain("archive unavailable"); - expect(screen.getByText("Sessions unavailable.")).toBeTruthy(); - expect(screen.queryByText("No sessions yet.")).toBeNull(); - fireEvent.change(screen.getByRole("searchbox", { name: /search/i }), { - target: { value: "retry" }, - }); - - expect(screen.queryByRole("heading", { name: /your agent history/i })).toBeNull(); - expect(screen.getByText("Archive unavailable.")).toBeTruthy(); - }); - - it("opens the command palette on ⌘K", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - fireEvent.keyDown(window, { key: "k", metaKey: true }); - expect(screen.getByRole("dialog", { name: /command palette/i })).toBeTruthy(); + await screen.findByRole("option", { name: /Port dotfiles to nix/ }); + expect(screen.getAllByRole("option").map((o) => o.textContent?.slice(0, 12))).toEqual(["Fix flaky re", "Port dotfile"]); + expect(m.listSessionsPage).toHaveBeenLastCalledWith(100, "c1"); }); - it("focuses the search box on / when not already typing", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - fireEvent.keyDown(window, { key: "/" }); - expect(document.activeElement).toBe(screen.getByRole("searchbox", { name: /search/i })); + it("paints only the latest session when responses arrive out of order", async () => { + let releaseFirst!: (d: SessionDetail) => void; + m.getSession.mockImplementationOnce(() => new Promise((resolve) => (releaseFirst = resolve))); + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Fix flaky retry backoff/ })); + fireEvent.click(screen.getByRole("option", { name: /Port dotfiles to nix/ })); + await screen.findByRole("article", { name: "Port dotfiles to nix" }); + + await act(async () => releaseFirst(detail(alpha))); + expect(screen.getByRole("article", { name: "Port dotfiles to nix" })).toBeTruthy(); + expect(screen.queryByRole("article", { name: "Fix flaky retry backoff" })).toBeNull(); }); +}); - it("renders scan progress as a bar with a percentage", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - act(() => { - scan.handler?.({ - discovered: 100, - ingested: 30, - skipped: 10, - failed: 10, - enriched: 5, - done: false, - }); +describe("search", () => { + it("runs a filtered query and highlights the matched words", async () => { + m.searchPage.mockResolvedValue({ + hits: [hit("b", "Port dotfiles to nix", "the \u{e000}retry\u{e001} loop")], + next_cursor: null, }); - const bar = screen.getByRole("progressbar", { name: /archive scan progress/i }); - expect(bar.getAttribute("aria-valuenow")).toBe("50"); - expect(screen.getByText(/50\/100 sessions · 50%/)).toBeTruthy(); - }); + await renderLoaded(); + typeQuery("retry agent:codex"); - it("shows a completion summary once the scan is done", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - act(() => { - scan.handler?.({ - discovered: 100, - ingested: 90, - skipped: 5, - failed: 5, - enriched: 40, - done: true, - }); - }); - expect(screen.getByText(/scan complete · 90 ingested/i)).toBeTruthy(); + const result = await screen.findByRole("option", { name: /Port dotfiles to nix/ }); + expect(within(result).getByText("retry").tagName).toBe("MARK"); + expect(within(result).getByText("in message")).toBeTruthy(); + expect(m.searchPage).toHaveBeenLastCalledWith("retry agent:codex", 50, null, "relevance"); }); - it("returns focus to the palette trigger after Escape", async () => { - render(); - const trigger = await screen.findByRole("button", { name: /open command palette/i }); - trigger.focus(); - fireEvent.click(trigger); - const input = screen.getByRole("combobox"); - expect(document.activeElement).toBe(input); - - fireEvent.keyDown(input, { key: "Escape" }); + it("scopes a search to the chosen repository and re-sorts it", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: /All sessions/ })); + fireEvent.click(await screen.findByRole("option", { name: "lore, repository" })); + typeQuery("retry"); + await screen.findByText("Searching sessions in lore.", { exact: false }); + await waitFor(() => expect(m.searchPage).toHaveBeenLastCalledWith("retry repo:repo-lore", 50, null, "relevance")); - expect(screen.queryByRole("dialog", { name: /command palette/i })).toBeNull(); - expect(document.activeElement).toBe(trigger); + fireEvent.change(screen.getByRole("combobox", { name: "Sort results" }), { target: { value: "newest" } }); + await waitFor(() => expect(m.searchPage).toHaveBeenLastCalledWith("retry repo:repo-lore", 50, null, "newest")); }); - it("finds and opens an archived session from the command palette", async () => { - const archived = summary("archive-session", "Retry with exponential backoff"); - vi.mocked(searchPage).mockResolvedValue({ - hits: [ - { - session_id: archived.id, - source_kind: "message_part", - source_id: "part-1", - field: "text", - snippet: "use exponential backoff", - rank: -1, - title: archived.title, - agent_id: archived.agent_id, - started_at: archived.started_at, - }, - ], - next_cursor: null, - }); - vi.mocked(getSession).mockResolvedValue(detail(archived)); - vi.useFakeTimers(); - - try { - render(); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - fireEvent.keyDown(window, { key: "k", metaKey: true }); - const palette = screen.getByRole("combobox"); - const searchPageMock = vi.mocked(searchPage); - searchPageMock.mockClear(); - - fireEvent.change(palette, { target: { value: "backoff" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - - expect(searchPageMock).toHaveBeenCalledWith("backoff", 50); - expect(screen.getByText("Retry with exponential backoff")).toBeTruthy(); - await act(async () => { - fireEvent.keyDown(palette, { key: "Enter" }); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(getSession).toHaveBeenCalledWith(archived.id); - } finally { - vi.useRealTimers(); - } - }); - - it("keeps rescan busy until the worker reports completion", async () => { - render(); - const button = await screen.findByRole("button", { name: "Rescan" }); - fireEvent.click(button); - expect(await screen.findByRole("button", { name: "Scanning…" })).toBeTruthy(); + it("explains that filters alone do not search", async () => { + await renderLoaded(); + typeQuery("agent:codex has:error"); - act(() => { - scan.handler?.({ - discovered: 2, - ingested: 1, - skipped: 0, - failed: 0, - enriched: 1, - done: true, - }); - }); - expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy(); + expect(await screen.findByText("Filters narrow a search. Add a word to search for.")).toBeTruthy(); + expect(m.searchPage).not.toHaveBeenCalled(); + typeQuery("retry agent:codex has:error"); + await waitFor(() => expect(m.searchPage).toHaveBeenCalledWith("retry agent:codex has:error", 50, null, "relevance")); }); - it("debounces rapid search input and only queries the latest text", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - - const search = screen.getByRole("searchbox", { name: /search/i }); - const searchPageMock = vi.mocked(searchPage); - searchPageMock.mockClear(); - vi.useFakeTimers(); - - try { - fireEvent.change(search, { target: { value: "r" } }); - fireEvent.change(search, { target: { value: "ret" } }); - fireEvent.change(search, { target: { value: "retry" } }); - - expect(searchPageMock).not.toHaveBeenCalled(); - - await act(async () => { - vi.advanceTimersByTime(200); - await Promise.resolve(); - }); - - expect(searchPageMock).toHaveBeenCalledTimes(1); - expect(searchPageMock).toHaveBeenCalledWith("retry", 50); - } finally { - vi.useRealTimers(); - } + it("says when nothing matches", async () => { + await renderLoaded(); + typeQuery("zzzqx"); + expect(await screen.findByText("No sessions match “zzzqx”.")).toBeTruthy(); }); - it("moves keyboard focus from search into its results and back", async () => { - vi.mocked(searchPage).mockResolvedValue({ - hits: [ - { - session_id: "search-session", - source_kind: "message_part", - source_id: "search-part", - field: "text", - snippet: "retry result", - rank: -1, - title: "Retry result", - agent_id: "codex", - started_at: null, - }, - ], - next_cursor: null, - }); - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - const search = screen.getByRole("searchbox", { name: /search/i }); - vi.useFakeTimers(); - - try { - fireEvent.change(search, { target: { value: "retry" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - const results = screen.getByRole("listbox", { name: /search results/i }); - - search.focus(); - fireEvent.keyDown(search, { key: "ArrowDown" }); - expect(document.activeElement).toBe(results); - - fireEvent.keyDown(results, { key: "ArrowUp" }); - expect(document.activeElement).toBe(search); - } finally { - vi.useRealTimers(); - } + it("reports a failed search in place", async () => { + m.searchPage.mockRejectedValue(new Error("fts5: syntax error")); + await renderLoaded(); + typeQuery("retry"); + expect(await screen.findByText("The search did not run.")).toBeTruthy(); + expect(screen.getByText("Error: fts5: syntax error")).toBeTruthy(); }); - it("removes results from the previous query as soon as new text is entered", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); + it("highlights the search words inside the opened session", async () => { + m.searchPage.mockResolvedValue({ hits: [hit("a", "Fix flaky retry backoff", "\u{e000}backoff\u{e001}")], next_cursor: null }); + await renderLoaded(); + typeQuery("backoff"); + fireEvent.click(await screen.findByRole("option", { name: /Fix flaky retry backoff/ })); - const search = screen.getByRole("searchbox", { name: /search/i }); - const searchPageMock = vi.mocked(searchPage); - searchPageMock.mockClear(); - searchPageMock.mockResolvedValueOnce({ - hits: [ - { - session_id: "s1", - source_kind: "message_part", - source_id: "p1", - field: "text", - snippet: "alpha result", - rank: -1, - title: "Old alpha result", - agent_id: "codex", - started_at: null, - }, - ], - next_cursor: null, - }); - vi.useFakeTimers(); - - try { - fireEvent.change(search, { target: { value: "alpha" } }); - await act(async () => { - vi.advanceTimersByTime(200); - await Promise.resolve(); - }); - expect(screen.getByText("Old alpha result")).toBeTruthy(); - - fireEvent.change(search, { target: { value: "beta" } }); - expect(screen.queryByText("Old alpha result")).toBeNull(); - expect(screen.getByRole("status").textContent).toContain("Searching"); - } finally { - vi.useRealTimers(); - searchPageMock.mockResolvedValue({ hits: [], next_cursor: null }); - } - }); - - it("cancels a pending search when the query is cleared", async () => { - render(); - await screen.findByRole("heading", { name: "Lore", level: 1 }); - - const search = screen.getByRole("searchbox", { name: /search/i }); - const searchPageMock = vi.mocked(searchPage); - searchPageMock.mockClear(); - vi.useFakeTimers(); - - try { - fireEvent.change(search, { target: { value: "retry" } }); - fireEvent.change(search, { target: { value: "" } }); - - await act(async () => { - vi.advanceTimersByTime(200); - await Promise.resolve(); - }); - - expect(searchPageMock).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } + const reader = await screen.findByRole("article", { name: "Fix flaky retry backoff" }); + const marks = within(reader).getAllByText("backoff"); + expect(marks.map((el) => el.tagName)).toEqual(["MARK"]); }); +}); - it("loads a newly selected repository only once", async () => { - vi.mocked(listRepositories).mockResolvedValue([repository("repo-a", "Repo A")]); - const repositorySessions = vi.mocked(listRepositorySessionsPage); - repositorySessions.mockResolvedValue({ sessions: [], next_cursor: null }); - - render(); - const repoButton = await screen.findByRole("button", { name: /Repo A/i }); - repositorySessions.mockClear(); - - fireEvent.click(repoButton); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(repositorySessions).toHaveBeenCalledTimes(1); - expect(repositorySessions).toHaveBeenCalledWith("repo-a", 100, null); +describe("keyboard", () => { + it("moves from the query line into the list, opens with Enter, and backs out with Escape", async () => { + await renderLoaded(); + fireEvent.keyDown(document.body, { key: "/" }); + const query = screen.getByRole("textbox", { name: "Search sessions" }); + expect(document.activeElement).toBe(query); + + fireEvent.keyDown(query, { key: "ArrowDown" }); + const list = screen.getByRole("listbox", { name: "Sessions in All sessions" }); + expect(document.activeElement).toBe(list); + + fireEvent.keyDown(list, { key: "ArrowDown" }); + fireEvent.keyDown(list, { key: "Enter" }); + const reader = await screen.findByRole("article", { name: "Port dotfiles to nix" }); + await waitFor(() => expect(document.activeElement).toBe(reader)); + + fireEvent.keyDown(reader, { key: "Escape" }); + expect(document.activeElement).toBe(list); + fireEvent.keyDown(list, { key: "k" }); + fireEvent.keyDown(list, { key: "ArrowUp" }); + expect(document.activeElement).toBe(query); }); - it("keeps sessions for the latest repository when responses finish out of order", async () => { - vi.mocked(listRepositories).mockResolvedValue([ - repository("repo-a", "Repo A"), - repository("repo-b", "Repo B"), - ]); - const repoA = deferred(); - const repoB = deferred(); - vi.mocked(listRepositorySessionsPage).mockImplementation((id) => - id === "repo-a" ? repoA.promise : repoB.promise, - ); + it("opens the palette with ⌘K and jumps to a session", async () => { + await renderLoaded(); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + const input = await screen.findByRole("combobox", { name: /Jump to a session/ }); + fireEvent.change(input, { target: { value: "dotfiles" } }); + fireEvent.keyDown(input, { key: "Enter" }); - render(); - const repoAButton = await screen.findByRole("button", { name: /Repo A/i }); - const repoBButton = screen.getByRole("button", { name: /Repo B/i }); - - fireEvent.click(repoAButton); - fireEvent.click(repoBButton); - expect(screen.getByRole("status").textContent).toContain("Loading sessions"); - await act(async () => - repoB.resolve({ - sessions: [summary("session-b", "B session")], - next_cursor: null, - }), - ); - expect(screen.getByText("B session")).toBeTruthy(); - - await act(async () => - repoA.resolve({ - sessions: [summary("session-a", "A session")], - next_cursor: null, - }), - ); - expect(screen.queryByText("A session")).toBeNull(); - expect(screen.getByText("B session")).toBeTruthy(); + expect(await screen.findByRole("article", { name: "Port dotfiles to nix" })).toBeTruthy(); + expect(screen.queryByRole("dialog", { name: "Command palette" })).toBeNull(); }); +}); - it("ignores an error from a superseded repository request", async () => { - vi.mocked(listRepositories).mockResolvedValue([ - repository("repo-a", "Repo A"), - repository("repo-b", "Repo B"), - ]); - const repoA = deferred(); - const repoB = deferred(); - vi.mocked(listRepositorySessionsPage).mockImplementation((id) => - id === "repo-a" ? repoA.promise : repoB.promise, - ); - - render(); - fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); - fireEvent.click(screen.getByRole("button", { name: /Repo B/i })); - await act(async () => - repoB.resolve({ - sessions: [summary("session-b", "B session")], - next_cursor: null, - }), - ); - await act(async () => repoA.reject(new Error("stale repository failure"))); - - expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.getByText("B session")).toBeTruthy(); - }); +describe("scan", () => { + it("shows progress from the worker and a summary when it finishes", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: "Rescan" })); + expect(await screen.findByRole("button", { name: "Scanning…" })).toBeTruthy(); - it("allows dismissing an active error banner", async () => { - vi.mocked(listRepositories).mockRejectedValueOnce(new Error("network failure")); - render(); - const alert = await screen.findByRole("alert"); - expect(alert).toBeTruthy(); - expect(screen.getByText("Error: network failure")).toBeTruthy(); + act(() => events.scan?.({ discovered: 10, ingested: 2, skipped: 1, failed: 0, enriched: 0, done: false })); + expect(screen.getByRole("progressbar", { name: "Archive scan" }).getAttribute("aria-valuetext")).toBe("3 of 10 sessions"); + expect(screen.getByText("Scanning 3 of 10")).toBeTruthy(); - fireEvent.click(screen.getByLabelText("Dismiss error")); - expect(screen.queryByRole("alert")).toBeNull(); + act(() => events.scan?.({ discovered: 10, ingested: 8, skipped: 1, failed: 1, enriched: 3, done: true })); + expect(screen.getByText("Scan finished: 8 new or changed, 1 unchanged, 1 unreadable")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Rescan" })).toBeTruthy(); }); - it("settles repository loading when a background refresh supersedes it", async () => { - vi.mocked(listRepositories).mockResolvedValue([repository("repo-a", "Repo A")]); - const manual = deferred(); - const repositorySessions = vi.mocked(listRepositorySessionsPage); - repositorySessions - .mockImplementationOnce(() => manual.promise) - .mockResolvedValue({ - sessions: [summary("refreshed", "Refreshed session")], - next_cursor: null, - }); - - render(); - fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); - expect(screen.getByRole("status").textContent).toContain("Loading sessions"); - - vi.useFakeTimers(); - try { - act(() => { - scan.handler?.({ - discovered: 1, - ingested: 1, - skipped: 0, - failed: 0, - enriched: 1, - done: true, - }); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - - expect(screen.queryByText("Loading sessions…")).toBeNull(); - expect(screen.getByText("Refreshed session")).toBeTruthy(); - - await act(async () => - manual.resolve({ - sessions: [summary("stale", "Stale session")], - next_cursor: null, - }), - ); - expect(screen.queryByText("Stale session")).toBeNull(); - expect(screen.getByText("Refreshed session")).toBeTruthy(); - } finally { - vi.useRealTimers(); - } + it("refreshes the list as the worker ingests sessions", async () => { + await renderLoaded(); + m.listSessionsPage.mockResolvedValue({ sessions: [session("c", "A brand new session"), alpha, beta], next_cursor: null }); + act(() => events.scan?.({ discovered: 3, ingested: 1, skipped: 0, failed: 0, enriched: 0, done: false })); + expect(await screen.findByRole("option", { name: /A brand new session/ }, { timeout: 2000 })).toBeTruthy(); }); - it("starts with a bounded session page and appends older sessions", async () => { - const pages = vi.mocked(listSessionsPage); - pages.mockClear(); - pages - .mockResolvedValueOnce({ - sessions: [summary("new-session", "New session")], - next_cursor: "older-cursor", - }) - .mockResolvedValueOnce({ - sessions: [summary("old-session", "Old session")], - next_cursor: null, - }); - - render(); - expect(await screen.findByText("New session")).toBeTruthy(); - expect(pages).toHaveBeenNthCalledWith(1, 100, null); - - fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); - expect(await screen.findByText("Old session")).toBeTruthy(); - expect(screen.getByText("New session")).toBeTruthy(); - expect(pages).toHaveBeenNthCalledWith(2, 100, "older-cursor"); - expect(screen.queryByRole("button", { name: /load older sessions/i })).toBeNull(); + it("reports a rescan that could not start", async () => { + m.rescan.mockRejectedValue("background ingestion worker unavailable"); + await renderLoaded(); + fireEvent.click(screen.getByRole("button", { name: "Rescan" })); + expect((await screen.findByRole("alert")).textContent).toBe("background ingestion worker unavailable"); + expect(screen.getByText("Scan failed")).toBeTruthy(); }); +}); - it("does not append an older page after the repository changes", async () => { - vi.mocked(listRepositories).mockResolvedValue([ - repository("repo-a", "Repo A"), - repository("repo-b", "Repo B"), - ]); - const olderA = deferred<{ sessions: SessionSummary[]; next_cursor: string | null }>(); - vi.mocked(listRepositorySessionsPage).mockImplementation((id, _limit, cursor) => { - if (id === "repo-a" && cursor === null) { - return Promise.resolve({ - sessions: [summary("a-new", "A newest")], - next_cursor: "a-older", - }); - } - if (id === "repo-a") return olderA.promise; - return Promise.resolve({ - sessions: [summary("b-new", "B newest")], - next_cursor: null, - }); - }); - +describe("states", () => { + it("never shows a failed first load as an empty archive, and recovers on retry", async () => { + m.listRepositories.mockRejectedValueOnce(new Error("database is locked")); render(); - fireEvent.click(await screen.findByRole("button", { name: /Repo A/i })); - expect(await screen.findByText("A newest")).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); - fireEvent.click(screen.getByRole("button", { name: /Repo B/i })); - expect(await screen.findByText("B newest")).toBeTruthy(); - - await act(async () => { - olderA.resolve({ - sessions: [summary("a-old", "A older")], - next_cursor: null, - }); - }); - expect(screen.queryByText("A older")).toBeNull(); - expect(screen.getByText("B newest")).toBeTruthy(); - }); - - it("preserves the loaded session depth during a background refresh", async () => { - const first = Array.from({ length: 100 }, (_, index) => - summary(`new-${index}`, `New ${index}`), - ); - const older = Array.from({ length: 20 }, (_, index) => - summary(`old-${index}`, `Old ${index}`), - ); - const pages = vi.mocked(listSessionsPage); - pages.mockClear(); - pages - .mockResolvedValueOnce({ sessions: first, next_cursor: "older" }) - .mockResolvedValueOnce({ sessions: older, next_cursor: null }) - .mockResolvedValueOnce({ sessions: [...first, ...older], next_cursor: null }); + expect(await screen.findByText("Lore could not open the archive.")).toBeTruthy(); + expect(screen.queryByRole("heading", { name: /searchable place/ })).toBeNull(); - render(); - await screen.findByText("New 0"); - fireEvent.click(screen.getByRole("button", { name: /load older sessions/i })); - await screen.findByText("Old 19"); - - vi.useFakeTimers(); - try { - act(() => { - scan.handler?.({ - discovered: 1, - ingested: 1, - skipped: 0, - failed: 0, - enriched: 1, - done: true, - }); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(300); - }); - expect(pages).toHaveBeenLastCalledWith(120, null); - expect(screen.getByText("Old 19")).toBeTruthy(); - } finally { - vi.useRealTimers(); - } + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("option", { name: /Fix flaky retry backoff/ })).toBeTruthy(); }); - it("keeps the latest session detail when responses finish out of order", async () => { - const sessionA = summary("session-a", "A session"); - const sessionB = summary("session-b", "B session"); - vi.mocked(listSessionsPage).mockResolvedValue({ - sessions: [sessionA, sessionB], - next_cursor: null, - }); - const detailA = deferred(); - const detailB = deferred(); - vi.mocked(getSession).mockImplementation((id) => - id === sessionA.id ? detailA.promise : detailB.promise, - ); - + it("offers first-run setup for a truly empty archive", async () => { + m.listDetectedAgents.mockResolvedValue([{ ...agents[0], session_count: 0 }]); + m.listRepositories.mockResolvedValue([]); + m.listSessionsPage.mockResolvedValue({ sessions: [], next_cursor: null }); render(); - fireEvent.click(await screen.findByText("A session")); - fireEvent.click(screen.getByText("B session")); - expect(screen.getByRole("status").textContent).toContain("Loading session"); - await act(async () => detailB.resolve(detail(sessionB))); - expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); - - await act(async () => detailA.resolve(detail(sessionA))); - expect(screen.queryByRole("heading", { name: "A session", level: 2 })).toBeNull(); - expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); + expect(await screen.findByRole("heading", { name: "Every agent session you have run, in one searchable place." })).toBeTruthy(); + expect(screen.getByText("~/.claude/projects")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Scan agent history" })); + expect(await screen.findByRole("button", { name: "Scanning agent history…" })).toBeTruthy(); + expect(m.rescan).toHaveBeenCalledTimes(1); }); - it("ignores an error from a superseded session request", async () => { - const sessionA = summary("session-a", "A session"); - const sessionB = summary("session-b", "B session"); - vi.mocked(listSessionsPage).mockResolvedValue({ - sessions: [sessionA, sessionB], - next_cursor: null, - }); - const detailA = deferred(); - const detailB = deferred(); - vi.mocked(getSession).mockImplementation((id) => - id === sessionA.id ? detailA.promise : detailB.promise, - ); - - render(); - fireEvent.click(await screen.findByText("A session")); - fireEvent.click(screen.getByText("B session")); - await act(async () => detailB.resolve(detail(sessionB))); - await act(async () => detailA.reject(new Error("stale session failure"))); + it("shows a session that failed to open, with a retry", async () => { + m.getSession.mockRejectedValueOnce(new Error("database is locked")); + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Fix flaky retry backoff/ })); + expect(await screen.findByText("Lore could not open this session.")).toBeTruthy(); - expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.getByRole("heading", { name: "B session", level: 2 })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("article", { name: "Fix flaky retry backoff" })).toBeTruthy(); }); +}); - it("resets active repo, folder, search, and session when forgetting everything", async () => { - const session = summary("session-1", "Initial session"); - vi.mocked(listSessionsPage).mockResolvedValue({ - sessions: [session], - next_cursor: null, - }); - vi.mocked(listRepositorySessionsPage).mockResolvedValue({ - sessions: [session], - next_cursor: null, - }); - vi.mocked(listRepositories).mockResolvedValue([ - { - id: "repo-1", - display_name: "lore", - session_count: 1, - worktree_count: 1, - identity_confidence: "confirmed", - primary_path: "/Users/dev/Lore", - is_missing: false, - }, - ]); - vi.mocked(listFolders).mockResolvedValue([ - { - id: "folder-1", - name: "Sprint 1", - session_count: 1, - position: 0, - }, - ]); - vi.mocked(getSession).mockResolvedValue(detail(session)); - vi.spyOn(window, "confirm").mockReturnValue(true); - - render(); +describe("session actions", () => { + it("loads and renders a recorded patch with line numbers", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Fix flaky retry backoff/ })); + await screen.findByRole("article", { name: "Fix flaky retry backoff" }); + fireEvent.click(screen.getByRole("tab", { name: /Files/ })); + fireEvent.click(screen.getByRole("button", { name: "Show patch" })); + + const patch = await screen.findByRole("figure", { name: "Patch for src/sync/worker.rs" }); + const rows = within(patch).getAllByRole("row").map((row) => row.textContent); + expect(rows).toEqual(["@@ -1,2 +1,2 @@", "1-let jitter = random();", "1+let jitter = self.rng.gen();", "22 keep();"]); + expect(m.getFilePatch).toHaveBeenCalledWith("fe1"); + }); - // Select repository - fireEvent.click(await screen.findByText("lore")); - // Open session - fireEvent.click(await screen.findByText("Initial session")); - expect(await screen.findByRole("heading", { name: "Initial session", level: 2 })).toBeTruthy(); - - // Open settings and click Forget everything - fireEvent.click(screen.getByRole("button", { name: "Settings" })); - expect(screen.getByRole("dialog", { name: "Settings" })).toBeTruthy(); - - // Mock empty response after forget - vi.mocked(listRepositories).mockResolvedValue([]); - vi.mocked(listFolders).mockResolvedValue([]); - vi.mocked(listSessionsPage).mockResolvedValue({ - sessions: [], - next_cursor: null, - }); - vi.mocked(forgetEverything).mockResolvedValueOnce({ - blobs_removed: 3, - source_paths: [], - }); + it("files the open session into a folder", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Fix flaky retry backoff/ })); + await screen.findByRole("article", { name: "Fix flaky retry backoff" }); + fireEvent.change(screen.getByRole("combobox", { name: "File in folder" }), { target: { value: "f1" } }); - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Forget everything" })); - }); + expect(await screen.findByText("Filed in “Release prep”.")).toBeTruthy(); + expect(m.setSessionFolder).toHaveBeenCalledWith("a", "f1"); + }); - expect(forgetEverything).toHaveBeenCalledTimes(1); - expect(screen.queryByRole("dialog", { name: "Settings" })).toBeNull(); - expect(screen.getByRole("status").textContent).toContain("Archive cleared (3 blob(s) removed)."); - expect(screen.queryByRole("heading", { name: "Initial session", level: 2 })).toBeNull(); + it("forgets a session only after confirming", async () => { + await renderLoaded(); + fireEvent.click(screen.getByRole("option", { name: /Fix flaky retry backoff/ })); + await screen.findByRole("article", { name: "Fix flaky retry backoff" }); + fireEvent.click(screen.getByRole("button", { name: "Forget…" })); + + const dialog = screen.getByRole("alertdialog", { name: "Forget “Fix flaky retry backoff”?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(m.forgetSession).not.toHaveBeenCalled(); + expect(screen.getByRole("article", { name: "Fix flaky retry backoff" })).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Forget…" })); + fireEvent.click(screen.getByRole("button", { name: "Forget session" })); + expect(await screen.findByText("Forgot “Fix flaky retry backoff”.")).toBeTruthy(); + expect(m.forgetSession).toHaveBeenCalledWith("a"); + expect(screen.getByText("Pick a session to read it.")).toBeTruthy(); }); }); diff --git a/src/App.tsx b/src/App.tsx index eb2a86a..d9ba3ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,647 +1,581 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import ArchiveOnboarding from "./components/ArchiveOnboarding"; import CommandPalette, { type Command } from "./components/CommandPalette"; -import ErrorBoundary from "./components/ErrorBoundary"; -import FolderList from "./components/FolderList"; -import RepositoryList from "./components/RepositoryList"; -import SearchResults from "./components/SearchResults"; -import SessionList from "./components/SessionList"; -import SessionView from "./components/SessionView"; -import SettingsPanel from "./components/SettingsPanel"; +import { BrowsePane, SearchPane } from "./components/ListPane"; +import type { OpenVia } from "./components/ListBox"; +import { ConfirmDialog, NameDialog } from "./components/Modal"; +import Onboarding from "./components/Onboarding"; +import QueryLine from "./components/QueryLine"; +import Reader, { type ReaderActions } from "./components/Reader"; +import ScopePicker from "./components/ScopePicker"; +import SettingsDialog from "./components/SettingsDialog"; +import StatusBar from "./components/StatusBar"; import { agentLabel } from "./format"; -import { useFolderManager } from "./hooks/useFolderManager"; -import { SEARCH_PAGE, useSearchState } from "./hooks/useSearchState"; -import { useSessionBrowser } from "./hooks/useSessionBrowser"; +import { queryTerms } from "./hooks/query"; +import { useCatalog, type Catalog } from "./hooks/useCatalog"; +import { useDebounced } from "./hooks/useDebounced"; +import { useOpenSession } from "./hooks/useOpenSession"; +import { usePagedList, type FetchPage } from "./hooks/usePagedList"; +import { ALL, initialWorkspace, workspaceReducer, type Overlay, type Scope } from "./hooks/workspace"; import { addAgentRoot, chooseAgentRootDirectory, + createFolder, + deleteFolder, + exportSessionMarkdown, forgetEverything, - getFilePatch, - listDetectedAgents, - listFolders, - listRepositories, + forgetSession, + listFolderSessionsPage, + listRepositorySessionsPage, + listSessionsPage, onScanProgress, removeAgentRoot, + renameFolder, rescan, + saveSessionExport, searchPage, + setSessionFolder, type DetectedAgent, - type RepositorySummary, - type ScanProgress, + type SearchHit, + type SessionSummary, } from "./ipc"; -/** The Lore mark: a node linked to two overlapping rings. */ +const SESSION_PAGE = 100; +const SEARCH_PAGE = 50; +const SEARCH_DEBOUNCE_MS = 180; +const EMPTY: Catalog = { agents: [], repositories: [], folders: [] }; + +const sessionKey = (s: SessionSummary) => s.id; +const hitKey = (h: SearchHit) => `${h.source_kind}:${h.source_id}:${h.field}`; + +function isTyping(target: EventTarget | null): boolean { + const el = target as HTMLElement | null; + return !!el && (el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)); +} + function Mark() { return ( -
-
- -

Lore

- git memory for coding agents - - preview build - - -
- - {progress && ( -
-
-
-
- - {progress.done - ? `Scan complete · ${progress.ingested} ingested · ${progress.skipped} skipped · ${progress.failed} failed` - : `${scanProcessed}/${scanTotal} sessions · ${scanPercent}% · ${progress.failed} failed`} - +
+
+
+ +

Lore

- )} - {error && ( -
- {error} - +
+ dispatch({ type: "query", query })} + inputRef={queryRef} + scopeLabel={scopeLabel} + scopeOpen={ws.overlay.kind === "scope"} + scopeButtonRef={scopeButtonRef} + onScope={() => overlay({ kind: ws.overlay.kind === "scope" ? "none" : "scope" })} + onLeaveDown={focusList} + /> + {ws.overlay.kind === "scope" && ( + 0 ? archiveTotal : null} + repositories={repositories} + folders={folders} + onChoose={chooseScope} + onNewFolder={newFolder} + onClose={() => { + closeOverlay(); + scopeButtonRef.current?.focus(); + }} + /> + )}
- )} - {notice && ( -
- {notice} - + +
- )} +
-
- - -
- updateSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "ArrowDown" && hits !== null && hits.length > 0) { - event.preventDefault(); - searchResultsRef.current?.focus(); - } - }} +
+ {catalogState.status === "failed" ? ( +
+

Lore could not open the archive.

+

{catalogState.error}

+

Your agent logs were not changed.

+ +
+ ) : archiveEmpty ? ( + void startScan()} + onAddRoot={(agent) => void addRoot(agent)} + onOpenSettings={() => overlay({ kind: "settings" })} /> - {query.trim() ? ( - void loadMore()} - onExitUp={() => searchInputRef.current?.focus()} - /> - ) : ( - archiveStatus === "loading" || sessionsPendingRequestRef.current !== null ? ( -
-

Loading sessions…

-
- ) : archiveStatus === "failed" ? ( -
-

Sessions unavailable.

-

Fix the error above, then try Rescan.

-
- ) : ( - - void loadOlderSessions(selectedRepoRef.current, selectedFolderRef.current) - } + ) : ( + <> +
+ {searching ? ( + + Searching sessions in {currentRepo.display_name}.{" "} + + + ) : currentFolder ? ( + "Searching all sessions. A folder cannot narrow a search yet." + ) : null + } + sort={ws.sort} + onSort={(sort) => dispatch({ type: "sort", sort })} + state={search.state} + openId={ws.openId} + onOpen={openSession} + onMore={() => void search.loadMore()} + listRef={listRef} + onExitTop={focusQuery} + /> + ) : ( + + + + + ) + } + state={catalogState.status === "loading" ? { status: "loading" } : browse.state} + openId={ws.openId} + onOpen={openSession} + onMore={() => void browse.loadMore()} + onRetry={() => void refreshAll()} + listRef={listRef} + onExitTop={focusQuery} + emptyHint={ + currentFolder + ? "This folder is empty. Open a session and choose File in folder." + : currentRepo + ? "No sessions are linked to this repository." + : "No sessions yet. Rescan to read your agent history." + } + /> + )} +
+
+ - ) - )} -
- -
- {archiveStatus === "loading" ? ( -
-

Loading archive…

-
- ) : archiveStatus === "failed" ? ( -
-
-

Archive unavailable.

-

No local agent logs were changed. Retry with Rescan.

-
-
- ) : archiveEmpty ? ( - void handleRescan()} - onAddAgentRoot={(agentId, displayName) => - void handleAddAgentRoot(agentId, displayName) - } - onOpenSettings={() => setSettingsOpen(true)} - /> - ) : sessionPendingRequestRef.current !== null ? ( -
-

Loading session…

-
- ) : ( - - void handleForget(refresh)} - /> - - )} -
-
- - {paletteOpen && ( - setPaletteOpen(false)} +
+ + )} + + + dispatch({ type: "flash", flash: null })} /> + + {ws.overlay.kind === "palette" && ( + + )} + {ws.overlay.kind === "settings" && ( + void addRoot(agent)} + onRemoveRoot={(agent, path) => void removeRoot(agent, path)} + onForgetEverything={forgetAll} + onClose={closeOverlay} + /> + )} + {ws.overlay.kind === "confirm" && ( + + )} + {ws.overlay.kind === "name" && ( + )} - - void handleAddAgentRoot(agentId, displayName) - } - onRemoveAgentRoot={(agentId, path) => void handleRemoveAgentRoot(agentId, path)} - onForgetEverything={handleForgetEverything} - onClose={() => setSettingsOpen(false)} - />
); } diff --git a/src/components/ArchiveOnboarding.tsx b/src/components/ArchiveOnboarding.tsx deleted file mode 100644 index 8490cb9..0000000 --- a/src/components/ArchiveOnboarding.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { DetectedAgent } from "../ipc"; - -const SUPPORTED_SOURCES = [ - { id: "claude-code", name: "Claude Code", path: "~/.claude/projects" }, - { id: "codex", name: "Codex", path: "~/.codex/sessions" }, -] as const; - -function detectionLabel(agent: DetectedAgent | undefined) { - if (!agent) return "ready to scan"; - return agent.installed ? "detected" : "not detected"; -} - -/** - * First-run guidance for a settled, genuinely empty archive. It states the - * actual V0 privacy boundary and only offers actions the current build owns. - */ -export default function ArchiveOnboarding({ - agents, - scanning, - rootBusy, - onScan, - onAddAgentRoot, - onOpenSettings, -}: { - agents: DetectedAgent[]; - scanning: boolean; - rootBusy: string | null; - onScan: () => void; - onAddAgentRoot: (agentId: string, displayName: string) => void; - onOpenSettings: () => void; -}) { - return ( -
-
-

Empty archive

-

Your agent history, searchable.

-

- Lore reads the sessions Claude Code and Codex already wrote to disk, then - organizes them by repository and Git evidence. -

- -
- Local and read-only. Archive content stays on this machine, and - Lore never changes your original agent logs. -
- -
-

What Lore checks

-
    - {SUPPORTED_SOURCES.map((source) => { - const agent = agents.find((candidate) => candidate.id === source.id); - const status = detectionLabel(agent); - const preferredRoot = - agent && agent.custom_roots.length > 0 - ? agent.custom_roots[agent.custom_roots.length - 1] - : (agent?.roots[0] ?? source.path); - const otherRootCount = Math.max(0, (agent?.roots.length ?? 1) - 1); - const displayRoot = - otherRootCount > 0 ? `${preferredRoot} + ${otherRootCount} more` : preferredRoot; - const rootTitle = agent?.roots.join("\n") ?? source.path; - const busy = rootBusy === source.id; - return ( -
  • - {source.name} - {displayRoot} - - {status} - - -
  • - ); - })} -
-
- -
- - -
-
-
- ); -} diff --git a/src/components/BackupSettings.test.tsx b/src/components/BackupSettings.test.tsx deleted file mode 100644 index 8ca7f96..0000000 --- a/src/components/BackupSettings.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const getBackupSchedule = vi.fn(); -const setBackupSchedule = vi.fn(); -const backupNow = vi.fn(); - -vi.mock("../ipc", () => ({ - getBackupSchedule: () => getBackupSchedule(), - setBackupSchedule: (interval: string, keep: number) => setBackupSchedule(interval, keep), - backupNow: () => backupNow(), -})); - -import BackupSettings from "./BackupSettings"; - -describe("BackupSettings", () => { - beforeEach(() => { - getBackupSchedule.mockReset().mockResolvedValue({ interval: "weekly", keep: 3 }); - setBackupSchedule.mockReset().mockResolvedValue(undefined); - backupNow.mockReset().mockResolvedValue(undefined); - }); - - it("loads the current schedule and reflects it in the controls", async () => { - render(); - const select = (await screen.findByLabelText("Automatic backups")) as HTMLSelectElement; - expect(select.value).toBe("weekly"); - expect((screen.getByLabelText("Keep newest") as HTMLInputElement).value).toBe("3"); - }); - - it("persists a changed interval", async () => { - render(); - const select = await screen.findByLabelText("Automatic backups"); - fireEvent.change(select, { target: { value: "daily" } }); - await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("daily", 3)); - }); - - it("runs an on-demand backup and reports success", async () => { - let resolveBackup: () => void = () => {}; - backupNow.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveBackup = resolve; - }), - ); - render(); - await screen.findByLabelText("Automatic backups"); - const button = screen.getByRole("button", { name: /back up now/i }); - expect(button.getAttribute("aria-busy")).toBe("false"); - fireEvent.click(button); - expect(button.getAttribute("aria-busy")).toBe("true"); - resolveBackup(); - await waitFor(() => expect(backupNow).toHaveBeenCalled()); - expect(await screen.findByText(/backup created/i)).toBeTruthy(); - expect(button.getAttribute("aria-busy")).toBe("false"); - }); - - it("disables the retention input when backups are off", async () => { - getBackupSchedule.mockResolvedValue({ interval: "off", keep: 5 }); - render(); - const keep = (await screen.findByLabelText("Keep newest")) as HTMLInputElement; - expect(keep.disabled).toBe(true); - }); - - it("persists changed retention count and clamps out-of-bounds input", async () => { - render(); - const keep = await screen.findByLabelText("Keep newest"); - - // Valid input change - fireEvent.change(keep, { target: { value: "14" } }); - await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 14)); - - // Below minimum (0) -> clamps to 1 - fireEvent.change(keep, { target: { value: "0" } }); - await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 1)); - - // Above maximum (200) -> clamps to 100 - fireEvent.change(keep, { target: { value: "200" } }); - await waitFor(() => expect(setBackupSchedule).toHaveBeenCalledWith("weekly", 100)); - }); - - it("displays error messages when backupNow or setBackupSchedule fails", async () => { - backupNow.mockRejectedValueOnce(new Error("Disk full")); - setBackupSchedule.mockRejectedValueOnce(new Error("Write error")); - - render(); - await screen.findByLabelText("Automatic backups"); - - // backupNow failure - const button = screen.getByRole("button", { name: /back up now/i }); - fireEvent.click(button); - expect(await screen.findByText(/Backup failed: Error: Disk full/i)).toBeTruthy(); - - // setBackupSchedule failure - const select = screen.getByLabelText("Automatic backups"); - fireEvent.change(select, { target: { value: "daily" } }); - expect(await screen.findByText(/Could not save: Error: Write error/i)).toBeTruthy(); - }); -}); diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx deleted file mode 100644 index 5bfed00..0000000 --- a/src/components/BackupSettings.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useEffect, useState } from "react"; - -import { clamp } from "../format"; -import { - backupNow, - getBackupSchedule, - setBackupSchedule, - type BackupInterval, -} from "../ipc"; - -const INTERVALS: { value: BackupInterval; label: string }[] = [ - { value: "off", label: "Off" }, - { value: "daily", label: "Daily" }, - { value: "weekly", label: "Weekly" }, -]; - -/** - * Backup cadence controls: automatic interval (off/daily/weekly), how many - * newest copies to retain, and an on-demand "Back up now". Reads and persists - * through the settings-backed schedule; backups are Lore-owned local snapshots, - * removed by "Forget everything". - */ -export default function BackupSettings() { - const [interval, setIntervalValue] = useState("off"); - const [keep, setKeep] = useState(7); - const [status, setStatus] = useState(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - let alive = true; - getBackupSchedule() - .then((s) => { - if (alive) { - setIntervalValue(s.interval as BackupInterval); - setKeep(s.keep); - } - }) - .catch(() => {}); - return () => { - alive = false; - }; - }, []); - - async function persist(nextInterval: BackupInterval, nextKeep: number) { - setIntervalValue(nextInterval); - setKeep(nextKeep); - setStatus(null); - try { - await setBackupSchedule(nextInterval, nextKeep); - } catch (e) { - setStatus(`Could not save: ${String(e)}`); - } - } - - async function runNow() { - setBusy(true); - setStatus(null); - try { - await backupNow(); - setStatus("Backup created."); - } catch (e) { - setStatus(`Backup failed: ${String(e)}`); - } finally { - setBusy(false); - } - } - - return ( -
-

- Backups -

-

- Local, Lore-owned snapshots of your archive, kept private on this machine and removed by - "Forget everything". -

-
- - -
-
- - - void persist(interval, clamp(Number(event.target.value) || 1, 1, 100)) - } - /> -
- - {status && ( -

- {status} -

- )} -
- ); -} diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx deleted file mode 100644 index 487c90c..0000000 --- a/src/components/CommandPalette.test.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import CommandPalette, { type Command } from "./CommandPalette"; - -function makeItems(): Command[] { - return [ - { id: "a", group: "Repository", label: "billing-service", hint: "12 sessions", run: vi.fn() }, - { id: "b", group: "Session", label: "Add webhook", hint: "Codex", run: vi.fn() }, - { id: "c", group: "Session", label: "Refactor retry", hint: "Claude", run: vi.fn() }, - ]; -} - -function deferred() { - let resolve!: (value: T) => void; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); - return { promise, resolve }; -} - -function renderPalette(props: Partial[0]> = {}) { - const finalProps = { - items: makeItems(), - onClose: vi.fn(), - ...props, - }; - const result = render(); - return { ...result, props: finalProps }; -} - -describe("CommandPalette", () => { - it("focuses its search field when mounted", () => { - renderPalette(); - expect(document.activeElement).toBe( - screen.getByRole("combobox", { name: /search commands and sessions/i }), - ); - }); - - it("filters as you type and runs the top match on Enter", () => { - const items = makeItems(); - const onClose = vi.fn(); - renderPalette({ items, onClose }); - const input = screen.getByRole("combobox"); - - fireEvent.change(input, { target: { value: "refactor" } }); - expect(screen.queryByText("billing-service")).toBeNull(); - expect(screen.getByText("Refactor retry")).toBeTruthy(); - - fireEvent.keyDown(input, { key: "Enter" }); - expect(items[2].run).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("finds loaded commands with an ordered fuzzy match", () => { - const items = makeItems(); - renderPalette({ items }); - const input = screen.getByRole("combobox"); - - fireEvent.change(input, { target: { value: "rfrr" } }); - - expect(screen.queryByText("billing-service")).toBeNull(); - expect(screen.getByText("Refactor retry")).toBeTruthy(); - fireEvent.keyDown(input, { key: "Enter" }); - expect(items[2].run).toHaveBeenCalledTimes(1); - }); - - it("navigates with arrow keys", () => { - const items = makeItems(); - renderPalette({ items }); - const input = screen.getByRole("combobox"); - fireEvent.keyDown(input, { key: "ArrowDown" }); - fireEvent.keyDown(input, { key: "Enter" }); - expect(items[1].run).toHaveBeenCalledTimes(1); - }); - - it("jumps to the first and last match with Home and End", () => { - const items = makeItems(); - renderPalette({ items }); - const input = screen.getByRole("combobox"); - - fireEvent.keyDown(input, { key: "End" }); - fireEvent.keyDown(input, { key: "Enter" }); - expect(items[2].run).toHaveBeenCalledTimes(1); - }); - - it("closes on Escape", () => { - const { props } = renderPalette(); - fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it("shows an empty state when nothing matches", () => { - renderPalette(); - fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzzz" } }); - expect(screen.getByText(/no matches/i)).toBeTruthy(); - }); - - it("debounces archive search and runs a returned session", async () => { - const run = vi.fn(); - const search = vi.fn().mockResolvedValue([ - { - id: "remote-session", - group: "Archive", - // The archive may match message content even when the title does not. - label: "Investigate checkout outage", - hint: "Codex · archive match", - run, - }, - ] satisfies Command[]); - vi.useFakeTimers(); - - try { - renderPalette({ search }); - const input = screen.getByRole("combobox"); - fireEvent.change(input, { target: { value: "backoff" } }); - - expect(search).not.toHaveBeenCalled(); - expect(screen.getByRole("status").textContent).toContain("Searching archive"); - - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - - expect(search).toHaveBeenCalledWith("backoff"); - expect(screen.getByText("Investigate checkout outage")).toBeTruthy(); - fireEvent.keyDown(input, { key: "Enter" }); - expect(run).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("handles synchronous search failure gracefully without throwing", async () => { - const search = vi.fn(() => { - throw new Error("sync failure"); - }); - vi.useFakeTimers(); - - try { - render( {}} />); - const input = screen.getByRole("combobox"); - fireEvent.change(input, { target: { value: "error-query" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - expect(search).toHaveBeenCalledWith("error-query"); - expect(screen.getByText(/archive search unavailable/i)).toBeTruthy(); - } finally { - vi.useRealTimers(); - } - }); - - it("ignores archive results from a superseded query", async () => { - const alpha = deferred(); - const beta = deferred(); - const search = vi.fn((query: string) => (query === "alpha" ? alpha.promise : beta.promise)); - vi.useFakeTimers(); - - try { - render( {}} />); - const input = screen.getByRole("combobox"); - - fireEvent.change(input, { target: { value: "alpha" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - fireEvent.change(input, { target: { value: "beta" } }); - await act(async () => { - await vi.advanceTimersByTimeAsync(200); - }); - - await act(async () => { - beta.resolve([ - { id: "beta", label: "Beta result", group: "Archive", run: vi.fn() }, - ]); - }); - expect(screen.getByText("Beta result")).toBeTruthy(); - - await act(async () => { - alpha.resolve([ - { id: "alpha", label: "Alpha result", group: "Archive", run: vi.fn() }, - ]); - }); - expect(screen.queryByText("Alpha result")).toBeNull(); - expect(screen.getByText("Beta result")).toBeTruthy(); - } finally { - vi.useRealTimers(); - } - }); - - it("closes when clicking the backdrop", () => { - const { props, container } = renderPalette(); - const backdrop = container.querySelector(".palette__backdrop"); - expect(backdrop).toBeTruthy(); - if (backdrop) fireEvent.click(backdrop); - expect(props.onClose).toHaveBeenCalledTimes(1); - }); - - it("executes an option and closes when clicked directly", () => { - const items = makeItems(); - const onClose = vi.fn(); - renderPalette({ items, onClose }); - - const option = screen.getByText("Add webhook"); - fireEvent.click(option); - expect(items[1].run).toHaveBeenCalledTimes(1); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it("updates active option on mouse enter", () => { - const items = makeItems(); - renderPalette({ items }); - - const option = screen.getByText("Refactor retry").closest("li"); - expect(option).toBeTruthy(); - if (option) fireEvent.mouseEnter(option); - - expect(option?.className).toContain("is-active"); - }); - - it("sets aria-setsize and aria-posinset on listbox options", () => { - const items = makeItems(); - renderPalette({ items }); - - const options = screen.getAllByRole("option"); - expect(options).toHaveLength(3); - expect(options[0].getAttribute("aria-setsize")).toBe("3"); - expect(options[0].getAttribute("aria-posinset")).toBe("1"); - expect(options[1].getAttribute("aria-setsize")).toBe("3"); - expect(options[1].getAttribute("aria-posinset")).toBe("2"); - expect(options[2].getAttribute("aria-setsize")).toBe("3"); - expect(options[2].getAttribute("aria-posinset")).toBe("3"); - }); - - it("restores focus to previous element on unmount", () => { - const trigger = document.createElement("button"); - document.body.appendChild(trigger); - trigger.focus(); - expect(document.activeElement).toBe(trigger); - - const { unmount } = renderPalette(); - expect(document.activeElement).toBe( - screen.getByRole("combobox", { name: /search commands and sessions/i }), - ); - - unmount(); - expect(document.activeElement).toBe(trigger); - trigger.remove(); - }); - - it("does not throw or close when Enter is pressed on an empty results list", () => { - const onClose = vi.fn(); - renderPalette({ items: [], onClose }); - const input = screen.getByRole("combobox"); - - fireEvent.keyDown(input, { key: "Enter" }); - expect(onClose).not.toHaveBeenCalled(); - }); - - it("closes on Escape key press without executing any item", () => { - const onClose = vi.fn(); - const items = makeItems(); - renderPalette({ items, onClose }); - const input = screen.getByRole("combobox"); - - fireEvent.keyDown(input, { key: "Escape" }); - expect(onClose).toHaveBeenCalledTimes(1); - expect(items[0].run).not.toHaveBeenCalled(); - }); - - it("renders command items without hints cleanly", () => { - const itemsWithoutHint: Command[] = [ - { id: "nohint", group: "Actions", label: "Toggle Dark Mode", run: vi.fn() }, - ]; - renderPalette({ items: itemsWithoutHint }); - expect(screen.getByText("Toggle Dark Mode")).toBeTruthy(); - expect(screen.queryByText("12 sessions")).toBeNull(); - }); -}); diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 540210b..1ec0ea2 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -1,283 +1,187 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useFocusTrap } from "../focus-trap"; +import { Modal } from "./Modal"; export interface Command { id: string; + group: string; label: string; hint?: string; - group?: string; run: () => void; } -const MAX_RESULTS = 50; -const ARCHIVE_SEARCH_DEBOUNCE_MS = 160; -const ARCHIVE_SEARCH_MIN_LENGTH = 2; - -type ArchiveSearch = (query: string) => Promise; - -interface ArchiveState { - query: string; - items: Command[]; - status: "idle" | "loading" | "settled" | "failed"; -} - -const IDLE_ARCHIVE: ArchiveState = { query: "", items: [], status: "idle" }; +const MAX_SHOWN = 50; +const ARCHIVE_DEBOUNCE_MS = 160; +const ARCHIVE_MIN_LENGTH = 2; /** - * Rank a compact, ordered fuzzy match. Contiguous and word-boundary matches - * win, while wide gaps lose points. `null` means the query is not a - * subsequence of the candidate at all. + * Score an ordered fuzzy match: substrings beat scattered letters, word starts + * beat word middles, and wide gaps cost points. `null` means no match. */ -function fuzzyScore(query: string, candidate: string): number | null { - const needle = query.trim().slice(0, 256).toLowerCase(); - const haystack = candidate.toLowerCase(); +export function fuzzyScore(query: string, candidate: string): number | null { + const needle = query.trim().toLowerCase(); + const hay = candidate.toLowerCase(); if (needle === "") return 0; - - const substringAt = haystack.indexOf(needle); - if (substringAt >= 0) { - return 1_000 - substringAt * 2 - (haystack.length - needle.length) * 0.1; - } - + const at = hay.indexOf(needle); + if (at >= 0) return 1_000 - at * 2 - (hay.length - needle.length) * 0.1; let score = 0; - let previous = -1; - let first = -1; - for (const character of needle) { - const at = haystack.indexOf(character, previous + 1); - if (at < 0) return null; - if (first < 0) first = at; - - const atBoundary = at === 0 || /[\s/_.:-]/.test(haystack[at - 1] ?? ""); - score += atBoundary ? 16 : 8; - if (previous >= 0) { - const gap = at - previous - 1; - score += gap === 0 ? 12 : Math.max(0, 6 - gap); - } - previous = at; + let prev = -1; + for (const ch of needle) { + const i = hay.indexOf(ch, prev + 1); + if (i < 0) return null; + score += i === 0 || /[\s/_.:-]/.test(hay[i - 1] ?? "") ? 16 : 8; + if (prev >= 0) score += i === prev + 1 ? 12 : Math.max(0, 6 - (i - prev - 1)); + prev = i; } - - return score - first - (haystack.length - needle.length) * 0.1; + return score - (hay.length - needle.length) * 0.1; } -function commandScore(query: string, command: Command): number | null { +function scoreCommand(query: string, command: Command): number | null { const label = fuzzyScore(query, command.label); const hint = command.hint ? fuzzyScore(query, command.hint) : null; - const group = command.group ? fuzzyScore(query, command.group) : null; - const scores = [ - label, - hint === null ? null : hint - 30, - group === null ? null : group - 50, - ].filter((score): score is number => score !== null); - return scores.length > 0 ? Math.max(...scores) : null; + if (label === null && hint === null) return null; + return Math.max(label ?? -Infinity, hint === null ? -Infinity : hint - 30); } +type ArchiveState = + | { status: "idle" } + | { status: "loading"; query: string } + | { status: "ready"; query: string; items: Command[] } + | { status: "failed"; query: string }; + /** - * A ⌘K command palette (Raycast/Linear pattern): fuzzy-filter over - * repositories, sessions, and actions, keyboard-driven (↑/↓, Enter, Esc). + * Jump anywhere: actions, repositories, folders, loaded sessions, and a + * full-text search of the archive for anything else. */ export default function CommandPalette({ - items, - search, + commands, + searchArchive, onClose, }: { - items: Command[]; - search?: ArchiveSearch; + commands: Command[]; + searchArchive: (query: string) => Promise; onClose: () => void; }) { const [query, setQuery] = useState(""); const [active, setActive] = useState(0); - const [archive, setArchive] = useState(IDLE_ARCHIVE); - const inputRef = useRef(null); - const dialogRef = useRef(null); - const activeOptionRef = useRef(null); - const archiveTimerRef = useRef | null>(null); - const archiveRequestRef = useRef(0); - const searchRef = useRef(search); - useFocusTrap(dialogRef, true); - - const filtered = useMemo(() => { - const q = query.trim(); - if (!q) return items.slice(0, MAX_RESULTS); - const localMatches = items - .map((item, index) => ({ item, index, score: commandScore(q, item) })) - .filter((match): match is typeof match & { score: number } => match.score !== null); - const archiveMatches = archive.query === q - ? archive.items.map((item, index) => ({ - item, - index: items.length + index, - // FTS can match a session body even when its title does not fuzzy - // match. Keep those matches, after direct command-label matches. - score: commandScore(q, item) ?? -100 - index, - })) - : []; - const deduplicated = new Map(); - for (const match of [...localMatches, ...archiveMatches]) { - const previous = deduplicated.get(match.item.id); - if (!previous || match.score > previous.score) deduplicated.set(match.item.id, match); - } - return [...deduplicated.values()] - .sort((a, b) => b.score - a.score || a.index - b.index) - .slice(0, MAX_RESULTS) - .map(({ item }) => item); - }, [archive, items, query]); - - const activeIndex = filtered.length === 0 ? 0 : Math.min(active, filtered.length - 1); - const archiveLoading = archive.query === query.trim() && archive.status === "loading"; - const archiveFailed = archive.query === query.trim() && archive.status === "failed"; + const [archive, setArchive] = useState({ status: "idle" }); + const input = useRef(null); + const trimmed = query.trim(); useEffect(() => { - searchRef.current = search; - }, [search]); - - useEffect(() => { - const previousFocus = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - inputRef.current?.focus(); + if (trimmed.length < ARCHIVE_MIN_LENGTH) { + setArchive({ status: "idle" }); + return; + } + let cancelled = false; + setArchive({ status: "loading", query: trimmed }); + const timer = setTimeout(() => { + searchArchive(trimmed).then( + (items) => !cancelled && setArchive({ status: "ready", query: trimmed, items }), + () => !cancelled && setArchive({ status: "failed", query: trimmed }), + ); + }, ARCHIVE_DEBOUNCE_MS); return () => { - if (previousFocus?.isConnected) previousFocus.focus(); + cancelled = true; + clearTimeout(timer); }; - }, []); - - useEffect( - () => () => { - archiveRequestRef.current += 1; - if (archiveTimerRef.current !== null) clearTimeout(archiveTimerRef.current); - }, - [], - ); - - useEffect(() => { - activeOptionRef.current?.scrollIntoView?.({ block: "nearest" }); - }, [activeIndex, filtered]); - - function run(index: number) { - const command = filtered[index]; - if (command) { - command.run(); - onClose(); + }, [searchArchive, trimmed]); + + const shown = useMemo(() => { + if (trimmed === "") return commands.slice(0, MAX_SHOWN); + const local = commands + .map((command, index) => ({ command, index, score: scoreCommand(trimmed, command) })) + .filter((m): m is typeof m & { score: number } => m.score !== null); + // Full-text hits can match a session body even when the title does not + // fuzzy-match, so they rank after direct matches instead of disappearing. + const remote = + archive.status === "ready" && archive.query === trimmed + ? archive.items.map((command, index) => ({ + command, + index: commands.length + index, + score: scoreCommand(trimmed, command) ?? -100 - index, + })) + : []; + const best = new Map(); + for (const match of [...local, ...remote]) { + const seen = best.get(match.command.id); + if (!seen || match.score > seen.score) best.set(match.command.id, match); } - } + return [...best.values()] + .sort((a, b) => b.score - a.score || a.index - b.index) + .slice(0, MAX_SHOWN) + .map((m) => m.command); + }, [archive, commands, trimmed]); - function updateQuery(next: string) { - setQuery(next); - setActive(0); - archiveRequestRef.current += 1; - const request = archiveRequestRef.current; - if (archiveTimerRef.current !== null) clearTimeout(archiveTimerRef.current); + const current = Math.min(active, Math.max(shown.length - 1, 0)); - const normalized = next.trim(); - if (!searchRef.current || normalized.length < ARCHIVE_SEARCH_MIN_LENGTH) { - setArchive(IDLE_ARCHIVE); - return; - } + useEffect(() => { + document.getElementById(shown[current]?.id ?? "")?.scrollIntoView?.({ block: "nearest" }); + }, [current, shown]); - setArchive({ query: normalized, items: [], status: "loading" }); - archiveTimerRef.current = setTimeout(() => { - archiveTimerRef.current = null; - try { - void Promise.resolve(searchRef.current?.(normalized)).then( - (nextItems) => { - if (request !== archiveRequestRef.current) return; - setArchive({ query: normalized, items: nextItems ?? [], status: "settled" }); - }, - () => { - if (request !== archiveRequestRef.current) return; - setArchive({ query: normalized, items: [], status: "failed" }); - }, - ); - } catch { - if (request !== archiveRequestRef.current) return; - setArchive({ query: normalized, items: [], status: "failed" }); - } - }, ARCHIVE_SEARCH_DEBOUNCE_MS); + function run(command: Command | undefined) { + if (!command) return; + onClose(); + command.run(); } function onKeyDown(event: React.KeyboardEvent) { - if (event.key === "Escape") { - event.preventDefault(); - onClose(); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - if (filtered.length > 0) setActive(Math.min(activeIndex + 1, filtered.length - 1)); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - if (filtered.length > 0) setActive(Math.max(activeIndex - 1, 0)); - } else if (event.key === "Home") { - event.preventDefault(); - if (filtered.length > 0) setActive(0); - } else if (event.key === "End") { - event.preventDefault(); - if (filtered.length > 0) setActive(filtered.length - 1); - } else if (event.key === "Enter") { - event.preventDefault(); - run(activeIndex); - } + if (event.key === "ArrowDown") setActive(Math.min(current + 1, shown.length - 1)); + else if (event.key === "ArrowUp") setActive(Math.max(current - 1, 0)); + else if (event.key === "Home") setActive(0); + else if (event.key === "End") setActive(Math.max(shown.length - 1, 0)); + else if (event.key === "Enter") run(shown[current]); + else return; + event.preventDefault(); } + const searching = archive.status === "loading"; return ( -
-
event.stopPropagation()} - > - updateQuery(event.target.value)} - onKeyDown={onKeyDown} - role="combobox" - aria-label="Search commands and sessions" - aria-autocomplete="list" - aria-expanded="true" - aria-controls="palette-list" - aria-activedescendant={filtered[activeIndex]?.id} - /> -
    - {filtered.length === 0 && !archiveLoading ? ( -
  • - No matches -
  • - ) : ( - filtered.map((command, index) => ( -
  • setActive(index)} - onClick={() => run(index)} - > - {command.group && {command.group}} - {command.label} - {command.hint && {command.hint}} -
  • - )) - )} -
- {(archiveLoading || archiveFailed) && ( -

- {archiveLoading ? "Searching archive…" : "Archive search unavailable."} -

- )} -
-
+ + { + setQuery(event.target.value); + setActive(0); + }} + onKeyDown={onKeyDown} + /> +
    + {shown.map((command, index) => ( +
  • setActive(index)} + onClick={() => run(command)} + > + {command.label} + {command.hint && {command.hint}} + {command.group} +
  • + ))} +
+

+ {shown.length === 0 && !searching + ? `Nothing matches “${trimmed}”.` + : searching + ? "Searching the archive…" + : archive.status === "failed" + ? "Archive search failed. Local matches are still shown." + : ""} +

+
); } diff --git a/src/components/Diff.tsx b/src/components/Diff.tsx new file mode 100644 index 0000000..e8fcc70 --- /dev/null +++ b/src/components/Diff.tsx @@ -0,0 +1,85 @@ +const MAX_LINES = 1_000; + +type LineKind = "file" | "hunk" | "add" | "del" | "context"; + +interface DiffLine { + kind: LineKind; + text: string; + oldNo: number | null; + newNo: number | null; +} + +const HUNK = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; +const FILE_HEADER = /^(diff --git |index |\+\+\+ |--- |\*\*\* (Begin|End|Update|Add|Delete) )/; + +/** True when text looks like a unified diff or an apply_patch envelope. */ +export function looksLikePatch(text: string): boolean { + return /^(@@ |diff --git |\*\*\* Begin Patch|--- \S)/m.test(text); +} + +/** Classify each line and carry old/new line numbers through unified hunks. */ +export function parseDiff(text: string): DiffLine[] { + let oldNo: number | null = null; + let newNo: number | null = null; + return text + .replace(/\n$/, "") + .split("\n") + .map((line): DiffLine => { + const hunk = HUNK.exec(line); + if (hunk) { + oldNo = Number(hunk[1]); + newNo = Number(hunk[2]); + return { kind: "hunk", text: line, oldNo: null, newNo: null }; + } + if (line.startsWith("@@")) return { kind: "hunk", text: line, oldNo: null, newNo: null }; + if (line.startsWith("diff --git ") || line.startsWith("*** ")) { + oldNo = null; + newNo = null; + } + // Inside a numbered hunk, `--- x` is a removed line that began with `--`. + if (oldNo === null && FILE_HEADER.test(line)) { + return { kind: "file", text: line, oldNo: null, newNo: null }; + } + if (line.startsWith("+")) { + const row = { kind: "add" as const, text: line, oldNo: null, newNo }; + if (newNo !== null) newNo += 1; + return row; + } + if (line.startsWith("-")) { + const row = { kind: "del" as const, text: line, oldNo, newNo: null }; + if (oldNo !== null) oldNo += 1; + return row; + } + const row = { kind: "context" as const, text: line, oldNo, newNo }; + if (oldNo !== null) oldNo += 1; + if (newNo !== null) newNo += 1; + return row; + }); +} + +/** A patch with a line-number gutter and add/remove tinting. */ +export default function Diff({ text, label = "Patch" }: { text: string; label?: string }) { + const all = parseDiff(text); + const lines = all.slice(0, MAX_LINES); + const hidden = all.length - lines.length; + return ( +
+ + + {lines.map((line, index) => ( + + + + + + ))} + +
{line.text || " "}
+ {hidden > 0 &&
{hidden} more lines not shown
} +
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 76ed263..8027bd9 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -2,71 +2,46 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; interface Props { children: ReactNode; - fallback?: ReactNode; onReset?: () => void; } interface State { - hasError: boolean; error: Error | null; } -export class ErrorBoundary extends Component { - public override state: State = { - hasError: false, - error: null, - }; +/** Contain a render crash to the region that threw, with a way back. */ +export default class ErrorBoundary extends Component { + public override state: State = { error: null }; public static getDerivedStateFromError(error: Error): State { - return { hasError: true, error }; + return { error }; } - public override componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Local-only logging: no remote telemetry per SECURITY.md - console.error("Uncaught render error:", error, errorInfo); + public override componentDidCatch(error: Error, info: ErrorInfo) { + // Local only: Lore sends no telemetry. + console.error("Render error:", error, info); } - public handleReset = () => { - this.setState({ hasError: false, error: null }); + private reset = () => { + this.setState({ error: null }); this.props.onReset?.(); }; public override render() { - if (this.state.hasError) { - if (this.props.fallback) { - return this.props.fallback; - } - - return ( -
-
-

Something went wrong

-

- {this.state.error?.message || "An unexpected error occurred while rendering this component."} -

-
- - -
-
+ if (!this.state.error) return this.props.children; + return ( +
+

Something went wrong

+

{this.state.error.message || "This part of Lore failed to draw."}

+
+ +
- ); - } - - return this.props.children; +
+ ); } } - -export default ErrorBoundary; diff --git a/src/components/FileChanges.tsx b/src/components/FileChanges.tsx new file mode 100644 index 0000000..f9d102b --- /dev/null +++ b/src/components/FileChanges.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; + +import { getFilePatch, type FileEventDto } from "../ipc"; +import Diff from "./Diff"; + +const SOURCE: Record = { + agent_patch: "recorded patch", + agent_tool_input: "from tool input", + lore_capture: "captured by Lore", +}; + +type PatchState = + | { status: "closed" } + | { status: "loading" } + | { status: "open"; text: string | null } + | { status: "failed"; error: string }; + +function editorHref(path: string): string { + return `vscode://file/${encodeURI(path.startsWith("/") ? path : `/${path}`)}`; +} + +function FileRow({ file }: { file: FileEventDto }) { + const [patch, setPatch] = useState({ status: "closed" }); + + async function toggle() { + if (patch.status === "open" || patch.status === "failed") { + setPatch({ status: "closed" }); + return; + } + setPatch({ status: "loading" }); + try { + setPatch({ status: "open", text: await getFilePatch(file.id) }); + } catch (e) { + setPatch({ status: "failed", error: String(e) }); + } + } + + const expanded = patch.status === "open" || patch.status === "failed"; + return ( +
  • +
    + {file.change_kind} + + {file.path} + + + {file.lines_added != null && +{file.lines_added}} + {file.lines_removed != null && −{file.lines_removed}} + + {SOURCE[file.source] ?? file.source} + + {file.has_patch && ( + + )} + + Open + + +
    + {patch.status === "open" && + (patch.text ? ( + + ) : ( +

    The patch for this change was not stored.

    + ))} + {patch.status === "failed" && ( +

    + Could not load the patch: {patch.error} +

    + )} +
  • + ); +} + +export default function FileChanges({ files }: { files: FileEventDto[] }) { + if (files.length === 0) { + return

    This session did not touch any files Lore could see.

    ; + } + return ( +
      + {files.map((file) => ( + + ))} +
    + ); +} diff --git a/src/components/FolderList.test.tsx b/src/components/FolderList.test.tsx deleted file mode 100644 index 71025e0..0000000 --- a/src/components/FolderList.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import type { FolderSummary } from "../ipc"; -import FolderList from "./FolderList"; - -function folder(id: string, name: string, count = 0): FolderSummary { - return { id, name, session_count: count, position: 0 }; -} - -function setup(overrides: Partial[0]> = {}) { - const props = { - folders: [folder("f1", "Inbox", 2)], - selectedId: null, - onSelect: vi.fn(), - onCreate: vi.fn(), - onRename: vi.fn(), - onDelete: vi.fn(), - onDropSession: vi.fn(), - ...overrides, - }; - render(); - return props; -} - -describe("FolderList", () => { - it("renders folders with their thread counts", () => { - setup(); - expect(screen.getByText("Inbox")).toBeTruthy(); - expect(screen.getByText("2")).toBeTruthy(); - }); - - it("shows an empty hint when there are no folders", () => { - setup({ folders: [] }); - expect(screen.getByText(/No folders yet/i)).toBeTruthy(); - }); - - it("creates a folder on Enter and ignores blank names", () => { - const props = setup({ folders: [] }); - fireEvent.click(screen.getByLabelText("New folder")); - const field = screen.getByLabelText("New folder name"); - - // Blank input commits nothing. - fireEvent.keyDown(field, { key: "Enter" }); - expect(props.onCreate).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByLabelText("New folder")); - const field2 = screen.getByLabelText("New folder name"); - fireEvent.change(field2, { target: { value: " Later " } }); - fireEvent.keyDown(field2, { key: "Enter" }); - expect(props.onCreate).toHaveBeenCalledWith("Later"); - }); - - it("selects a folder on click and deletes on the ✕", () => { - const props = setup(); - fireEvent.click(screen.getByRole("button", { name: /^Inbox/ })); - expect(props.onSelect).toHaveBeenCalledWith("f1"); - - fireEvent.click(screen.getByLabelText("Delete folder Inbox")); - expect(props.onDelete).toHaveBeenCalledWith("f1"); - }); - - it("renames a folder on F2 keyboard shortcut", () => { - const props = setup(); - const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); - fireEvent.keyDown(folderBtn, { key: "F2" }); - - const editField = screen.getByLabelText("Rename folder Inbox"); - expect(editField).toBeTruthy(); - fireEvent.change(editField, { target: { value: "Archive" } }); - fireEvent.keyDown(editField, { key: "Enter" }); - expect(props.onRename).toHaveBeenCalledWith("f1", "Archive"); - }); - - it("deletes a folder on Delete or Cmd+Backspace keyboard shortcut", () => { - const props = setup(); - const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); - fireEvent.keyDown(folderBtn, { key: "Delete" }); - expect(props.onDelete).toHaveBeenCalledWith("f1"); - - fireEvent.keyDown(folderBtn, { key: "Backspace", metaKey: true }); - expect(props.onDelete).toHaveBeenCalledTimes(2); - }); - - it("files a dropped thread into the folder", () => { - const props = setup(); - const row = screen.getByText("Inbox").closest("li")!; - const data: Record = { - "application/x-lore-session": "sess-9", - }; - const dataTransfer = { - types: Object.keys(data), - getData: (type: string) => data[type] ?? "", - dropEffect: "", - }; - fireEvent.drop(row, { dataTransfer }); - expect(props.onDropSession).toHaveBeenCalledWith("f1", "sess-9"); - }); - - it("toggles is-dragover class on dragOver and dragLeave with valid session mime", () => { - setup(); - const row = screen.getByText("Inbox").closest("li")!; - const dataTransfer = { - types: ["application/x-lore-session"], - dropEffect: "", - }; - fireEvent.dragOver(row, { dataTransfer }); - expect(row.classList.contains("is-dragover")).toBe(true); - expect(dataTransfer.dropEffect).toBe("move"); - - fireEvent.dragLeave(row); - expect(row.classList.contains("is-dragover")).toBe(false); - }); - - it("cancels folder creation and renaming on Escape key", () => { - const props = setup(); - // Cancel create - fireEvent.click(screen.getByLabelText("New folder")); - const createField = screen.getByLabelText("New folder name"); - fireEvent.change(createField, { target: { value: "Abandoned" } }); - fireEvent.keyDown(createField, { key: "Escape" }); - expect(props.onCreate).not.toHaveBeenCalled(); - expect(screen.queryByLabelText("New folder name")).toBeNull(); - - expect(props.onRename).not.toHaveBeenCalled(); - expect(screen.queryByLabelText("Rename folder Inbox")).toBeNull(); - }); - - it("ignores dragOver events with non-session MIME types", () => { - setup(); - const row = screen.getByText("Inbox").closest("li")!; - const dataTransfer = { - types: ["text/plain"], - dropEffect: "", - }; - fireEvent.dragOver(row, { dataTransfer }); - expect(row.classList.contains("is-dragover")).toBe(false); - expect(dataTransfer.dropEffect).toBe(""); - }); - - it("commits folder rename on blur", () => { - const props = setup(); - const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); - fireEvent.keyDown(folderBtn, { key: "F2" }); - const editField = screen.getByLabelText("Rename folder Inbox"); - fireEvent.change(editField, { target: { value: "Inbox Renamed" } }); - fireEvent.blur(editField); - expect(props.onRename).toHaveBeenCalledWith("f1", "Inbox Renamed"); - }); - - it("does not call onRename when submitted name is blank", () => { - const props = setup(); - const folderBtn = screen.getByRole("button", { name: /^Inbox/ }); - fireEvent.keyDown(folderBtn, { key: "F2" }); - const editField = screen.getByLabelText("Rename folder Inbox"); - fireEvent.change(editField, { target: { value: " " } }); - fireEvent.keyDown(editField, { key: "Enter" }); - expect(props.onRename).not.toHaveBeenCalled(); - }); -}); diff --git a/src/components/FolderList.tsx b/src/components/FolderList.tsx deleted file mode 100644 index 3548e6d..0000000 --- a/src/components/FolderList.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { useRef, useState } from "react"; - -import type { FolderSummary } from "../ipc"; -import { SESSION_DND_MIME } from "./SessionList"; - -/** - * The user's folders in the left rail. Folders are created inline, selected to - * filter the session list, renamed by double-click, and deleted with the ✕. - * Dragging a thread from the session list onto a folder files it there (one - * folder per thread). - */ -export default function FolderList({ - folders, - selectedId, - onSelect, - onCreate, - onRename, - onDelete, - onDropSession, -}: { - folders: FolderSummary[]; - selectedId: string | null; - onSelect: (id: string) => void; - onCreate: (name: string) => void; - onRename: (id: string, name: string) => void; - onDelete: (id: string) => void; - onDropSession: (folderId: string, sessionId: string) => void; -}) { - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(""); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(""); - const [dragOverId, setDragOverId] = useState(null); - // Enter and blur both fire commit; the guard keeps that from double-creating. - const committedRef = useRef(false); - - function openCreate() { - committedRef.current = false; - setNewName(""); - setCreating(true); - } - function commitCreate() { - if (committedRef.current) return; - committedRef.current = true; - const name = newName.trim(); - setCreating(false); - setNewName(""); - if (name) onCreate(name); - } - function cancelCreate() { - committedRef.current = true; - setCreating(false); - setNewName(""); - } - - function startEdit(folder: FolderSummary) { - committedRef.current = false; - setEditName(folder.name); - setEditingId(folder.id); - } - function commitEdit() { - if (committedRef.current) return; - committedRef.current = true; - const name = editName.trim(); - const id = editingId; - setEditingId(null); - if (id && name) onRename(id, name); - } - function cancelEdit() { - committedRef.current = true; - setEditingId(null); - } - - function hasSession(event: React.DragEvent) { - return event.dataTransfer.types.includes(SESSION_DND_MIME); - } - - return ( - - ); -} diff --git a/src/components/GitEvidence.tsx b/src/components/GitEvidence.tsx new file mode 100644 index 0000000..9299537 --- /dev/null +++ b/src/components/GitEvidence.tsx @@ -0,0 +1,103 @@ +import { formatRelative, formatTime } from "../format"; +import type { GitObservationDto, RepositorySummary, SegmentDto } from "../ipc"; + +/** + * Provenance stays visible on every row: who claimed the git fact decides how + * far to trust it, so the classes are never merged into one rail. + */ +const PROVENANCE: Record = { + agent_recorded: { label: "Agent recorded", help: "The agent wrote this down during the session." }, + agent_patch: { label: "Agent patch", help: "Taken from a patch the agent recorded." }, + lore_captured: { label: "Lore observed", help: "Lore read this from the repository." }, + lore_reverified: { label: "Lore re-verified", help: "Lore checked this again later." }, +}; + +const TIMING: Record = { + exact_event: "at the event", + near_event: "near the event", + retrospective: "after the fact", + current_only: "current state only", +}; + +export default function GitEvidence({ + observations, + segments, + repositories, +}: { + observations: GitObservationDto[]; + segments: SegmentDto[]; + repositories: RepositorySummary[]; +}) { + const repoName = (id: string | null) => + id === null ? null : (repositories.find((r) => r.id === id)?.display_name ?? id); + + return ( +
    + {observations.length === 0 ? ( +

    No git evidence was recorded or observed for this session.

    + ) : ( +
      + {observations.map((o) => { + const provenance = PROVENANCE[o.source] ?? { label: o.source, help: "" }; + return ( +
    1. + + {provenance.label} + + + {o.branch && {o.branch}} + {o.commit_sha && {o.commit_sha.slice(0, 10)}} + {o.is_dirty === true && uncommitted changes} + {o.commit_exists === false && commit no longer exists} + {o.remote_url_norm && {o.remote_url_norm}} + + + {TIMING[o.temporal_confidence] ?? o.temporal_confidence}, {formatRelative(o.observed_at)} + +
    2. + ); + })} +
    + )} + {segments.length > 0 && ( +
    +

    + Working context +

    + + + + + + + + + + + {segments.map((s) => ( + + + + + + + ))} + +
    MessagesDirectoryRepositoryModel
    + {s.seq_start}–{s.seq_end} + + {s.cwd ?? "unknown"} + + {repoName(s.repository_id) ?? "unresolved"} + {s.repository_id && s.resolution_confidence !== "high" && ( + ({s.resolution_confidence} confidence) + )} + {s.model ?? "unknown"}
    +
    + )} +
    + ); +} diff --git a/src/components/Highlight.tsx b/src/components/Highlight.tsx new file mode 100644 index 0000000..b81fb42 --- /dev/null +++ b/src/components/Highlight.tsx @@ -0,0 +1,46 @@ +import { Fragment, type ReactNode } from "react"; + +import { HIGHLIGHT_END, HIGHLIGHT_START } from "../ipc"; + +/** Render a search snippet, turning the backend's highlight markers into marks. */ +export function Snippet({ text }: { text: string }) { + const nodes: ReactNode[] = []; + let rest = text; + let key = 0; + while (rest.length > 0) { + const start = rest.indexOf(HIGHLIGHT_START); + if (start === -1) { + nodes.push({rest}); + break; + } + if (start > 0) nodes.push({rest.slice(0, start)}); + const from = start + HIGHLIGHT_START.length; + const end = rest.indexOf(HIGHLIGHT_END, from); + if (end === -1) { + nodes.push({rest.slice(from)}); + break; + } + nodes.push({rest.slice(from, end)}); + rest = rest.slice(end + HIGHLIGHT_END.length); + } + return <>{nodes}; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Mark every case-insensitive occurrence of the search terms in plain text. */ +export function Highlight({ text, terms }: { text: string; terms: string[] }) { + const usable = terms.map((t) => t.replace(/^["']+|["']+$/g, "")).filter((t) => t.length > 1); + if (usable.length === 0) return <>{text}; + const pattern = new RegExp(`(${usable.map(escapeRegExp).join("|")})`, "gi"); + const parts = text.split(pattern); + return ( + <> + {parts.map((part, index) => + index % 2 === 1 ? {part} : {part}, + )} + + ); +} diff --git a/src/components/ListBox.tsx b/src/components/ListBox.tsx new file mode 100644 index 0000000..b17ff85 --- /dev/null +++ b/src/components/ListBox.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; + +import { clamp } from "../format"; +import { useWindowing } from "../virtual"; + +export type OpenVia = "keyboard" | "pointer"; + +interface ListBoxProps { + id: string; + label: string; + items: T[]; + keyOf: (item: T) => string; + isOpen: (item: T) => boolean; + renderRow: (item: T) => ReactNode; + onOpen: (item: T, via: OpenVia) => void; + listRef: RefObject; + /** Arrow up from the first row hands focus back (to the query line). */ + onExitTop?: () => void; + /** Called when the user reaches the end, by scrolling or by keyboard. */ + onReachEnd?: () => void; + footer?: ReactNode; +} + +/** + * A virtualized single-select listbox. Arrow keys or j/k move the active row, + * Home and End jump, Enter opens. Rows must share one fixed height so the + * window math in `virtual.ts` holds. + */ +export default function ListBox({ + id, + label, + items, + keyOf, + isOpen, + renderRow, + onOpen, + listRef, + onExitTop, + onReachEnd, + footer, +}: ListBoxProps) { + const listKey = items.length > 0 ? keyOf(items[0]) : null; + const [cursor, setCursor] = useState({ listKey, index: 0 }); + const last = items.length - 1; + const active = cursor.listKey === listKey ? clamp(cursor.index, 0, Math.max(last, 0)) : 0; + const scroller = useRef(null); + const { startIndex, endIndex, padTop, padBottom, scrollToIndex } = useWindowing( + scroller, + items.length, + ); + + useEffect(() => { + scrollToIndex(active); + }, [active, scrollToIndex]); + + function move(index: number) { + setCursor({ listKey, index: clamp(index, 0, Math.max(last, 0)) }); + if (index > last) onReachEnd?.(); + } + + function onKeyDown(event: React.KeyboardEvent) { + const page = 10; + switch (event.key) { + case "ArrowDown": + case "j": + move(active + 1); + break; + case "ArrowUp": + case "k": + if (active === 0 && onExitTop) onExitTop(); + else move(active - 1); + break; + case "PageDown": + move(active + page); + break; + case "PageUp": + move(active - page); + break; + case "Home": + move(0); + break; + case "End": + move(last); + onReachEnd?.(); + break; + case "Enter": { + const item = items[active]; + if (item) onOpen(item, "keyboard"); + break; + } + default: + return; + } + event.preventDefault(); + } + + function onScroll() { + const el = scroller.current; + if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 160) onReachEnd?.(); + } + + const optionId = (index: number) => `${id}-${index}`; + + return ( +
    +
      0 ? optionId(active) : undefined} + onKeyDown={onKeyDown} + > + {padTop > 0 &&
    • { + setCursor({ listKey, index }); + onOpen(item, "pointer"); + }} + > + {renderRow(item)} +
    • + ); + })} + {padBottom > 0 &&
    + {footer} +
    + ); +} diff --git a/src/components/ListPane.tsx b/src/components/ListPane.tsx new file mode 100644 index 0000000..27b7d98 --- /dev/null +++ b/src/components/ListPane.tsx @@ -0,0 +1,228 @@ +import type { ReactNode, RefObject } from "react"; + +import { agentLabel, formatRelative, formatTime } from "../format"; +import type { SearchHit, SearchSort, SessionSummary } from "../ipc"; +import type { PagedState } from "../hooks/usePagedList"; +import { Snippet } from "./Highlight"; +import ListBox, { type OpenVia } from "./ListBox"; + +const FIELD: Record = { + title: "title", + text: "message", + patch: "patch", + content_json: "tool call", +}; + +function ParseFlag({ status }: { status: string }) { + if (status === "ok") return null; + return {status}; +} + +function SessionRow({ session }: { session: SessionSummary }) { + return ( + <> + {session.title ?? "Untitled session"} + + {agentLabel(session.agent_id)} + {session.message_count} messages + + + + + ); +} + +function HitRow({ hit }: { hit: SearchHit }) { + return ( + <> + {hit.title ?? "Untitled session"} + + {agentLabel(hit.agent_id)} + in {FIELD[hit.field] ?? hit.field} + + + + + + + ); +} + +function Placeholder({ role = "status", children }: { role?: "status" | "alert"; children: ReactNode }) { + return ( +
    + {children} +
    + ); +} + +function MoreFooter({ cursor, more, onMore, label }: { cursor: string | null; more: string; onMore: () => void; label: string }) { + if (cursor === null) return null; + return ( +
    + +
    + ); +} + +export function BrowsePane({ + heading, + count, + headerActions, + state, + openId, + onOpen, + onMore, + onRetry, + listRef, + onExitTop, + emptyHint, +}: { + heading: string; + count: number | null; + headerActions?: ReactNode; + state: PagedState; + openId: string | null; + onOpen: (id: string, via: OpenVia) => void; + onMore: () => void; + onRetry: () => void; + listRef: RefObject; + onExitTop: () => void; + emptyHint: ReactNode; +}) { + return ( +
    +
    +

    + {heading} +

    + {count !== null && {count}} + + {headerActions} +
    + {state.status === "loading" || state.status === "idle" ? ( + Loading sessions… + ) : state.status === "failed" ? ( + +

    Lore could not load these sessions.

    +

    {state.error}

    + +
    + ) : state.items.length === 0 ? ( + {emptyHint} + ) : ( + s.id} + isOpen={(session) => session.id === openId} + renderRow={(session) => } + onOpen={(session, via) => onOpen(session.id, via)} + listRef={listRef} + onExitTop={onExitTop} + onReachEnd={onMore} + footer={} + /> + )} +
    + ); +} + +export function SearchPane({ + query, + pending, + needsTerm, + scopeNote, + sort, + onSort, + state, + openId, + onOpen, + onMore, + listRef, + onExitTop, +}: { + query: string; + pending: boolean; + needsTerm: boolean; + scopeNote: ReactNode; + sort: SearchSort; + onSort: (sort: SearchSort) => void; + state: PagedState; + openId: string | null; + onOpen: (id: string, via: OpenVia) => void; + onMore: () => void; + listRef: RefObject; + onExitTop: () => void; +}) { + const ready = state.status === "ready" && !pending; + const count = ready ? `${state.items.length}${state.cursor ? "+" : ""}` : null; + return ( +
    +
    +

    + Results +

    + {count !== null && {count}} + + + +
    + {scopeNote &&

    {scopeNote}

    } + {needsTerm ? ( + +

    Filters narrow a search. Add a word to search for.

    +

    + For example, retry {query.trim()} +

    +
    + ) : pending || state.status === "loading" || state.status === "idle" ? ( + Searching… + ) : state.status === "failed" ? ( + +

    The search did not run.

    +

    {state.error}

    +
    + ) : state.items.length === 0 ? ( + +

    No sessions match “{query.trim()}”.

    +

    Try fewer words, or drop a filter.

    +
    + ) : ( + `${hit.source_kind}:${hit.source_id}:${hit.field}`} + isOpen={(hit) => hit.session_id === openId} + renderRow={(hit) => } + onOpen={(hit, via) => onOpen(hit.session_id, via)} + listRef={listRef} + onExitTop={onExitTop} + onReachEnd={onMore} + footer={} + /> + )} +
    + ); +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..ab52839 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { useFocusTrap } from "../focus-trap"; + +/** + * A modal shell: backdrop, focus trap, Escape to close, and focus returned to + * whatever had it before. `initialFocus` names the element to land on. + */ +export function Modal({ + label, + labelledBy, + role = "dialog", + className = "", + onClose, + initialFocus, + children, +}: { + label?: string; + labelledBy?: string; + role?: "dialog" | "alertdialog"; + className?: string; + onClose: () => void; + initialFocus?: React.RefObject; + children: ReactNode; +}) { + const dialog = useRef(null); + useFocusTrap(dialog, true); + + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + (initialFocus?.current ?? dialog.current)?.focus(); + return () => { + if (previous?.isConnected) previous.focus(); + }; + }, [initialFocus]); + + return ( +
    +
    event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + > + {children} +
    +
    + ); +} + +/** Ask before an irreversible action. Focus starts on Cancel. */ +export function ConfirmDialog({ + title, + body, + confirmLabel, + onConfirm, + onClose, +}: { + title: string; + body: string; + confirmLabel: string; + onConfirm: () => void; + onClose: () => void; +}) { + const cancel = useRef(null); + return ( + +

    + {title} +

    +

    {body}

    +
    + + +
    +
    + ); +} + +/** Ask for a short name, for creating or renaming a folder. */ +export function NameDialog({ + title, + label, + initial, + confirmLabel, + onSubmit, + onClose, +}: { + title: string; + label: string; + initial: string; + confirmLabel: string; + onSubmit: (name: string) => void; + onClose: () => void; +}) { + const [name, setName] = useState(initial); + const field = useRef(null); + const trimmed = name.trim(); + return ( + +
    { + event.preventDefault(); + if (trimmed === "") return; + onClose(); + onSubmit(trimmed); + }} + > +

    + {title} +

    + +
    + + +
    +
    +
    + ); +} diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx new file mode 100644 index 0000000..49cde64 --- /dev/null +++ b/src/components/Onboarding.tsx @@ -0,0 +1,82 @@ +import type { DetectedAgent } from "../ipc"; + +const KNOWN = [ + { id: "claude-code", name: "Claude Code", path: "~/.claude/projects" }, + { id: "codex", name: "Codex", path: "~/.codex/sessions" }, +] as const; + +/** + * First run, shown only for a loaded archive that is truly empty. It names the + * folders Lore will read and offers only actions this build can do. + */ +export default function Onboarding({ + agents, + scanning, + busyAgent, + onScan, + onAddRoot, + onOpenSettings, +}: { + agents: DetectedAgent[]; + scanning: boolean; + busyAgent: string | null; + onScan: () => void; + onAddRoot: (agent: DetectedAgent) => void; + onOpenSettings: () => void; +}) { + const sources = KNOWN.map((known) => { + const agent = agents.find((a) => a.id === known.id); + return { + known, + agent: agent ?? { + id: known.id, + display_name: known.name, + installed: false, + version: null, + session_count: 0, + roots: [known.path], + custom_roots: [], + }, + detected: agent?.installed ?? false, + }; + }); + + return ( +
    +

    + Every agent session you have run, in one searchable place. +

    +

    + Lore reads the session logs Claude Code and Codex already keep on disk, links them to the git repositories + they worked in, and keeps the archive on this machine. Your original logs are never changed. +

    +
      + {sources.map(({ known, agent, detected }) => ( +
    • +
      + {known.name} + {agent.roots[0] ?? known.path} +
      + {detected ? "found" : "not found"} + +
    • + ))} +
    +
    + + +
    +
    + ); +} diff --git a/src/components/QueryLine.tsx b/src/components/QueryLine.tsx new file mode 100644 index 0000000..8f697b6 --- /dev/null +++ b/src/components/QueryLine.tsx @@ -0,0 +1,125 @@ +import { useRef, useState, type RefObject } from "react"; + +import { FILTERS, tokenize } from "../hooks/query"; + +/** + * The search line. Filters are tinted in place as they are typed, so the + * grammar teaches itself: a transparent input sits over a mirror that renders + * the same text token by token. The scope chip on the left says what an empty + * query browses and what a typed query is narrowed to. + */ +export default function QueryLine({ + value, + onChange, + inputRef, + scopeLabel, + scopeOpen, + scopeButtonRef, + onScope, + onLeaveDown, +}: { + value: string; + onChange: (value: string) => void; + inputRef: RefObject; + scopeLabel: string; + scopeOpen: boolean; + scopeButtonRef: RefObject; + onScope: () => void; + onLeaveDown: () => void; +}) { + const mirror = useRef(null); + const [focused, setFocused] = useState(false); + + function syncScroll() { + if (mirror.current && inputRef.current) mirror.current.scrollLeft = inputRef.current.scrollLeft; + } + + function insert(text: string) { + const next = value === "" || value.endsWith(" ") ? `${value}${text}` : `${value} ${text}`; + onChange(next); + requestAnimationFrame(() => inputRef.current?.focus()); + } + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "ArrowDown" || event.key === "Enter") { + event.preventDefault(); + onLeaveDown(); + } else if (event.key === "Escape") { + event.preventDefault(); + if (value !== "") onChange(""); + else onLeaveDown(); + } + } + + return ( +
    + +
    + + { + onChange(event.target.value); + syncScroll(); + }} + onKeyDown={onKeyDown} + onKeyUp={syncScroll} + onSelect={syncScroll} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + /> + +
    +
    +

    Narrow a search with filters. Words without a filter are what Lore searches for.

    +
      + {FILTERS.map((filter) => ( +
    • + +
    • + ))} +
    +
    +
    + ); +} diff --git a/src/components/Reader.tsx b/src/components/Reader.tsx new file mode 100644 index 0000000..f304568 --- /dev/null +++ b/src/components/Reader.tsx @@ -0,0 +1,313 @@ +import { useRef, useState, type RefObject } from "react"; + +import { agentLabel, formatRelative, formatTime, formatTokens } from "../format"; +import type { FolderSummary, RepositorySummary } from "../ipc"; +import type { OpenSession, OpenSessionState } from "../hooks/useOpenSession"; +import ErrorBoundary from "./ErrorBoundary"; +import FileChanges from "./FileChanges"; +import GitEvidence from "./GitEvidence"; +import Transcript from "./Transcript"; + +export interface ReaderActions { + copyMarkdown: (id: string) => void; + saveFile: (id: string) => void; + file: (id: string, folderId: string | null) => void; + forget: (id: string, title: string) => void; +} + +type Tab = "conversation" | "files" | "git"; + +function duration(start: number | null, end: number | null): string | null { + if (start == null || end == null || end <= start) return null; + const minutes = Math.round((end - start) / 60_000); + if (minutes < 1) return "under a minute"; + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + return `${hours} h ${minutes % 60} min`; +} + +function ParseNotice({ session }: { session: OpenSession }) { + const { summary, parse_note, file_events } = session.detail; + if (summary.parse_status === "ok") return null; + const failed = summary.parse_status === "failed"; + return ( +
    +

    + {failed ? "Lore could not fully read this session" : "Lore read part of this session"} +

    +

    + Recovered {summary.message_count} messages, {summary.tool_call_count} tool calls, and{" "} + {file_events.length} file changes{session.git.length > 0 ? ", with git context" : ", with no git context"}. + Your original agent log is untouched, so a later Lore can read it again. +

    +

    + Parser note:{" "} + + {parse_note ?? + (failed + ? "Lore could not safely normalize this source format." + : "Lore skipped records it could not safely normalize.")} + +

    +
    + ); +} + +function FolderSelect({ + folders, + onFile, +}: { + folders: FolderSummary[]; + onFile: (folderId: string | null) => void; +}) { + return ( + + ); +} + +function SessionReader({ + session, + terms, + folders, + repositories, + actions, + onBack, + readerRef, +}: { + session: OpenSession; + terms: string[]; + folders: FolderSummary[]; + repositories: RepositorySummary[]; + actions: ReaderActions; + onBack: () => void; + readerRef: RefObject; +}) { + const [tab, setTab] = useState("conversation"); + const tabRefs = useRef>({ conversation: null, files: null, git: null }); + const { summary, segments, file_events, messages, next_message_cursor } = session.detail; + const title = summary.title ?? "Untitled session"; + const tokens = (summary.total_input_tokens ?? 0) + (summary.total_output_tokens ?? 0); + const length = duration(summary.started_at, summary.ended_at); + const tabs: { id: Tab; label: string; count?: number }[] = [ + { id: "conversation", label: "Conversation" }, + { id: "files", label: "Files", count: file_events.length }, + { id: "git", label: "Git", count: session.git.length }, + ]; + + function onTabKey(event: React.KeyboardEvent) { + const index = tabs.findIndex((t) => t.id === tab); + const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (delta === 0) return; + event.preventDefault(); + const next = tabs[(index + delta + tabs.length) % tabs.length].id; + setTab(next); + tabRefs.current[next]?.focus(); + } + + return ( +
    { + if (event.key === "Escape" && !event.defaultPrevented) { + event.preventDefault(); + onBack(); + } + }} + > +
    +

    + {title} +

    +
    +
    +
    Agent
    +
    {agentLabel(summary.agent_id)}
    +
    + {summary.primary_model && ( +
    +
    Model
    +
    {summary.primary_model}
    +
    + )} + {summary.started_at != null && ( +
    +
    Started
    +
    {formatRelative(summary.started_at)}
    +
    + )} + {length && ( +
    +
    Length
    +
    {length}
    +
    + )} +
    +
    Messages
    +
    {Math.max(summary.message_count, messages.length)}
    +
    +
    +
    Tool calls
    +
    {summary.tool_call_count}
    +
    + {tokens > 0 && ( +
    +
    Tokens
    +
    + {formatTokens(tokens)} +
    +
    + )} + {summary.est_cost_usd != null && summary.est_cost_usd > 0 && ( +
    +
    Est. cost
    +
    ${summary.est_cost_usd.toFixed(2)}
    +
    + )} + {segments.length > 1 && ( +
    +
    Contexts
    +
    {segments.length}
    +
    + )} +
    + {session.secrets > 0 && ( +

    + + {session.secrets === 1 ? "1 secret flagged" : `${session.secrets} secrets flagged`} + {" "} + Redacted from search and exports. The original log still has them. +

    + )} +
    + + + actions.file(summary.id, folderId)} /> + + +
    +
    + + + +
    + {tabs.map((t) => ( + + ))} +
    +
    + + {tab === "conversation" && ( + + )} + {tab === "files" && } + {tab === "git" && ( + + )} + +
    +
    + ); +} + +export default function Reader({ + state, + onRetry, + ...props +}: { + state: OpenSessionState; + onRetry: () => void; + terms: string[]; + folders: FolderSummary[]; + repositories: RepositorySummary[]; + actions: ReaderActions; + onBack: () => void; + readerRef: RefObject; +}) { + switch (state.status) { + case "none": + return ( +
    +

    Pick a session to read it.

    +

    + ↑ ↓ move through the list, Enter opens, Esc comes back. +

    +
    + ); + case "loading": + return ( +
    +

    Opening session…

    +
    + ); + case "missing": + return ( +
    +

    This session is no longer in the archive.

    +

    It may have been forgotten. Pick another one from the list.

    +
    + ); + case "failed": + return ( +
    +

    Lore could not open this session.

    +

    {state.error}

    + +
    + ); + case "ready": + return ; + } +} diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx deleted file mode 100644 index ee177c4..0000000 --- a/src/components/RepositoryList.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useState } from "react"; - -import type { RepositorySummary } from "../ipc"; -import { SESSION_DND_MIME } from "./SessionList"; - -export default function RepositoryList({ - repositories, - selectedId, - allSelected, - onSelect, - onUnfileSession, -}: { - repositories: RepositorySummary[]; - selectedId: string | null; - /** - * True when the unfiltered "All sessions" view is active (no repo or folder). - * Defaults to "no repo selected" for callers that have no folder dimension. - */ - allSelected?: boolean; - onSelect: (id: string | null) => void; - /** Drop a thread here to remove it from its folder. */ - onUnfileSession?: (sessionId: string) => void; -}) { - const isAll = allSelected ?? selectedId === null; - const [unfileHover, setUnfileHover] = useState(false); - const hasSession = (event: React.DragEvent) => - event.dataTransfer.types.includes(SESSION_DND_MIME); - - return ( - - ); -} diff --git a/src/components/ScopePicker.tsx b/src/components/ScopePicker.tsx new file mode 100644 index 0000000..87f6af7 --- /dev/null +++ b/src/components/ScopePicker.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { FolderSummary, RepositorySummary } from "../ipc"; +import { sameScope, type Scope } from "../hooks/workspace"; + +interface Choice { + id: string; + scope: Scope; + group: "Archive" | "Repositories" | "Folders"; + label: string; + count: number | null; + note: string | null; +} + +/** + * Pick what the list browses: everything, one repository, or one folder. + * Type to narrow, arrows to move, Enter to choose, Escape to close. + */ +export default function ScopePicker({ + scope, + total, + repositories, + folders, + onChoose, + onNewFolder, + onClose, +}: { + scope: Scope; + total: number | null; + repositories: RepositorySummary[]; + folders: FolderSummary[]; + onChoose: (scope: Scope) => void; + onNewFolder: () => void; + onClose: () => void; +}) { + const [filter, setFilter] = useState(""); + const [active, setActive] = useState(0); + const input = useRef(null); + const root = useRef(null); + + const choices = useMemo(() => { + const all: Choice[] = [ + { id: "scope-all", scope: { kind: "all" }, group: "Archive", label: "All sessions", count: total, note: null }, + ...repositories.map((r) => ({ + id: `scope-repo-${r.id}`, + scope: { kind: "repository", id: r.id } as Scope, + group: "Repositories" as const, + label: r.display_name, + count: r.session_count, + note: r.is_missing ? "checkout missing" : r.identity_confidence === "low" ? "low confidence" : null, + })), + ...folders.map((f) => ({ + id: `scope-folder-${f.id}`, + scope: { kind: "folder", id: f.id } as Scope, + group: "Folders" as const, + label: f.name, + count: f.session_count, + note: null, + })), + ]; + const needle = filter.trim().toLowerCase(); + return needle === "" ? all : all.filter((c) => c.label.toLowerCase().includes(needle)); + }, [filter, folders, repositories, total]); + + const current = Math.min(active, Math.max(choices.length - 1, 0)); + + useEffect(() => { + input.current?.focus(); + }, []); + + useEffect(() => { + function onPointer(event: MouseEvent) { + if (root.current && !root.current.contains(event.target as Node)) onClose(); + } + document.addEventListener("mousedown", onPointer); + return () => document.removeEventListener("mousedown", onPointer); + }, [onClose]); + + useEffect(() => { + document.getElementById(choices[current]?.id ?? "")?.scrollIntoView?.({ block: "nearest" }); + }, [choices, current]); + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "ArrowDown") setActive(Math.min(current + 1, choices.length - 1)); + else if (event.key === "ArrowUp") setActive(Math.max(current - 1, 0)); + else if (event.key === "Enter") { + const choice = choices[current]; + if (choice) onChoose(choice.scope); + } else if (event.key === "Escape") onClose(); + else return; + event.preventDefault(); + } + + let lastGroup: string | null = null; + return ( +
    + { + setFilter(event.target.value); + setActive(0); + }} + onKeyDown={onKeyDown} + /> +
      + {choices.length === 0 &&
    • Nothing matches “{filter.trim()}”.
    • } + {choices.map((choice, index) => { + const heading = choice.group !== lastGroup ? choice.group : null; + lastGroup = choice.group; + return ( +
    • + {heading && heading !== "Archive" && ( + + )} +
      setActive(index)} + onClick={() => onChoose(choice.scope)} + > + {choice.label} + {choice.note && {choice.note}} + {choice.count !== null && {choice.count}} +
      +
    • + ); + })} +
    +
    + + {repositories.length === 0 && Repositories appear after a scan finds git evidence.} +
    +
    + ); +} diff --git a/src/components/SearchResults.test.tsx b/src/components/SearchResults.test.tsx deleted file mode 100644 index 2e24e99..0000000 --- a/src/components/SearchResults.test.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; - -import SearchResults from "./SearchResults"; -import { HIGHLIGHT_END, HIGHLIGHT_START, type SearchHit } from "../ipc"; - -function hit(overrides: Partial = {}): SearchHit { - return { - session_id: "s1", - source_kind: "message_part", - source_id: "p1", - field: "text", - snippet: `deploy the ${HIGHLIGHT_START}retryBackoff${HIGHLIGHT_END} helper`, - rank: -1.2, - title: "Add retry backoff", - agent_id: "codex", - started_at: null, - ...overrides, - }; -} - -const idle = { - searching: false, - hasMore: false, - loadingMore: false, - onLoadMore: () => {}, -}; - -describe("SearchResults", () => { - it("renders nothing for an empty query", () => { - const { container } = render( - {}} {...idle} />, - ); - expect(container.firstChild).toBeNull(); - }); - - it("shows an empty state when a query has no matches", () => { - render( - {}} {...idle} />, - ); - expect(screen.getByText(/no matches/i)).toBeTruthy(); - expect(screen.getByRole("status").textContent).toContain("No matches"); - }); - - it("announces an in-flight search instead of showing a false empty state", () => { - render( - {}} - {...idle} - searching - />, - ); - expect(screen.getByRole("status").textContent).toContain("Searching"); - expect(screen.queryByText(/no matches/i)).toBeNull(); - }); - - it("highlights matched terms and opens the session on click", () => { - const onOpen = vi.fn(); - render( - , - ); - - // The matched term is wrapped in , and the markers themselves are gone. - const mark = screen.getByText("retryBackoff"); - expect(mark.tagName).toBe("MARK"); - expect(screen.getByText("Add retry backoff")).toBeTruthy(); - expect(document.body.textContent).not.toContain(HIGHLIGHT_START); - - fireEvent.click(screen.getByRole("option")); - expect(onOpen).toHaveBeenCalledWith("s1"); - - fireEvent.click(screen.getByText("Add retry backoff")); - expect(onOpen).toHaveBeenCalledTimes(2); - expect(onOpen).toHaveBeenLastCalledWith("s1"); - }); - - it("shows a Load more control only when more pages exist and fires it on click", () => { - const onLoadMore = vi.fn(); - const { rerender } = render( - {}} {...idle} />, - ); - // No further pages: no button. - expect(screen.queryByRole("button", { name: /load more/i })).toBeNull(); - - // More pages available: the button appears and invokes the callback. - rerender( - {}} - searching={false} - hasMore - loadingMore={false} - onLoadMore={onLoadMore} - />, - ); - fireEvent.click(screen.getByRole("button", { name: /load more/i })); - expect(onLoadMore).toHaveBeenCalledTimes(1); - - // While a page is loading the control is disabled to prevent double fetches. - rerender( - {}} - searching={false} - hasMore - loadingMore - onLoadMore={onLoadMore} - />, - ); - expect(screen.getByRole("button", { name: /loading/i }).hasAttribute("disabled")).toBe(true); - }); - - it("exposes a keyboard-operable listbox with a roving active option", () => { - render( - {}} - {...idle} - />, - ); - // The listbox is focusable and the ARIA listbox pattern is complete. - const listbox = screen.getByRole("listbox", { name: /search results/i }); - expect(listbox.getAttribute("tabindex")).toBe("0"); - // First option is active by default. - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - const options = screen.getAllByRole("option"); - expect(options[0].getAttribute("aria-posinset")).toBe("1"); - expect(options[0].getAttribute("aria-setsize")).toBe("2"); - expect(options[0].className).toContain("is-active"); - - // ArrowDown/Up move the roving active option (aria-activedescendant tracks it). - fireEvent.keyDown(listbox, { key: "ArrowDown" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - expect(screen.getAllByRole("option")[1].className).toContain("is-active"); - fireEvent.keyDown(listbox, { key: "ArrowUp" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - - // j and k also navigate down and up. - fireEvent.keyDown(listbox, { key: "j" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - fireEvent.keyDown(listbox, { key: "k" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - - // Home/End jump to the ends; movement is clamped at the boundaries. - fireEvent.keyDown(listbox, { key: "End" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - fireEvent.keyDown(listbox, { key: "ArrowDown" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - fireEvent.keyDown(listbox, { key: "Home" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - }); - - it("calls onExitUp when pressing ArrowUp or k on the first result", () => { - const onExitUp = vi.fn(); - render( - {}} - onExitUp={onExitUp} - {...idle} - />, - ); - const listbox = screen.getByRole("listbox", { name: /search results/i }); - fireEvent.keyDown(listbox, { key: "ArrowUp" }); - expect(onExitUp).toHaveBeenCalledTimes(1); - - fireEvent.keyDown(listbox, { key: "k" }); - expect(onExitUp).toHaveBeenCalledTimes(2); - }); - - it("navigates with j, k, Home and End keys", () => { - render( - {}} - {...idle} - />, - ); - const listbox = screen.getByRole("listbox", { name: /search results/i }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - - // 'j' moves down - fireEvent.keyDown(listbox, { key: "j" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - - // 'End' jumps to the end - fireEvent.keyDown(listbox, { key: "End" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-2"); - - // 'k' moves up - fireEvent.keyDown(listbox, { key: "k" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - - // 'Home' jumps to the start - fireEvent.keyDown(listbox, { key: "Home" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - }); - - it("opens the active result on Enter", () => { - const onOpen = vi.fn(); - render( - , - ); - const listbox = screen.getByRole("listbox", { name: /search results/i }); - fireEvent.keyDown(listbox, { key: "ArrowDown" }); - fireEvent.keyDown(listbox, { key: "Enter" }); - expect(onOpen).toHaveBeenCalledWith("b"); - }); - - it("resets the active option to the top when the query changes", () => { - const hits = [ - hit({ session_id: "a", source_id: "pa" }), - hit({ session_id: "b", source_id: "pb" }), - ]; - const { rerender } = render( - {}} {...idle} />, - ); - const listbox = screen.getByRole("listbox", { name: /search results/i }); - fireEvent.keyDown(listbox, { key: "End" }); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-1"); - // A new query is a fresh context: the active option returns to the top. - rerender( - {}} {...idle} />, - ); - expect(listbox.getAttribute("aria-activedescendant")).toBe("search-result-0"); - }); - - it("shows how long ago a result was recorded", () => { - const { container } = render( - {}} - {...idle} - />, - ); - // A recent result carries its relative time; a result with no timestamp - // renders no