feat(app): search history by workspace, agent, and branch (#2995)

History was a date-grouped list you scrolled. Finding a session from last
week meant eyeballing every row. This adds a search field to the existing
filter rail, next to the host picker.

Search runs on the daemon, not the client. The daemon already holds every
persisted agent in memory, so it ranks the whole history and the client
never has to warn that it only looked at the pages it had loaded.

A searched response is one ranked page with no cursor. The global top K is
always contained in the union of each host's own top K, so merging per-host
pages and slicing back to the limit is the exact global answer — no k-way
merge, and no offset into a ranking that is recomputed per request, which
would duplicate and skip rows as history changes underneath it. Past result
200 of a relevance order the answer is a narrower query, so the footer says
that instead of offering a "Load more" that cannot deliver.

Matching is the app's existing picker matcher, moved to the protocol package
so app and daemon share one implementation instead of drifting into two
"why didn't this show up?" rule sets. Typo tolerance is opt-in, so the
comboboxes keep exact narrowing; the budget is per token, and four-character
tokens get transpositions only, because at that length a free substitution
turns "main" into "mail", "maid" and "rain".

Ranked rows carry the spans they matched on, so results can mark them. The
subsequence and typo tiers match characters the eye would never find
unaided, and an unexplained row reads as a bug. The ranker computes the
spans anyway; sending them keeps the client from deriving a second opinion
that could disagree with the ranking it is explaining.

Hosts that fail or are offline are now named above the list. Without that,
"No sessions match" is a claim the app has no basis for.

Gated on server_info.features.agentHistorySearch, checked once against the
connected hosts. Reachability is reported by the banner instead, so one
offline host in the registry no longer hides the field.
This commit is contained in:
Mohamed Boudra
2026-08-08 10:24:51 +02:00
committed by GitHub
parent cc5ce1038d
commit b167ee02e1
38 changed files with 2549 additions and 253 deletions
+71 -27
View File
@@ -21,6 +21,9 @@ import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { HighlightedText } from "@/components/ui/highlighted-text";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -32,6 +35,18 @@ interface AgentListProps {
listFooterComponent?: ReactElement | null;
showAttentionIndicator?: boolean;
showHostColumn?: boolean;
/**
* Where a search matched each row, keyed by `serverId:agentId`. Rows mark the
* spans so the list can explain why a result is in it — the subsequence and
* typo tiers match characters the eye would not find on its own.
*/
searchMatchesByAgentKey?: Record<string, AgentSearchMatch[]>;
/**
* Renders one flat list in the given order instead of grouping by day. Day
* headings claim the list is chronological, which is a lie once the caller
* has ordered it by something else — relevance, for instance.
*/
flat?: boolean;
}
type DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older";
@@ -127,12 +142,14 @@ function SessionBadge({
function WorkspaceTitlePrefix({
visible,
workspaceName,
ranges,
testID,
iconSize,
color,
}: {
visible: boolean;
workspaceName: string;
ranges?: readonly MatchRange[];
testID: string;
iconSize: number;
color: string;
@@ -143,9 +160,13 @@ function WorkspaceTitlePrefix({
return (
<>
<Text style={styles.workspaceTitleText} numberOfLines={1} testID={testID}>
{workspaceName}
</Text>
<HighlightedText
text={workspaceName}
ranges={ranges}
style={styles.workspaceTitleText}
numberOfLines={1}
testID={testID}
/>
<ChevronRight size={iconSize} color={color} />
</>
);
@@ -203,6 +224,7 @@ function SessionRowTrailingAttention({
function SessionRow({
agent,
searchMatches,
isMobile,
selectedAgentId,
showAttentionIndicator,
@@ -211,6 +233,7 @@ function SessionRow({
onLongPress,
}: {
agent: AggregatedAgent;
searchMatches?: readonly AgentSearchMatch[];
isMobile: boolean;
selectedAgentId?: string;
showAttentionIndicator: boolean;
@@ -228,6 +251,11 @@ function SessionRow({
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
const rangesFor = useCallback(
(field: AgentSearchMatch["field"]) =>
searchMatches?.find((match) => match.field === field)?.ranges,
[searchMatches],
);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -266,6 +294,7 @@ function SessionRow({
<WorkspaceTitlePrefix
visible={!isMobile && Boolean(workspaceName)}
workspaceName={workspaceName}
ranges={rangesFor("workspace")}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
iconSize={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
@@ -273,9 +302,12 @@ function SessionRow({
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || t("agentList.fallbackTitle")}
</Text>
<HighlightedText
text={agent.title || t("agentList.fallbackTitle")}
ranges={agent.title ? rangesFor("title") : undefined}
style={sessionTitleStyle}
numberOfLines={1}
/>
<SessionRowBadges
agent={agent}
archivedIcon={archivedIcon}
@@ -285,29 +317,29 @@ function SessionRow({
</View>
{isMobile ? (
<View style={styles.rowMetaRow}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
<HighlightedText
text={workspaceName}
ranges={rangesFor("workspace")}
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
{showHostColumn && agent.serverLabel ? (
@@ -323,25 +355,25 @@ function SessionRow({
</View>
{!isMobile ? (
<View style={styles.rowColumns}>
<Text
<HighlightedText
text={projectName}
ranges={rangesFor("project")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
/>
{showHostColumn ? (
<Text style={styles.columnMetaHost} numberOfLines={1}>
{agent.serverLabel}
</Text>
) : null}
<Text
<HighlightedText
text={branch}
ranges={rangesFor("branch")}
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
/>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
@@ -365,6 +397,8 @@ export function AgentList({
listFooterComponent,
showAttentionIndicator = true,
showHostColumn = false,
searchMatchesByAgentKey,
flat = false,
}: AgentListProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
@@ -432,6 +466,14 @@ export function AgentList({
}, [actionAgent, actionClient, archiveAgent]);
const flatItems = useMemo((): FlatListItem[] => {
if (flat) {
return agents.map((agent) => ({
type: "agent" as const,
key: `${agent.serverId}:${agent.id}`,
agent,
}));
}
const buckets = new Map<DateSectionKey, AggregatedAgent[]>();
for (const agent of agents) {
const section = deriveDateSectionKey(agent.lastActivityAt);
@@ -452,7 +494,7 @@ export function AgentList({
}
}
return result;
}, [agents]);
}, [agents, flat]);
const renderItem: ListRenderItem<FlatListItem> = useCallback(
({ item }) => {
@@ -466,6 +508,7 @@ export function AgentList({
return (
<SessionRow
agent={item.agent}
searchMatches={searchMatchesByAgentKey?.[item.key]}
isMobile={isMobile}
selectedAgentId={selectedAgentId}
showAttentionIndicator={showAttentionIndicator}
@@ -479,6 +522,7 @@ export function AgentList({
handleAgentLongPress,
handleAgentPress,
isMobile,
searchMatchesByAgentKey,
selectedAgentId,
showAttentionIndicator,
showHostColumn,
@@ -20,6 +20,7 @@ export interface HostFilterProps {
selectedHost: string;
onSelectHost: (serverId: string) => void;
triggerTestID?: string;
hostOptionTestID?: (serverId: string) => string;
}
/**
@@ -32,6 +33,7 @@ export function HostFilter({
selectedHost,
onSelectHost,
triggerTestID,
hostOptionTestID,
}: HostFilterProps): ReactElement {
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
@@ -64,6 +66,7 @@ export function HostFilter({
searchable={false}
title="Filter by host"
desktopPlacement="bottom-start"
hostOptionTestID={hostOptionTestID}
>
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
@@ -266,6 +266,7 @@ export function HostPicker({
active={active}
selected={selected}
onPress={onPress}
testID={hostOptionTestID?.(option.id)}
/>
);
}
@@ -23,7 +23,7 @@ import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
import {
@@ -1,4 +1,8 @@
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
export type ComboboxOptionKind = "directory" | "file";
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { toHighlightSegments } from "./highlighted-text-segments";
/** What the eye sees, written back out with the marked runs bracketed. */
function marked(text: string, ranges: { start: number; length: number }[]): string {
return toHighlightSegments(text, ranges)
.map((segment) => (segment.marked ? `[${segment.text}]` : segment.text))
.join("");
}
describe("toHighlightSegments", () => {
it("marks one span and keeps the rest intact", () => {
expect(marked("add stripe billing", [{ start: 4, length: 6 }])).toBe("add [stripe] billing");
});
it("marks several spans in order", () => {
expect(
marked("add stripe billing", [
{ start: 4, length: 6 },
{ start: 11, length: 4 },
]),
).toBe("add [stripe] [bill]ing");
});
it("marks a span at the very start and at the very end", () => {
expect(marked("main", [{ start: 0, length: 4 }])).toBe("[main]");
expect(marked("feat/main", [{ start: 5, length: 4 }])).toBe("feat/[main]");
});
it("clamps a range that runs past the end of the text", () => {
expect(marked("main", [{ start: 2, length: 99 }])).toBe("ma[in]");
});
it("drops a range that starts past the end of the text", () => {
expect(marked("main", [{ start: 99, length: 4 }])).toBe("main");
});
it("does not let overlapping ranges duplicate characters", () => {
expect(
marked("billing", [
{ start: 0, length: 4 },
{ start: 2, length: 5 },
]),
).toBe("[bill][ing]");
});
it("returns the whole text as one unmarked run when there are no ranges", () => {
expect(toHighlightSegments("main", [])).toEqual([{ start: 0, text: "main", marked: false }]);
});
});
@@ -0,0 +1,35 @@
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
export interface HighlightSegment {
/** Offset into the source text; unique per segment, so it doubles as a key. */
start: number;
text: string;
marked: boolean;
}
/**
* Splits text into marked and unmarked runs. Ranges are clamped and skipped
* rather than trusted: they arrive over the wire, and a stale one must not slice
* a title into gibberish.
*/
export function toHighlightSegments(
text: string,
ranges: readonly MatchRange[],
): HighlightSegment[] {
const segments: HighlightSegment[] = [];
let cursor = 0;
for (const range of ranges) {
const start = Math.max(range.start, cursor);
const end = Math.min(start + range.length, text.length);
if (end <= start) continue;
if (start > cursor) {
segments.push({ start: cursor, text: text.slice(cursor, start), marked: false });
}
segments.push({ start, text: text.slice(start, end), marked: true });
cursor = end;
}
if (cursor < text.length) {
segments.push({ start: cursor, text: text.slice(cursor), marked: false });
}
return segments;
}
@@ -0,0 +1,65 @@
import { useMemo, type ReactElement } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
import { toHighlightSegments } from "./highlighted-text-segments";
export interface HighlightedTextProps {
text: string;
/** Character spans to mark, in order and non-overlapping. */
ranges?: readonly MatchRange[];
style?: StyleProp<TextStyle>;
numberOfLines?: number;
testID?: string;
}
/**
* Text with some of its characters marked. Used to show why a search result is
* in the list — the subsequence and typo tiers can match on characters the eye
* would never find unaided.
*
* The mark is a surface tint rather than the accent: it appears many times on a
* screen, so it has to stay quiet and survive every theme, and the one accent
* per surface is spoken for.
*/
export function HighlightedText({
text,
ranges,
style,
numberOfLines,
testID,
}: HighlightedTextProps): ReactElement {
const segments = useMemo(
() => (ranges && ranges.length > 0 ? toHighlightSegments(text, ranges) : null),
[ranges, text],
);
if (!segments) {
return (
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
{text}
</Text>
);
}
return (
<Text style={style} numberOfLines={numberOfLines} testID={testID}>
{segments.map((segment) =>
segment.marked ? (
<Text key={segment.start} style={styles.mark}>
{segment.text}
</Text>
) : (
<Text key={segment.start}>{segment.text}</Text>
),
)}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
mark: {
backgroundColor: theme.colors.surface3,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,113 @@
import { useCallback, useState, type ReactElement } from "react";
import { Pressable, TextInput, View } from "react-native";
import { Search, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
const ThemedSearch = withUnistyles(Search);
const ThemedX = withUnistyles(X);
const ThemedTextInput = withUnistyles(TextInput, (theme: Theme) => ({
// Placeholders sit at foregroundMuted and no dimmer — see docs/design.md §14.
placeholderTextColor: theme.colors.foregroundMuted,
selectionColor: theme.colors.foreground,
}));
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
export interface SearchFieldProps {
value: string;
onChangeText: (value: string) => void;
placeholder: string;
/** Falls back to `placeholder`, which already names the field. */
accessibilityLabel?: string;
clearAccessibilityLabel: string;
testID?: string;
clearTestID?: string;
}
/**
* The standalone search box that leads a screen's filter rail. Its chrome is
* the host filter pill's, so the two sit on the same rail and read as one row
* of controls; only the border colour moves on focus, which keeps the leading
* icon from shifting. It owns its own clear affordance — a caller rendering its
* own X has been handed the wrong component.
*/
export function SearchField({
value,
onChangeText,
placeholder,
accessibilityLabel,
clearAccessibilityLabel,
testID,
clearTestID,
}: SearchFieldProps): ReactElement {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = useCallback(() => setIsFocused(true), []);
const handleBlur = useCallback(() => setIsFocused(false), []);
const handleClear = useCallback(() => onChangeText(""), [onChangeText]);
return (
<View style={[styles.field, isFocused && styles.fieldFocused]}>
<ThemedSearch size={14} uniProps={mutedColorMapping} />
<ThemedTextInput
testID={testID}
value={value}
onChangeText={onChangeText}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
accessibilityLabel={accessibilityLabel ?? placeholder}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
style={styles.input}
/>
{value.length > 0 ? (
<Pressable
testID={clearTestID}
onPress={handleClear}
accessibilityRole="button"
accessibilityLabel={clearAccessibilityLabel}
hitSlop={8}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
</Pressable>
) : null}
</View>
);
}
const SEARCH_FIELD_MAX_WIDTH = 420;
const styles = StyleSheet.create((theme) => ({
field: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
maxWidth: SEARCH_FIELD_MAX_WIDTH,
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
fieldFocused: {
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface2,
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
height: 20,
// The browser's focus ring would sit inside the field's own focus border.
// `outlineWidth` is typed on ViewStyle since RN 0.81 and is a no-op on
// native, so this needs neither a cast nor a platform branch.
outlineWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));