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
2 changes: 2 additions & 0 deletions apps/app/src/react-app/domains/session/chat/session-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
165 changes: 141 additions & 24 deletions apps/app/src/react-app/domains/session/search/session-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>();
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 {
Expand All @@ -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 };
Expand All @@ -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 = {
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Pin,
PinOff,
Plus,
Search,
Share2,
Trash2,
RefreshCw,
Expand All @@ -33,6 +34,7 @@ import {
isRemoteConnectionErrorMessage,
getWorkspaceTaskLoadErrorDisplay,
isRemoteConnectionWorkspace,
isMacPlatform,
isWindowsPlatform,
} from "../../../../app/utils";
import { t } from "../../../../i18n";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -760,6 +765,18 @@ export function AppSidebar(props: AppSidebarProps) {

<SidebarFooter>
<SidebarMenu>
{props.onOpenSessionSearch ? (
<SidebarMenuItem>
<SidebarMenuButton
onClick={props.onOpenSessionSearch}
aria-label={`${t("session.cmd_sessions_title")} (${sessionSearchShortcut})`}
title={`${t("session.cmd_sessions_title")} (${sessionSearchShortcut})`}
>
<Search className="size-4" />
{t("session.cmd_sessions_title")}
</SidebarMenuButton>
</SidebarMenuItem>
) : null}
<SidebarMenuItem>
<SidebarMenuButton onClick={props.onOpenCreateWorkspace}>
<Plus className="size-4" />
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/react-app/shell/session-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading