diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index e5cd571c9..ef38c6df2 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -109,6 +109,7 @@ export type SessionPageSidebarProps = { onPrefetchSession?: (workspaceId: string, sessionId: string) => void; onCreateTaskInWorkspace: (workspaceId: string) => void; onCreateTaskWithPrompt?: (workspaceId: string, prompt: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; @@ -831,6 +832,7 @@ export function SessionPage(props: SessionPageProps) { onOpenSession={openSessionTab} onPrefetchSession={props.sidebar.onPrefetchSession} onCreateTaskInWorkspace={props.sidebar.onCreateTaskInWorkspace} + onOpenSessionSearch={props.sidebar.onOpenSessionSearch} onOpenRenameSession={props.onRenameSession ? openRenameModal : undefined} onOpenDeleteSession={props.onDeleteSession ? (sessionId) => { setSessionActionId(sessionId); diff --git a/apps/app/src/react-app/domains/session/search/session-search.ts b/apps/app/src/react-app/domains/session/search/session-search.ts index 33d31bf36..787e2a1c7 100644 --- a/apps/app/src/react-app/domains/session/search/session-search.ts +++ b/apps/app/src/react-app/domains/session/search/session-search.ts @@ -32,11 +32,32 @@ export type SessionSearchProgress = { type CacheEntry = { updatedAt: number; /** One entry per message that contains searchable text. */ - texts: Array<{ role: "user" | "assistant"; text: string; lower: string }>; + texts: Array<{ role: "user" | "assistant"; text: string; words: WordRange[] }>; /** Set when the transcript fetch failed; retried after a short cool-down. */ failedAt?: number; }; +type QueryToken = { + value: string; +}; + +type WordRange = { + value: string; + start: number; + end: number; +}; + +type TokenRange = { + start: number; + end: number; + score: number; +}; + +type TokenizedMatch = { + ranges: TokenRange[]; + score: number; +}; + export type SessionMessageFetcher = ( workspaceId: string, sessionId: string, @@ -46,18 +67,114 @@ const SNIPPET_BEFORE = 36; const SNIPPET_AFTER = 72; const DEFAULT_CONCURRENCY = 6; const FAILURE_RETRY_MS = 30_000; +const MIN_TOKEN_LENGTH = 2; +const WORD_PATTERN = /[\p{L}\p{N}_./-]+/gu; +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "for", + "in", + "is", + "of", + "on", + "or", + "the", + "to", + "with", +]); function collapseWhitespace(value: string): string { return value.replace(/\s+/g, " "); } -/** Build a compact snippet centered on the first occurrence of the query. */ -export function buildSnippet(text: string, index: number, length: number): SessionSearchSnippet { - const start = Math.max(0, index - SNIPPET_BEFORE); - const end = Math.min(text.length, index + length + SNIPPET_AFTER); - const before = `${start > 0 ? "…" : ""}${collapseWhitespace(text.slice(start, index)).trimStart()}`; - const after = `${collapseWhitespace(text.slice(index + length, end)).trimEnd()}${end < text.length ? "…" : ""}`; - return { before, match: text.slice(index, index + length), after }; +function tokenizeQuery(query: string): QueryToken[] { + const seen = new Set(); + const tokens: QueryToken[] = []; + for (const match of query.toLowerCase().matchAll(WORD_PATTERN)) { + const value = match[0]; + if (value.length < MIN_TOKEN_LENGTH || STOP_WORDS.has(value) || seen.has(value)) continue; + seen.add(value); + tokens.push({ value }); + } + return tokens; +} + +function wordRanges(lower: string): WordRange[] { + const ranges: WordRange[] = []; + for (const match of lower.matchAll(WORD_PATTERN)) { + ranges.push({ + value: match[0], + start: match.index, + end: match.index + match[0].length, + }); + } + return ranges; +} + +function scoreWordForToken(word: WordRange, token: QueryToken): TokenRange | null { + if (word.value === token.value) { + return { start: word.start, end: word.end, score: 120 }; + } + if (word.value.startsWith(token.value)) { + return { start: word.start, end: word.end, score: 95 }; + } + + const index = word.value.indexOf(token.value); + if (index >= 0) { + return { + start: word.start + index, + end: word.start + index + token.value.length, + score: 75, + }; + } + + return null; +} + +function findBestTokenRange(words: WordRange[], token: QueryToken): TokenRange | null { + let best: TokenRange | null = null; + for (const word of words) { + const range = scoreWordForToken(word, token); + if (!range) continue; + if (!best || range.score > best.score) { + best = range; + } + } + return best; +} + +function matchTokenizedQuery(words: WordRange[], tokens: QueryToken[]): TokenizedMatch | null { + if (tokens.length === 0) return null; + + const ranges: TokenRange[] = []; + for (const token of tokens) { + const range = findBestTokenRange(words, token); + if (!range) return null; + ranges.push(range); + } + + const sorted = [...ranges].sort((a, b) => a.start - b.start); + const span = sorted[sorted.length - 1].end - sorted[0].start; + const proximityBonus = Math.max(0, 120 - span); + const score = ranges.reduce((sum, range) => sum + range.score, 0) + proximityBonus; + + return { ranges: sorted, score }; +} + +function buildTokenSnippet(text: string, ranges: TokenRange[]): SessionSearchSnippet { + const highlight = ranges.reduce((best, range) => { + if (range.score !== best.score) return range.score > best.score ? range : best; + return range.start < best.start ? range : best; + }, ranges[0]); + const start = Math.max(0, highlight.start - SNIPPET_BEFORE); + const end = Math.min(text.length, highlight.end + SNIPPET_AFTER); + return { + before: `${start > 0 ? "…" : ""}${collapseWhitespace(text.slice(start, highlight.start)).trimStart()}`, + match: collapseWhitespace(text.slice(highlight.start, highlight.end)), + after: `${collapseWhitespace(text.slice(highlight.end, end)).trimEnd()}${end < text.length ? "…" : ""}`, + }; } function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): CacheEntry { @@ -70,7 +187,8 @@ function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): Ca if (part.synthetic || part.ignored) continue; const text = part.text.trim(); if (!text) continue; - texts.push({ role, text, lower: text.toLowerCase() }); + const lower = text.toLowerCase(); + texts.push({ role, text, words: wordRanges(lower) }); } } return { updatedAt, texts }; @@ -79,23 +197,24 @@ function toCacheEntry(updatedAt: number, messages: OpenworkSessionMessage[]): Ca function matchEntry( session: SearchableSession, entry: CacheEntry, - queryLower: string, + queryTokens: QueryToken[], ): SessionSearchMatch | null { - // Prefer the user's own prompts: they are usually what people remember typing. - let fallback: SessionSearchMatch | null = null; + let best: { match: SessionSearchMatch; score: number } | null = null; for (const item of entry.texts) { - const index = item.lower.indexOf(queryLower); - if (index < 0) continue; + const tokenMatch = matchTokenizedQuery(item.words, queryTokens); + if (!tokenMatch) continue; + const score = tokenMatch.score + (item.role === "user" ? 50 : 0); const match: SessionSearchMatch = { session, kind: "message", role: item.role, - snippet: buildSnippet(item.text, index, queryLower.length), + snippet: buildTokenSnippet(item.text, tokenMatch.ranges), }; - if (item.role === "user") return match; - if (!fallback) fallback = match; + if (!best || score > best.score) { + best = { match, score }; + } } - return fallback; + return best?.match ?? null; } export type SessionSearchRun = { @@ -138,9 +257,7 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses const messages = await fetchMessages(session.workspaceId, session.sessionId); entry = toCacheEntry(session.updatedAt, messages); } catch { - // Unreachable session (stale workspace, server hiccup): record an empty - // entry with a cool-down so one bad session cannot stall every later - // keystroke, but still gets retried once the cool-down expires. + // Keep one stale workspace or server hiccup from blocking every keystroke. entry = { ...toCacheEntry(session.updatedAt, []), failedAt: Date.now() }; } cache.set(session.sessionId, entry); @@ -149,7 +266,7 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses return { search({ query, sessions, onMatch, onProgress, concurrency = DEFAULT_CONCURRENCY }) { - const queryLower = query.trim().toLowerCase(); + const queryTokens = tokenizeQuery(query); let cancelled = false; // Scan newest sessions first so the most relevant hits stream in early. @@ -169,14 +286,14 @@ export function createSessionSearcher(fetchMessages: SessionMessageFetcher): Ses const entry = await getEntry(session); if (cancelled) return; scanned += 1; - const match = matchEntry(session, entry, queryLower); + const match = matchEntry(session, entry, queryTokens); if (match) onMatch(match); report(); } }; const done = (async () => { - if (!queryLower) { + if (queryTokens.length === 0) { scanned = total; report(); return; diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx index b8efd3bc1..73e21f647 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar-provider.tsx @@ -19,6 +19,7 @@ export type SidebarContextValue = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 08bcc998b..4cc1d3341 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -12,6 +12,7 @@ import { Pin, PinOff, Plus, + Search, Share2, Trash2, RefreshCw, @@ -33,6 +34,7 @@ import { isRemoteConnectionErrorMessage, getWorkspaceTaskLoadErrorDisplay, isRemoteConnectionWorkspace, + isMacPlatform, isWindowsPlatform, } from "../../../../app/utils"; import { t } from "../../../../i18n"; @@ -573,6 +575,7 @@ export type AppSidebarProps = { onOpenDeleteSession?: (sessionId: string) => void; onArchiveSession?: (sessionId: string, archived: boolean) => void; onOpenCreateGroupModal?: (workspaceId: string) => void; + onOpenSessionSearch?: () => void; onOpenRenameWorkspace: (workspaceId: string) => void; onShareWorkspace: (workspaceId: string) => void; onRevealWorkspace: (workspaceId: string) => void; @@ -663,6 +666,7 @@ export function AppSidebar(props: AppSidebarProps) { [workspaceId]: Math.min((current[workspaceId] ?? MAX_SESSIONS_PREVIEW) + MAX_SESSIONS_PREVIEW, totalRoots), })); }; + const sessionSearchShortcut = isMacPlatform() ? "Cmd+Shift+F" : "Ctrl+Shift+F"; React.useEffect(() => { const workspaceId = props.selectedWorkspaceId.trim(); @@ -709,6 +713,7 @@ export function AppSidebar(props: AppSidebarProps) { onOpenDeleteSession: props.onOpenDeleteSession, onArchiveSession: props.onArchiveSession, onOpenCreateGroupModal: props.onOpenCreateGroupModal, + onOpenSessionSearch: props.onOpenSessionSearch, onOpenRenameWorkspace: props.onOpenRenameWorkspace, onShareWorkspace: props.onShareWorkspace, onRevealWorkspace: props.onRevealWorkspace, @@ -760,6 +765,18 @@ export function AppSidebar(props: AppSidebarProps) { + {props.onOpenSessionSearch ? ( + + + + {t("session.cmd_sessions_title")} + + + ) : null} diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 56e1b20ab..e52e3643c 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -1671,6 +1671,7 @@ export function SessionRoute() { onCreateTaskInWorkspace: (workspaceId) => { void handleCreateTaskInWorkspace(workspaceId); }, + onOpenSessionSearch: () => setSessionSearchOpen(true), onCreateTaskWithPrompt: (workspaceId, prompt) => { void (async () => { const workspace = workspaces.find((item) => item.id === workspaceId); diff --git a/apps/app/tests/session-search.test.ts b/apps/app/tests/session-search.test.ts new file mode 100644 index 000000000..6dc8a4662 --- /dev/null +++ b/apps/app/tests/session-search.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSessionSearcher, + type SearchableSession, + type SessionMessageFetcher, +} from "../src/react-app/domains/session/search/session-search"; + +const session: SearchableSession = { + workspaceId: "workspace-a", + sessionId: "session-a", + title: "Auth callback debugging", + workspaceTitle: "OpenWork", + updatedAt: 123, +}; + +function message(role: "user" | "assistant", text: string) { + return JSON.parse(JSON.stringify({ + info: { role }, + parts: [{ type: "text", text }], + })); +} + +async function search(query: string, fetchMessages: SessionMessageFetcher) { + const searcher = createSessionSearcher(fetchMessages); + const results: Array<{ role: string; match: string; before: string; after: string }> = []; + const run = searcher.search({ + query, + sessions: [session], + onMatch: (match) => { + results.push({ + role: match.role ?? "unknown", + match: match.snippet?.match ?? "", + before: match.snippet?.before ?? "", + after: match.snippet?.after ?? "", + }); + }, + onProgress: () => undefined, + concurrency: 1, + }); + await run.done; + return results; +} + +describe("session transcript search", () => { + test("matches remembered words without requiring the exact phrase", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "The OAuth redirect failed after the authentication callback returned."), + ]; + + const results = await search("auth redirect", fetchMessages); + + expect(results[0]).toMatchObject({ + role: "assistant", + match: "redirect", + }); + expect(results[0]?.after).toContain("authentication"); + }); + + test("prefers the user's own matching message", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "The deployment error came from a missing Vercel token."), + message("user", "Can you fix the Vercel deployment failure?"), + ]; + + const results = await search("deploy vercel", fetchMessages); + + expect(results[0]?.role).toBe("user"); + }); + + test("does not highlight the full span when remembered words are far apart", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", `deploy ${"lorem ipsum ".repeat(80)} vercel`), + ]; + + const results = await search("deploy vercel", fetchMessages); + + expect(results.length).toBe(1); + expect(results[0]?.match).toBe("deploy"); + expect(results[0]?.match).not.toContain("vercel"); + expect(results[0]?.match.length).toBeLessThan(20); + }); + + test("does not fuzzy-match short identifiers or numeric ids", async () => { + const searcher = createSessionSearcher(async () => [ + message("assistant", "Investigated issue 2331 in the node startup path."), + ]); + const results: string[] = []; + + for (const query of ["2332", "code"]) { + const run = searcher.search({ + query, + sessions: [session], + onMatch: (match) => results.push(match.snippet?.match ?? ""), + onProgress: () => undefined, + concurrency: 1, + }); + await run.done; + } + + expect(results).toEqual([]); + }); + + test("matches non-ASCII query terms", async () => { + const fetchMessages: SessionMessageFetcher = async () => [ + message("assistant", "Summarized the café menu translation notes."), + ]; + + const results = await search("café", fetchMessages); + + expect(results.length).toBe(1); + }); +});