Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
getConversationStream,
invalidateHistoryCache,
addToFileIndex,
searchSessions,
invalidateSearchIndex,
} from "./storage";
import {
initWatcher,
Expand Down Expand Up @@ -76,6 +78,15 @@ export function createServer(options: ServerOptions) {
return c.json(projects);
});

app.get("/api/search", async (c) => {
const query = c.req.query("q");
if (!query || !query.trim()) {
return c.json([]);
}
const results = await searchSessions(query);
return c.json(results);
});

app.get("/api/sessions/stream", async (c) => {
return streamSSE(c, async (stream) => {
let isConnected = true;
Expand Down Expand Up @@ -231,6 +242,7 @@ export function createServer(options: ServerOptions) {

onSessionChange((sessionId: string, filePath: string) => {
addToFileIndex(sessionId, filePath);
invalidateSearchIndex(sessionId);
});

startWatcher();
Expand Down
161 changes: 161 additions & 0 deletions api/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ const fileIndex = new Map<string, string>();
let historyCache: HistoryEntry[] | null = null;
const pendingRequests = new Map<string, Promise<unknown>>();

// Search index: sessionId -> full text content
const searchIndex = new Map<string, string>();

export interface SearchResult {
session: Session;
score: number;
matchContext?: string;
}

export function initStorage(dir?: string): void {
claudeDir = dir ?? join(homedir(), ".claude");
projectsDir = join(claudeDir, "projects");
Expand All @@ -89,6 +98,29 @@ function getProjectName(projectPath: string): string {
return parts[parts.length - 1] || projectPath;
}

function extractTextFromContent(content: string | ContentBlock[]): string {
if (typeof content === "string") {
return content;
}

const parts: string[] = [];
for (const block of content) {
if (block.type === "text" && block.text) {
parts.push(block.text);
} else if (block.type === "thinking" && block.thinking) {
parts.push(block.thinking);
} else if (block.type === "tool_result" && typeof block.content === "string") {
parts.push(block.content);
}
}
return parts.join(" ");
}

function normalizeText(text: string): string {
// Lowercase and convert underscores to spaces for matching
return text.toLowerCase().replace(/_/g, " ").replace(/\s+/g, " ").trim();
}

async function buildFileIndex(): Promise<void> {
try {
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
Expand Down Expand Up @@ -377,3 +409,132 @@ export async function getConversationStream(
}
}
}

async function getSessionFullText(sessionId: string): Promise<string> {
// Check cache first
if (searchIndex.has(sessionId)) {
return searchIndex.get(sessionId)!;
}

const messages = await getConversation(sessionId);
const parts: string[] = [];

for (const msg of messages) {
if (msg.message?.content) {
parts.push(extractTextFromContent(msg.message.content));
}
if (msg.summary) {
parts.push(msg.summary);
}
}

const fullText = parts.join(" ");
searchIndex.set(sessionId, fullText);
return fullText;
}

function getRecencyMultiplier(timestamp: number): number {
const now = Date.now();
const age = now - timestamp;
const day = 24 * 60 * 60 * 1000;

if (age < day) return 3.0; // Last 24 hours
if (age < 7 * day) return 2.0; // Last 7 days
if (age < 30 * day) return 1.5; // Last 30 days
return 1.0;
}

function searchMatches(
text: string,
queryWords: string[]
): { matches: boolean; matchContext?: string } {
const normalizedText = normalizeText(text);

// Fast check: all query words must appear as substrings
for (const word of queryWords) {
if (!normalizedText.includes(word)) {
return { matches: false };
}
}

// Prefix matching: each query word must prefix some word in text
const textWords = normalizedText.split(/\s+/);
for (const queryWord of queryWords) {
const found = textWords.some((tw) => tw.startsWith(queryWord));
if (!found) {
return { matches: false };
}
}

// Find context around first match
const firstQueryWord = queryWords[0];
const idx = normalizedText.indexOf(firstQueryWord);
if (idx !== -1) {
const start = Math.max(0, idx - 50);
const end = Math.min(normalizedText.length, idx + firstQueryWord.length + 100);
let context = text.substring(start, end);
if (start > 0) context = "..." + context;
if (end < text.length) context = context + "...";
return { matches: true, matchContext: context };
}

return { matches: true };
}

export async function searchSessions(query: string): Promise<SearchResult[]> {
const queryWords = normalizeText(query).split(/\s+/).filter(Boolean);

if (queryWords.length === 0) {
return [];
}

const sessions = await getSessions();
const results: SearchResult[] = [];

// Process sessions in parallel batches for performance
const batchSize = 10;
for (let i = 0; i < sessions.length; i += batchSize) {
const batch = sessions.slice(i, i + batchSize);
const batchResults = await Promise.all(
batch.map(async (session) => {
const fullText = await getSessionFullText(session.id);
const { matches, matchContext } = searchMatches(fullText, queryWords);

if (matches) {
const baseScore = queryWords.length;
const recencyMultiplier = getRecencyMultiplier(session.timestamp);
return {
session,
score: baseScore * recencyMultiplier,
matchContext,
};
}
return null;
})
);

for (const result of batchResults) {
if (result) {
results.push(result);
}
}
}

// Sort by score descending, then by timestamp descending
results.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return b.session.timestamp - a.session.timestamp;
});

return results;
}

export function invalidateSearchIndex(sessionId?: string): void {
if (sessionId) {
searchIndex.delete(sessionId);
} else {
searchIndex.clear();
}
}
91 changes: 75 additions & 16 deletions web/components/session-list.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,91 @@
import { useState, useMemo, memo, useRef } from "react";
import { useState, useMemo, memo, useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Session } from "@claude-run/api";
import { formatTime } from "../utils";

interface SearchResult {
session: Session;
score: number;
matchContext?: string;
}

interface SessionListProps {
sessions: Session[];
selectedSession: string | null;
onSelectSession: (sessionId: string) => void;
loading?: boolean;
}

function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

const SessionList = memo(function SessionList(props: SessionListProps) {
const { sessions, selectedSession, onSelectSession, loading } = props;
const [search, setSearch] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(null);
const [searching, setSearching] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);

const filteredSessions = useMemo(() => {
if (!search.trim()) {
return sessions;
const debouncedSearch = useDebounce(search, 300);

// Perform search when debounced search value changes
useEffect(() => {
if (!debouncedSearch.trim()) {
setSearchResults(null);
return;
}
const query = search.toLowerCase();
return sessions.filter(
(s) =>
s.display.toLowerCase().includes(query) ||
s.projectName.toLowerCase().includes(query)
);
}, [sessions, search]);

setSearching(true);
fetch(`/api/search?q=${encodeURIComponent(debouncedSearch)}`)
.then((res) => res.json())
.then((results: SearchResult[]) => {
setSearchResults(results);
setSearching(false);
})
.catch((err) => {
console.error("Search error:", err);
setSearching(false);
});
}, [debouncedSearch]);

// When not searching, use the passed sessions; when searching, use search results
const displaySessions = useMemo(() => {
if (searchResults !== null) {
return searchResults.map((r) => r.session);
}
return sessions;
}, [sessions, searchResults]);

// Map session id to match context for display
const matchContextMap = useMemo(() => {
if (!searchResults) return new Map<string, string>();
const map = new Map<string, string>();
for (const r of searchResults) {
if (r.matchContext) {
map.set(r.session.id, r.matchContext);
}
}
return map;
}, [searchResults]);

const virtualizer = useVirtualizer({
count: filteredSessions.length,
count: displaySessions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 76,
overscan: 10,
measureElement: (element) => element.getBoundingClientRect().height,
});

const isSearching = searching || (search.trim() !== "" && search !== debouncedSearch);

return (
<div className="h-full overflow-hidden bg-zinc-950 flex flex-col">
<div className="px-3 py-2 border-b border-zinc-800/60">
Expand Down Expand Up @@ -83,7 +134,7 @@ const SessionList = memo(function SessionList(props: SessionListProps) {
</div>

<div ref={parentRef} className="flex-1 overflow-y-auto">
{loading ? (
{loading || isSearching ? (
<div className="flex items-center justify-center py-8">
<svg
className="w-5 h-5 text-zinc-600 animate-spin"
Expand All @@ -105,7 +156,7 @@ const SessionList = memo(function SessionList(props: SessionListProps) {
/>
</svg>
</div>
) : filteredSessions.length === 0 ? (
) : displaySessions.length === 0 ? (
<p className="py-8 text-center text-xs text-zinc-600">
{search ? "No sessions match" : "No sessions found"}
</p>
Expand All @@ -118,7 +169,8 @@ const SessionList = memo(function SessionList(props: SessionListProps) {
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const session = filteredSessions[virtualItem.index];
const session = displaySessions[virtualItem.index];
const matchContext = matchContextMap.get(session.id);
return (
<button
key={session.id}
Expand Down Expand Up @@ -149,6 +201,11 @@ const SessionList = memo(function SessionList(props: SessionListProps) {
<p className="text-[12px] text-zinc-300 leading-snug line-clamp-2 break-words">
{session.display}
</p>
{matchContext && (
<p className="text-[11px] text-zinc-500 leading-snug line-clamp-2 break-words mt-1 italic">
{matchContext}
</p>
)}
</button>
);
})}
Expand All @@ -158,7 +215,9 @@ const SessionList = memo(function SessionList(props: SessionListProps) {

<div className="px-3 py-2 border-t border-zinc-800/60">
<div className="text-[10px] text-zinc-600 text-center">
{sessions.length} session{sessions.length !== 1 ? "s" : ""}
{searchResults !== null
? `${displaySessions.length} result${displaySessions.length !== 1 ? "s" : ""}`
: `${sessions.length} session${sessions.length !== 1 ? "s" : ""}`}
</div>
</div>
</div>
Expand Down