Skip to content

Commit ac66a19

Browse files
hsusulclaude
andcommitted
feat(ui): full-text search command and results view
Expose the search command from the Tauri shell (delegating to lore-core::search) with a typed IPC wrapper, and add a search experience to the shell: a search box in the sessions pane swaps the session list for a results view when a query is present. SearchResults parses the redacted snippet's highlight markers into <mark> spans, shows the session title, agent chip, and matched field, and opens the session on click. The markers are exported from ipc to stay in lock-step with lore_core::search. Because the FTS index is built from redacted projections, results and snippets are secret-safe by construction. Vitest covers snippet highlighting (markers stripped, term wrapped in <mark>), empty/no-match states, and click-to-open. Shell clippy, UI typecheck/lint, 24 tests, and the build pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f7d2e64 commit ac66a19

7 files changed

Lines changed: 260 additions & 6 deletions

File tree

‎src-tauri/src/lib.rs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ use lore_core::discovery::DiscoveryConfig;
1313
use lore_core::pipeline::{Pipeline, ProgressEvent, ProgressSink};
1414
use lore_core::storage::blob::BlobStore;
1515
use lore_ipc::{
16-
DetectedAgent, GitObservationDto, RepositorySummary, RescanResult, ScanProgress, SessionDetail,
17-
SessionSummary,
16+
DetectedAgent, GitObservationDto, RepositorySummary, RescanResult, ScanProgress, SearchHit,
17+
SessionDetail, SessionSummary,
1818
};
1919
use rusqlite::Connection;
2020
use tauri::{AppHandle, Emitter, Manager, State};
@@ -144,6 +144,13 @@ fn get_git_snapshot(
144144
lore_core::query::get_git_snapshot(&conn, &id).map_err(|e| e.to_string())
145145
}
146146

147+
/// Full-text search over the redacted projections (secret-safe by construction).
148+
#[tauri::command]
149+
fn search(state: State<'_, AppState>, query: String, limit: i64) -> Result<Vec<SearchHit>, String> {
150+
let conn = state.db.lock().map_err(|_| "state lock poisoned")?;
151+
lore_core::search::search(&conn, &query, limit).map_err(|e| e.to_string())
152+
}
153+
147154
/// Run a discovery→ingest→enrich pass, streaming `scan_progress` events, and
148155
/// return the final tally.
149156
#[tauri::command]
@@ -208,6 +215,7 @@ pub fn run() {
208215
get_session,
209216
get_git_snapshot,
210217
get_file_patch,
218+
search,
211219
rescan
212220
])
213221
.run(tauri::generate_context!());

‎src/App.test.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ vi.mock("./ipc", () => ({
99
getSession: vi.fn().mockResolvedValue(null),
1010
getGitSnapshot: vi.fn().mockResolvedValue([]),
1111
getFilePatch: vi.fn().mockResolvedValue(null),
12+
search: vi.fn().mockResolvedValue([]),
1213
rescan: vi.fn().mockResolvedValue({}),
1314
onScanProgress: vi.fn().mockResolvedValue(() => {}),
15+
HIGHLIGHT_START: "\u{e000}",
16+
HIGHLIGHT_END: "\u{e001}",
1417
}));
1518

1619
import App from "./App";

‎src/App.tsx‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
22

33
import CommandPalette, { type Command } from "./components/CommandPalette";
44
import RepositoryList from "./components/RepositoryList";
5+
import SearchResults from "./components/SearchResults";
56
import SessionList from "./components/SessionList";
67
import SessionView from "./components/SessionView";
78
import { agentLabel } from "./format";
@@ -15,10 +16,12 @@ import {
1516
listSessions,
1617
onScanProgress,
1718
rescan,
19+
search,
1820
type DetectedAgent,
1921
type GitObservationDto,
2022
type RepositorySummary,
2123
type ScanProgress,
24+
type SearchHit,
2225
type SessionDetail,
2326
type SessionSummary,
2427
} from "./ipc";
@@ -57,6 +60,8 @@ export default function App() {
5760
const [scanning, setScanning] = useState(false);
5861
const [error, setError] = useState<string | null>(null);
5962
const [paletteOpen, setPaletteOpen] = useState(false);
63+
const [query, setQuery] = useState("");
64+
const [hits, setHits] = useState<SearchHit[]>([]);
6065

6166
const loadSessions = useCallback(async (repo: string | null) => {
6267
const rows = repo
@@ -104,6 +109,19 @@ export default function App() {
104109
}
105110
}
106111

112+
async function runSearch(next: string) {
113+
setQuery(next);
114+
if (next.trim() === "") {
115+
setHits([]);
116+
return;
117+
}
118+
try {
119+
setHits(await search(next, 50));
120+
} catch (e) {
121+
setError(String(e));
122+
}
123+
}
124+
107125
async function openSession(id: string) {
108126
setSelectedSession(id);
109127
setError(null);
@@ -225,11 +243,28 @@ export default function App() {
225243
</aside>
226244

227245
<section className="pane pane--sessions" aria-label="sessions">
228-
<SessionList
229-
sessions={sessions}
230-
selectedId={selectedSession}
231-
onOpen={openSession}
246+
<input
247+
className="search-box"
248+
type="search"
249+
placeholder="Search… (e.g. retryBackoff agent:codex path:auth/)"
250+
aria-label="search"
251+
value={query}
252+
onChange={(event) => void runSearch(event.target.value)}
232253
/>
254+
{query.trim() ? (
255+
<SearchResults
256+
hits={hits}
257+
query={query}
258+
selectedId={selectedSession}
259+
onOpen={openSession}
260+
/>
261+
) : (
262+
<SessionList
263+
sessions={sessions}
264+
selectedId={selectedSession}
265+
onOpen={openSession}
266+
/>
267+
)}
233268
</section>
234269

235270
<section className="pane pane--detail">
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import SearchResults from "./SearchResults";
5+
import { HIGHLIGHT_END, HIGHLIGHT_START, type SearchHit } from "../ipc";
6+
7+
function hit(overrides: Partial<SearchHit> = {}): SearchHit {
8+
return {
9+
session_id: "s1",
10+
source_kind: "message_part",
11+
source_id: "p1",
12+
field: "text",
13+
snippet: `deploy the ${HIGHLIGHT_START}retryBackoff${HIGHLIGHT_END} helper`,
14+
rank: -1.2,
15+
title: "Add retry backoff",
16+
agent_id: "codex",
17+
started_at: null,
18+
...overrides,
19+
};
20+
}
21+
22+
describe("SearchResults", () => {
23+
it("renders nothing for an empty query", () => {
24+
const { container } = render(
25+
<SearchResults hits={[]} query=" " selectedId={null} onOpen={() => {}} />,
26+
);
27+
expect(container.firstChild).toBeNull();
28+
});
29+
30+
it("shows an empty state when a query has no matches", () => {
31+
render(<SearchResults hits={[]} query="zzz" selectedId={null} onOpen={() => {}} />);
32+
expect(screen.getByText(/no matches/i)).toBeTruthy();
33+
});
34+
35+
it("highlights matched terms and opens the session on click", () => {
36+
const onOpen = vi.fn();
37+
render(<SearchResults hits={[hit()]} query="retry" selectedId={null} onOpen={onOpen} />);
38+
39+
// The matched term is wrapped in <mark>, and the markers themselves are gone.
40+
const mark = screen.getByText("retryBackoff");
41+
expect(mark.tagName).toBe("MARK");
42+
expect(screen.getByText("Add retry backoff")).toBeTruthy();
43+
expect(document.body.textContent).not.toContain(HIGHLIGHT_START);
44+
45+
fireEvent.click(screen.getByRole("option"));
46+
expect(onOpen).toHaveBeenCalledWith("s1");
47+
});
48+
});

‎src/components/SearchResults.tsx‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Fragment } from "react";
2+
3+
import { agentLabel } from "../format";
4+
import { HIGHLIGHT_END, HIGHLIGHT_START, type SearchHit } from "../ipc";
5+
6+
/** Split a snippet on the highlight markers into plain/highlighted runs. */
7+
function Snippet({ text }: { text: string }) {
8+
const nodes: React.ReactNode[] = [];
9+
let rest = text;
10+
let key = 0;
11+
while (rest.length > 0) {
12+
const start = rest.indexOf(HIGHLIGHT_START);
13+
if (start === -1) {
14+
nodes.push(<Fragment key={key++}>{rest}</Fragment>);
15+
break;
16+
}
17+
if (start > 0) nodes.push(<Fragment key={key++}>{rest.slice(0, start)}</Fragment>);
18+
const end = rest.indexOf(HIGHLIGHT_END, start + HIGHLIGHT_START.length);
19+
if (end === -1) {
20+
nodes.push(<Fragment key={key++}>{rest.slice(start + HIGHLIGHT_START.length)}</Fragment>);
21+
break;
22+
}
23+
const matched = rest.slice(start + HIGHLIGHT_START.length, end);
24+
nodes.push(<mark key={key++}>{matched}</mark>);
25+
rest = rest.slice(end + HIGHLIGHT_END.length);
26+
}
27+
return <span className="hit__snippet">{nodes}</span>;
28+
}
29+
30+
const FIELD_LABEL: Record<string, string> = {
31+
title: "title",
32+
text: "message",
33+
patch: "patch",
34+
content_json: "tool",
35+
};
36+
37+
export default function SearchResults({
38+
hits,
39+
query,
40+
selectedId,
41+
onOpen,
42+
}: {
43+
hits: SearchHit[];
44+
query: string;
45+
selectedId: string | null;
46+
onOpen: (id: string) => void;
47+
}) {
48+
if (query.trim() === "") return null;
49+
if (hits.length === 0) {
50+
return <p className="sessions__empty">No matches for “{query.trim()}”.</p>;
51+
}
52+
return (
53+
<ul className="results" role="listbox" aria-label="search results">
54+
{hits.map((hit, index) => (
55+
<li
56+
key={`${hit.source_id}-${index}`}
57+
role="option"
58+
aria-selected={hit.session_id === selectedId}
59+
className={`results__item${hit.session_id === selectedId ? " is-active" : ""}`}
60+
onClick={() => onOpen(hit.session_id)}
61+
>
62+
<div className="results__meta">
63+
<span className="results__title">{hit.title ?? "(untitled)"}</span>
64+
<span className="chip chip--agent">{agentLabel(hit.agent_id)}</span>
65+
<span className="results__field">{FIELD_LABEL[hit.field] ?? hit.field}</span>
66+
</div>
67+
<Snippet text={hit.snippet} />
68+
</li>
69+
))}
70+
</ul>
71+
);
72+
}

‎src/ipc.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { MessagePartDto } from "../crates/lore-ipc/bindings/MessagePartDto"
1313
import type { RepositorySummary } from "../crates/lore-ipc/bindings/RepositorySummary";
1414
import type { RescanResult } from "../crates/lore-ipc/bindings/RescanResult";
1515
import type { ScanProgress } from "../crates/lore-ipc/bindings/ScanProgress";
16+
import type { SearchHit } from "../crates/lore-ipc/bindings/SearchHit";
1617
import type { SegmentDto } from "../crates/lore-ipc/bindings/SegmentDto";
1718
import type { SessionDetail } from "../crates/lore-ipc/bindings/SessionDetail";
1819
import type { SessionSummary } from "../crates/lore-ipc/bindings/SessionSummary";
@@ -26,11 +27,16 @@ export type {
2627
RepositorySummary,
2728
RescanResult,
2829
ScanProgress,
30+
SearchHit,
2931
SegmentDto,
3032
SessionDetail,
3133
SessionSummary,
3234
};
3335

36+
/** Snippet highlight markers (must match lore_core::search). */
37+
export const HIGHLIGHT_START = "\u{e000}";
38+
export const HIGHLIGHT_END = "\u{e001}";
39+
3440
/** The agents Lore knows about, with ingested-session counts. */
3541
export function listDetectedAgents(): Promise<DetectedAgent[]> {
3642
return invoke<DetectedAgent[]>("list_detected_agents");
@@ -69,6 +75,11 @@ export function getFilePatch(id: string): Promise<string | null> {
6975
return invoke<string | null>("get_file_patch", { id });
7076
}
7177

78+
/** Full-text search over the redacted projections. Secret-safe by construction. */
79+
export function search(query: string, limit: number): Promise<SearchHit[]> {
80+
return invoke<SearchHit[]>("search", { query, limit });
81+
}
82+
7283
/** Run a discovery→ingest→enrich pass and resolve with the final tally. */
7384
export function rescan(): Promise<RescanResult> {
7485
return invoke<RescanResult>("rescan");

‎src/styles.css‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,83 @@ kbd {
788788
color: var(--text-3);
789789
}
790790

791+
/* ── Search ────────────────────────────────────────────────────────────── */
792+
793+
.search-box {
794+
width: 100%;
795+
font: inherit;
796+
color: inherit;
797+
background: var(--bg);
798+
border: 1px solid var(--border);
799+
border-radius: var(--r);
800+
padding: 0.35rem 0.55rem;
801+
margin-bottom: 0.4rem;
802+
}
803+
804+
.search-box:focus {
805+
outline: none;
806+
border-color: var(--accent);
807+
}
808+
809+
.results {
810+
list-style: none;
811+
margin: 0;
812+
padding: 0;
813+
display: flex;
814+
flex-direction: column;
815+
gap: 1px;
816+
}
817+
818+
.results__item {
819+
padding: 0.45rem 0.55rem;
820+
border-radius: var(--r);
821+
cursor: pointer;
822+
border-left: 2px solid transparent;
823+
}
824+
825+
.results__item:hover {
826+
background: var(--surface-hover);
827+
}
828+
829+
.results__item.is-active {
830+
background: var(--accent-weak);
831+
border-left-color: var(--accent);
832+
}
833+
834+
.results__meta {
835+
display: flex;
836+
align-items: center;
837+
gap: 0.4rem;
838+
margin-bottom: 0.15rem;
839+
}
840+
841+
.results__title {
842+
font-weight: 500;
843+
overflow: hidden;
844+
text-overflow: ellipsis;
845+
white-space: nowrap;
846+
}
847+
848+
.results__field {
849+
margin-left: auto;
850+
color: var(--text-3);
851+
font-size: 0.72rem;
852+
}
853+
854+
.hit__snippet {
855+
display: block;
856+
color: var(--text-2);
857+
font-size: 0.8rem;
858+
overflow-wrap: anywhere;
859+
}
860+
861+
.hit__snippet mark {
862+
background: color-mix(in srgb, var(--accent) 30%, transparent);
863+
color: inherit;
864+
border-radius: 2px;
865+
padding: 0 1px;
866+
}
867+
791868
/* ── Command palette ───────────────────────────────────────────────────── */
792869

793870
.omnibar {

0 commit comments

Comments
 (0)