From e0db02c682651b5c32e072f4e32bc7eb73be12b4 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 25 Oct 2026 23:59:20 -0500 Subject: [PATCH 1/3] feat(ui): add the workspace state model and archive data hooks The rebuilt UI keeps navigation in one reducer: scope, query, sort, the open session, a single overlay, the status flash, and the scan phase. Data hooks load the catalog, keyset-paged lists, and the open session, and each drops responses that lost a race to a newer request. --- src/hooks/query.ts | 62 ++++++++++++++++++ src/hooks/useCatalog.ts | 57 +++++++++++++++++ src/hooks/useDebounced.ts | 15 +++++ src/hooks/useOpenSession.ts | 55 ++++++++++++++++ src/hooks/usePagedList.ts | 103 ++++++++++++++++++++++++++++++ src/hooks/workspace.ts | 124 ++++++++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+) create mode 100644 src/hooks/query.ts create mode 100644 src/hooks/useCatalog.ts create mode 100644 src/hooks/useDebounced.ts create mode 100644 src/hooks/useOpenSession.ts create mode 100644 src/hooks/usePagedList.ts create mode 100644 src/hooks/workspace.ts diff --git a/src/hooks/query.ts b/src/hooks/query.ts new file mode 100644 index 0000000..6833af9 --- /dev/null +++ b/src/hooks/query.ts @@ -0,0 +1,62 @@ +/** + * The search-query grammar as the UI sees it. Mirrors `parse_query` in + * `crates/lore-core/src/search.rs`: whitespace-separated tokens, where a known + * `key:value` prefix (or a `has:` flag) is a filter and anything else is a term. + * The backend returns nothing for a query with no terms, so the UI must know + * the difference to explain an empty result honestly. + */ + +export interface FilterSpec { + key: string; + example: string; + help: string; +} + +export const FILTERS: readonly FilterSpec[] = [ + { key: "agent:", example: "agent:codex", help: "Sessions from one agent" }, + { key: "path:", example: "path:src/auth/", help: "Touched a file under this path" }, + { key: "tool:", example: "tool:Bash", help: "Called this tool" }, + { key: "model:", example: "model:sonnet", help: "Ran on a matching model" }, + { key: "branch:", example: "branch:main", help: "Git branch seen in the session" }, + { key: "commit:", example: "commit:1a2b3c", help: "Commit sha prefix" }, + { key: "worktree:", example: "worktree:feat", help: "Git worktree id" }, + { key: "git-source:", example: "git-source:lore_captured", help: "Who saw the git evidence" }, + { key: "after:", example: "after:2026-07-01", help: "Started on or after a date" }, + { key: "before:", example: "before:2026-08-01", help: "Started before a date" }, + { key: "has:error", example: "has:error", help: "A tool call failed" }, + { key: "has:patch", example: "has:patch", help: "Recorded a patch" }, +]; + +const PREFIXES = FILTERS.filter((f) => f.key.endsWith(":")).map((f) => f.key); +const FLAGS = new Set(FILTERS.filter((f) => !f.key.endsWith(":")).map((f) => f.key)); +// `repo:` is real but takes an opaque repository id, so the UI scopes by +// repository through the scope picker instead of advertising it here. +const HIDDEN_PREFIXES = ["repo:"]; + +export type QueryToken = + | { kind: "space"; text: string } + | { kind: "term"; text: string } + | { kind: "filter"; text: string }; + +function isFilter(token: string): boolean { + if (FLAGS.has(token)) return true; + return [...PREFIXES, ...HIDDEN_PREFIXES].some((p) => token.startsWith(p)); +} + +/** Split a raw query into tokens, keeping whitespace so it can be re-rendered verbatim. */ +export function tokenize(raw: string): QueryToken[] { + const tokens: QueryToken[] = []; + for (const part of raw.split(/(\s+)/)) { + if (part === "") continue; + if (/^\s+$/.test(part)) tokens.push({ kind: "space", text: part }); + else tokens.push({ kind: isFilter(part) ? "filter" : "term", text: part }); + } + return tokens; +} + +/** The plain search terms in a query, without filters. */ +export function queryTerms(raw: string): string[] { + return tokenize(raw) + .filter((t) => t.kind === "term") + .map((t) => t.text); +} diff --git a/src/hooks/useCatalog.ts b/src/hooks/useCatalog.ts new file mode 100644 index 0000000..0e79b83 --- /dev/null +++ b/src/hooks/useCatalog.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + listDetectedAgents, + listFolders, + listRepositories, + type DetectedAgent, + type FolderSummary, + type RepositorySummary, +} from "../ipc"; + +export interface Catalog { + agents: DetectedAgent[]; + repositories: RepositorySummary[]; + folders: FolderSummary[]; +} + +export type CatalogState = + | { status: "loading" } + | { status: "failed"; error: string } + | { status: "ready"; catalog: Catalog }; + +/** + * The archive's navigational facts: agents, repositories, and folders. A failed + * first load is `failed`, never an empty archive. A failed later refresh keeps + * the last good catalog and reports the error to the caller instead. + */ +export function useCatalog() { + const [state, setState] = useState({ status: "loading" }); + const request = useRef(0); + const loaded = useRef(false); + + const refresh = useCallback(async () => { + const id = ++request.current; + if (!loaded.current) setState({ status: "loading" }); + try { + const [agents, repositories, folders] = await Promise.all([ + listDetectedAgents(), + listRepositories(), + listFolders(), + ]); + if (id !== request.current) return; + loaded.current = true; + setState({ status: "ready", catalog: { agents, repositories, folders } }); + } catch (e) { + if (id !== request.current) return; + if (loaded.current) throw e; + setState({ status: "failed", error: String(e) }); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { state, refresh }; +} diff --git a/src/hooks/useDebounced.ts b/src/hooks/useDebounced.ts new file mode 100644 index 0000000..34324d5 --- /dev/null +++ b/src/hooks/useDebounced.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +/** Follow `value` after it has been still for `ms`. A blank value lands at once. */ +export function useDebounced(value: string, ms: number): string { + const [settled, setSettled] = useState(value); + useEffect(() => { + if (value.trim() === "") { + setSettled(value); + return; + } + const timer = setTimeout(() => setSettled(value), ms); + return () => clearTimeout(timer); + }, [value, ms]); + return settled; +} diff --git a/src/hooks/useOpenSession.ts b/src/hooks/useOpenSession.ts new file mode 100644 index 0000000..6d7c145 --- /dev/null +++ b/src/hooks/useOpenSession.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + getGitSnapshot, + getSession, + sessionSecretCount, + type GitObservationDto, + type SessionDetail, +} from "../ipc"; + +export interface OpenSession { + detail: SessionDetail; + git: GitObservationDto[]; + secrets: number; +} + +export type OpenSessionState = + | { status: "none" } + | { status: "loading"; id: string } + | { status: "missing"; id: string } + | { status: "failed"; id: string; error: string } + | { status: "ready"; id: string; session: OpenSession }; + +/** Load the reader's session. Only the latest requested id may paint. */ +export function useOpenSession(id: string | null) { + const [state, setState] = useState({ status: "none" }); + const [attempt, setAttempt] = useState(0); + const request = useRef(0); + + useEffect(() => { + const req = ++request.current; + if (id === null) { + setState({ status: "none" }); + return; + } + setState({ status: "loading", id }); + Promise.all([getSession(id), getGitSnapshot(id), sessionSecretCount(id)]).then( + ([detail, git, secrets]) => { + if (req !== request.current) return; + setState( + detail + ? { status: "ready", id, session: { detail, git, secrets } } + : { status: "missing", id }, + ); + }, + (e: unknown) => { + if (req !== request.current) return; + setState({ status: "failed", id, error: String(e) }); + }, + ); + }, [id, attempt]); + + const retry = useCallback(() => setAttempt((n) => n + 1), []); + return { state, retry }; +} diff --git a/src/hooks/usePagedList.ts b/src/hooks/usePagedList.ts new file mode 100644 index 0000000..4c1ccb2 --- /dev/null +++ b/src/hooks/usePagedList.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface Page { + items: T[]; + cursor: string | null; +} + +/** Fetch one page. `limit` may exceed the page size when a refresh keeps depth. */ +export type FetchPage = (limit: number, cursor: string | null) => Promise>; + +export type PagedState = + | { status: "idle" } + | { status: "loading" } + | { status: "failed"; error: string } + | { + status: "ready"; + items: T[]; + cursor: string | null; + more: "idle" | "loading" | "failed"; + }; + +/** + * A keyset-paged list. A new `fetchPage` identity starts over from the first + * page; `null` means there is nothing to fetch. Every request carries a + * generation, so a response that lost a race to a newer request is dropped. + * `refresh` reloads in place at the current depth without flashing a loading + * state, which keeps a background scan from collapsing a scrolled list. + */ +export function usePagedList( + fetchPage: FetchPage | null, + pageSize: number, + keyOf: (item: T) => string, +) { + const [state, setState] = useState>( + fetchPage ? { status: "loading" } : { status: "idle" }, + ); + const generation = useRef(0); + const stateRef = useRef(state); + stateRef.current = state; + + useEffect(() => { + const gen = ++generation.current; + if (!fetchPage) { + setState({ status: "idle" }); + return; + } + setState({ status: "loading" }); + fetchPage(pageSize, null).then( + (page) => { + if (gen !== generation.current) return; + setState({ status: "ready", items: page.items, cursor: page.cursor, more: "idle" }); + }, + (e: unknown) => { + if (gen !== generation.current) return; + setState({ status: "failed", error: String(e) }); + }, + ); + }, [fetchPage, pageSize]); + + const loadMore = useCallback(async () => { + const current = stateRef.current; + if (!fetchPage || current.status !== "ready") return; + if (current.cursor === null || current.more === "loading") return; + const gen = ++generation.current; + setState({ ...current, more: "loading" }); + try { + const page = await fetchPage(pageSize, current.cursor); + if (gen !== generation.current) return; + // Pages are disjoint by contract; deduping keeps a concurrent archive + // refresh from ever painting the same row twice. + const seen = new Set(current.items.map(keyOf)); + setState({ + status: "ready", + items: [...current.items, ...page.items.filter((item) => !seen.has(keyOf(item)))], + cursor: page.cursor, + more: "idle", + }); + } catch { + if (gen !== generation.current) return; + setState({ ...current, more: "failed" }); + } + }, [fetchPage, keyOf, pageSize]); + + const refresh = useCallback(async () => { + if (!fetchPage) return; + const current = stateRef.current; + const depth = current.status === "ready" ? current.items.length : 0; + const gen = ++generation.current; + if (current.status !== "ready") setState({ status: "loading" }); + try { + const page = await fetchPage(Math.max(pageSize, depth), null); + if (gen !== generation.current) return; + setState({ status: "ready", items: page.items, cursor: page.cursor, more: "idle" }); + } catch (e) { + if (gen !== generation.current) return; + // A failed background refresh keeps the rows the user is looking at. + if (stateRef.current.status !== "ready") setState({ status: "failed", error: String(e) }); + else throw e; + } + }, [fetchPage, pageSize]); + + return { state, loadMore, refresh }; +} diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts new file mode 100644 index 0000000..89345d5 --- /dev/null +++ b/src/hooks/workspace.ts @@ -0,0 +1,124 @@ +import type { ScanProgress, SearchSort } from "../ipc"; + +/** What the list pane browses when no query is typed. */ +export type Scope = + | { kind: "all" } + | { kind: "repository"; id: string } + | { kind: "folder"; id: string }; + +export const ALL: Scope = { kind: "all" }; + +export function sameScope(a: Scope, b: Scope): boolean { + if (a.kind === "all" || b.kind === "all") return a.kind === b.kind; + return a.kind === b.kind && a.id === b.id; +} + +/** + * The archive scan. Scans start from Rescan, from adding a source folder, or on + * their own at launch, so progress events can move `idle` to `running` too. + */ +export type ScanState = + | { phase: "idle" } + | { phase: "running"; progress: ScanProgress | null } + | { phase: "done"; progress: ScanProgress } + | { phase: "failed"; error: string }; + +/** One transient line in the status bar. Errors stay until dismissed or replaced. */ +export type Flash = { tone: "notice" | "error"; text: string } | null; + +/** At most one overlay is open; opening another replaces it. */ +export type Overlay = + | { kind: "none" } + | { kind: "palette" } + | { kind: "settings" } + | { kind: "scope" } + | { + kind: "confirm"; + title: string; + body: string; + confirmLabel: string; + onConfirm: () => void; + } + | { + kind: "name"; + title: string; + label: string; + initial: string; + confirmLabel: string; + onSubmit: (name: string) => void; + }; + +export interface Workspace { + scope: Scope; + query: string; + sort: SearchSort; + /** The session open in the reader. */ + openId: string | null; + overlay: Overlay; + flash: Flash; + scan: ScanState; +} + +export const initialWorkspace: Workspace = { + scope: ALL, + query: "", + sort: "relevance", + openId: null, + overlay: { kind: "none" }, + flash: null, + scan: { phase: "idle" }, +}; + +export type Action = + | { type: "scope"; scope: Scope } + | { type: "query"; query: string } + | { type: "sort"; sort: SearchSort } + | { type: "open"; id: string | null } + | { type: "overlay"; overlay: Overlay } + | { type: "flash"; flash: Flash } + | { type: "scan-requested" } + | { type: "scan-progress"; progress: ScanProgress } + | { type: "scan-failed"; error: string } + | { type: "forgot-everything" }; + +export function workspaceReducer(state: Workspace, action: Action): Workspace { + switch (action.type) { + case "scope": + return { ...state, scope: action.scope, overlay: { kind: "none" } }; + case "query": + return { ...state, query: action.query }; + case "sort": + return { ...state, sort: action.sort }; + case "open": + return { ...state, openId: action.id }; + case "overlay": + return { ...state, overlay: action.overlay }; + case "flash": + return { ...state, flash: action.flash }; + case "scan-requested": + return { ...state, scan: { phase: "running", progress: null } }; + case "scan-progress": + return { + ...state, + scan: action.progress.done + ? { phase: "done", progress: action.progress } + : { phase: "running", progress: action.progress }, + }; + case "scan-failed": + return { + ...state, + scan: { phase: "failed", error: action.error }, + flash: { tone: "error", text: action.error }, + }; + case "forgot-everything": + return { ...initialWorkspace, scan: state.scan }; + } +} + +/** Processed and total candidates for a progress bar, or null before discovery. */ +export function scanFraction(progress: ScanProgress | null): { done: number; total: number } | null { + if (!progress) return null; + const done = progress.ingested + progress.skipped + progress.failed; + const total = Math.max(progress.discovered, done); + return total > 0 ? { done, total } : null; +} From f68179988d1ddd05e2607bfff293ca883482d911 Mon Sep 17 00:00:00 2001 From: Henry Su Date: Sun, 25 Oct 2026 23:59:27 -0500 Subject: [PATCH 2/3] feat(ui): rebuild the archive UI around a query line A query line spans the top bar and drives the app. The scope chip on its left picks what to browse (all sessions, a repository, a folder) and narrows a search to a repository. Filters tint in place as you type. Below it, a virtualized list sits beside a reader with Conversation, Files, and Git tabs, collapsible tool calls, and numbered diffs. A status bar carries agent counts, messages, and scan progress. The palette, settings, onboarding, folder filing, export, and forget flows keep their capabilities with in-app dialogs instead of window.confirm and window.prompt. The theme is a cyanotype palette in light and dark, with IBM Plex Sans and Plex Mono bundled locally. --- package-lock.json | 20 + package.json | 2 + src/App.test.tsx | 859 --------- src/App.tsx | 1068 +++++------ src/components/ArchiveOnboarding.tsx | 96 - src/components/BackupSettings.test.tsx | 98 - src/components/BackupSettings.tsx | 121 -- src/components/CommandPalette.test.tsx | 280 --- src/components/CommandPalette.tsx | 376 ++-- src/components/Diff.tsx | 85 + src/components/ErrorBoundary.tsx | 71 +- src/components/FileChanges.tsx | 100 + src/components/FolderList.test.tsx | 160 -- src/components/FolderList.tsx | 211 --- src/components/GitEvidence.tsx | 103 ++ src/components/Highlight.tsx | 46 + src/components/ListBox.tsx | 145 ++ src/components/ListPane.tsx | 228 +++ src/components/Modal.tsx | 156 ++ src/components/Onboarding.tsx | 82 + src/components/QueryLine.tsx | 125 ++ src/components/Reader.tsx | 313 ++++ src/components/RepositoryList.tsx | 84 - src/components/ScopePicker.tsx | 155 ++ src/components/SearchResults.test.tsx | 446 ----- src/components/SearchResults.tsx | 199 -- src/components/SessionList.test.tsx | 143 -- src/components/SessionList.tsx | 148 -- src/components/SessionView.test.tsx | 358 ---- src/components/SessionView.tsx | 417 ----- src/components/SettingsDialog.tsx | 230 +++ src/components/SettingsPanel.test.tsx | 211 --- src/components/SettingsPanel.tsx | 179 -- src/components/StatusBar.tsx | 77 + src/components/Transcript.tsx | 215 +++ src/components/tool.ts | 87 + src/hooks/useFolderManager.ts | 142 -- src/hooks/useSearchState.ts | 105 -- src/hooks/useSessionBrowser.ts | 235 --- src/styles.css | 2353 ++++++++++++------------ 40 files changed, 4027 insertions(+), 6502 deletions(-) delete mode 100644 src/App.test.tsx delete mode 100644 src/components/ArchiveOnboarding.tsx delete mode 100644 src/components/BackupSettings.test.tsx delete mode 100644 src/components/BackupSettings.tsx delete mode 100644 src/components/CommandPalette.test.tsx create mode 100644 src/components/Diff.tsx create mode 100644 src/components/FileChanges.tsx delete mode 100644 src/components/FolderList.test.tsx delete mode 100644 src/components/FolderList.tsx create mode 100644 src/components/GitEvidence.tsx create mode 100644 src/components/Highlight.tsx create mode 100644 src/components/ListBox.tsx create mode 100644 src/components/ListPane.tsx create mode 100644 src/components/Modal.tsx create mode 100644 src/components/Onboarding.tsx create mode 100644 src/components/QueryLine.tsx create mode 100644 src/components/Reader.tsx delete mode 100644 src/components/RepositoryList.tsx create mode 100644 src/components/ScopePicker.tsx delete mode 100644 src/components/SearchResults.test.tsx delete mode 100644 src/components/SearchResults.tsx delete mode 100644 src/components/SessionList.test.tsx delete mode 100644 src/components/SessionList.tsx delete mode 100644 src/components/SessionView.test.tsx delete mode 100644 src/components/SessionView.tsx create mode 100644 src/components/SettingsDialog.tsx delete mode 100644 src/components/SettingsPanel.test.tsx delete mode 100644 src/components/SettingsPanel.tsx create mode 100644 src/components/StatusBar.tsx create mode 100644 src/components/Transcript.tsx create mode 100644 src/components/tool.ts delete mode 100644 src/hooks/useFolderManager.ts delete mode 100644 src/hooks/useSearchState.ts delete mode 100644 src/hooks/useSessionBrowser.ts 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 deleted file mode 100644 index d405818..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,859 +0,0 @@ -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), -})); - -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}", -})); - -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 { - return { - id, - agent_id: "codex", - title, - started_at: null, - ended_at: null, - message_count: 0, - tool_call_count: 0, - primary_model: null, - parse_status: "ok", - total_input_tokens: null, - total_output_tokens: null, - total_cache_tokens: null, - est_cost_usd: null, - }; -} - -function detail(session: SessionSummary): SessionDetail { - return { - summary: session, - parse_note: null, - segments: [], - messages: [], - file_events: [], - next_message_cursor: null, - }; -} - -function repository(id: string, name: string): RepositorySummary { - return { - id, - display_name: name, - identity_confidence: "confirmed", - primary_path: null, - is_missing: false, - session_count: 1, - worktree_count: 1, - }; -} - -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 }; -} - -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 }); -}); - -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); - - render(); - - expect(screen.queryByRole("heading", { name: /your agent history/i })).toBeNull(); - expect(screen.getByText("Loading archive…")).toBeTruthy(); - - await act(async () => agents.resolve([])); - expect( - await screen.findByRole("heading", { name: "Your agent history, searchable." }), - ).toBeTruthy(); - }); - - 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(); - - 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(); - }); - - it("never relabels a failed initial load as an empty archive", async () => { - vi.mocked(listDetectedAgents).mockRejectedValueOnce(new Error("archive unavailable")); - - 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(); - }); - - 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("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, - }); - }); - 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(); - }); - - 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(); - }); - - 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" }); - - expect(screen.queryByRole("dialog", { name: /command palette/i })).toBeNull(); - expect(document.activeElement).toBe(trigger); - }); - - 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(); - - act(() => { - scan.handler?.({ - discovered: 2, - ingested: 1, - skipped: 0, - failed: 0, - enriched: 1, - done: true, - }); - }); - expect(await screen.findByRole("button", { name: "Rescan" })).toBeTruthy(); - }); - - 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("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("removes results from the previous query as soon as new text is entered", 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(); - 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(); - } - }); - - 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); - }); - - 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, - ); - - 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(); - }); - - 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(); - }); - - 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(); - - fireEvent.click(screen.getByLabelText("Dismiss error")); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - 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("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("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, - }); - }); - - 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 }); - - 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(); - } - }); - - 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, - ); - - 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(); - }); - - 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"))); - - expect(screen.queryByRole("alert")).toBeNull(); - expect(screen.getByRole("heading", { name: "B session", level: 2 })).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(); - - // 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: [], - }); - - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Forget everything" })); - }); - - 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(); - }); -}); 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