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,
},
}));
@@ -17,11 +17,13 @@ type UseAgentHistoryModule = typeof import("./use-agent-history");
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
beforeAll(async () => {
const module = await import("./use-agent-history");
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
});
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
@@ -63,6 +65,7 @@ function historyPayload(input: {
entries: FetchAgentHistoryEntry[];
hasMore?: boolean;
nextCursor?: string | null;
searchTruncated?: boolean;
}): FetchAgentHistoryResult {
return {
requestId: "req_history",
@@ -72,6 +75,7 @@ function historyPayload(input: {
prevCursor: null,
hasMore: input.hasMore ?? false,
},
...(input.searchTruncated === undefined ? {} : { searchTruncated: input.searchTruncated }),
};
}
@@ -81,6 +85,7 @@ function historyEntry(input: {
updatedAt: string;
title?: string | null;
archivedAt?: string | null;
searchScore?: number;
}): FetchAgentHistoryEntry {
return {
agent: {
@@ -130,6 +135,7 @@ function historyEntry(input: {
mainRepoRoot: null,
},
},
...(input.searchScore === undefined ? {} : { searchScore: input.searchScore }),
};
}
@@ -282,6 +288,276 @@ describe("fetchAgentHistoryPage", () => {
]);
});
it("sends the query to the daemon rather than filtering the page locally", async () => {
const client = createClient([
historyPayload({
entries: [
historyEntry({
id: "match",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Add Stripe billing",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryPage({
client,
serverId: "server-1",
cursor: null,
search: "stripe",
});
expect(client.calls[0]?.search).toBe("stripe");
expect(page.searchScoreByAgentKey).toEqual({ "server-1:match": 1000 });
});
it("keeps per-host scores apart when two hosts issue the same agent id", async () => {
const sharedId = "collision";
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: sharedId,
cwd: "/repo/a",
updatedAt: "2026-04-09T10:00:00.000Z",
title: "Weak match on A",
searchScore: 4000,
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: sharedId,
cwd: "/repo/b",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Strong match on B",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.searchScoreByAgentKey).toEqual({
"server-a:collision": 4000,
"server-b:collision": 1000,
});
expect(page.agents.map((agent) => agent.title)).toEqual([
"Strong match on B",
"Weak match on A",
]);
});
it("reports truncation when a host had more matches than its page could hold", async () => {
const client = createClient([
historyPayload({
entries: [
historyEntry({
id: "top",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Top match",
searchScore: 1000,
}),
],
searchTruncated: true,
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.isSearchTruncated).toBe(true);
// A ranked response promises no next page, so nothing can ask for one.
expect(page.pageInfoByServerId["server-a"]).toEqual({
nextCursor: null,
prevCursor: null,
hasMore: false,
});
});
it("reports truncation when two complete host pages overflow the merge", async () => {
// Neither host is locally truncated; together they exceed what the merged
// list can show, and the footer has to say so.
const buildHost = (serverId: string, count: number) =>
createClient([
historyPayload({
entries: Array.from({ length: count }, (_, index) =>
historyEntry({
id: `${serverId}-${index}`,
cwd: `/repo/${serverId}`,
updatedAt: "2026-04-01T10:00:00.000Z",
title: `Match ${index}`,
searchScore: 1000 + index,
}),
),
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: buildHost("server-a", 150) },
{ serverId: "server-b", serverLabel: "Linux box", client: buildHost("server-b", 150) },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.isSearchTruncated).toBe(true);
expect(page.agents).toHaveLength(200);
});
it("names the host that failed instead of quietly shortening the list", async () => {
const workingClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "reachable",
cwd: "/repo/a",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Reachable match",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "match",
});
expect(page.hostErrors).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
expect(page.agents.map((agent) => agent.id)).toEqual(["reachable"]);
});
it("keeps naming a host that failed page one when another host loads page two", async () => {
// A host that rejects the first page contributes no cursor, so it is never
// asked again. Reading only the newest page would drop its error while its
// history stays missing, so the projection has to see every page.
const workingClient = createClient([
historyPayload({
entries: [
historyEntry({ id: "page-1", cwd: "/repo/a", updatedAt: "2026-04-02T10:00:00.000Z" }),
],
hasMore: true,
nextCursor: "cursor-2",
}),
historyPayload({
entries: [
historyEntry({ id: "page-2", cwd: "/repo/a", updatedAt: "2026-04-01T10:00:00.000Z" }),
],
}),
]);
const hosts = [
{ serverId: "server-a", serverLabel: "MacBook", client: workingClient },
{ serverId: "server-b", serverLabel: "Linux box", client: createFailingClient() },
] satisfies AgentHistoryHost[];
const firstPage = await fetchAgentHistoryBatch({ hosts, cursorByServerId: null });
const secondPage = await fetchAgentHistoryBatch({
hosts,
cursorByServerId: { "server-a": "cursor-2" },
});
// Only the healthy host is re-fetched, so the second page carries no error
// of its own. That is exactly why the screen cannot read the newest page.
expect(secondPage.hostErrors).toEqual([]);
expect(secondPage.agents.map((agent) => agent.id)).toEqual(["page-2"]);
expect(
collectAgentHistoryHostErrors({
pages: [firstPage, secondPage],
unreachableHosts: [],
}),
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
});
it("reports a host that is not connected alongside one whose request failed", () => {
expect(
collectAgentHistoryHostErrors({
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
unreachableHosts: [{ serverId: "server-c", serverName: "Offline box" }],
}),
).toEqual([
{ serverId: "server-c", serverName: "Offline box" },
{ serverId: "server-b", serverName: "Linux box" },
]);
});
it("names a host once when it is both unreachable and failed", () => {
expect(
collectAgentHistoryHostErrors({
pages: [{ hostErrors: [{ serverId: "server-b", serverName: "Linux box" }] }],
unreachableHosts: [{ serverId: "server-b", serverName: "Linux box" }],
}),
).toEqual([{ serverId: "server-b", serverName: "Linux box" }]);
});
it("orders a searched all-host page by relevance instead of recency", async () => {
const serverAClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "newer-weak-match",
cwd: "/repo/a",
updatedAt: "2026-04-09T10:00:00.000Z",
title: "Unbilled usage report",
searchScore: 4000,
}),
],
}),
]);
const serverBClient = createClient([
historyPayload({
entries: [
historyEntry({
id: "older-strong-match",
cwd: "/repo/b",
updatedAt: "2026-04-01T10:00:00.000Z",
title: "Bill the customer",
searchScore: 1000,
}),
],
}),
]);
const page = await fetchAgentHistoryBatch({
hosts: [
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
] satisfies AgentHistoryHost[],
cursorByServerId: null,
search: "bill",
});
expect(page.agents.map((agent) => agent.id)).toEqual([
"older-strong-match",
"newer-weak-match",
]);
});
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
const serverAClient = createClient([
historyPayload({
+212 -14
View File
@@ -3,11 +3,13 @@ import type {
FetchAgentHistoryOptions,
FetchAgentHistoryPageInfo,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -25,17 +27,52 @@ export interface AgentHistoryResult {
isError: boolean;
hasMore: boolean;
isLoadingMore: boolean;
/** False when any target host predates search; the screen hides the field. */
isSearchSupported: boolean;
/** More sessions matched than the ranked page holds. Narrow the query. */
isSearchTruncated: boolean;
/** Where the query matched each row, keyed by `serverId:agentId`. */
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
/** Hosts that failed while others succeeded. The list still renders. */
hostErrors: AgentHistoryHostError[];
refreshAll: () => Promise<void>;
loadMore: () => void;
}
export interface AgentHistoryPage {
agents: AggregatedAgent[];
/**
* Relevance of each agent to the request's search, keyed by `serverId:agentId`
* because that pair — not the bare agent id — is what identifies a row across
* hosts. Empty without a query. Each host ranks only its own sessions, so
* merging several of them into one list needs the scores rather than each
* host's row order.
*/
searchScoreByAgentKey: Record<string, number>;
/** Where the query matched in each row, keyed like `searchScoreByAgentKey`. */
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
pageInfo: FetchAgentHistoryPageInfo;
/** More matched this host's query than its page could hold. */
isSearchTruncated: boolean;
}
/** Identity of a history row. A bare agent id belongs to exactly one host. */
export function agentHistoryRowKey(agent: { serverId: string; id: string }): string {
return `${agent.serverId}:${agent.id}`;
}
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
/**
* A host that could not be reached while at least one other could. Its sessions
* are missing from the list, which under a query means "no matches" would be a
* claim the app cannot make.
*/
export interface AgentHistoryHostError {
serverId: string;
serverName: string;
}
export interface AgentHistoryHost {
serverId: string;
serverLabel: string;
@@ -44,17 +81,27 @@ export interface AgentHistoryHost {
interface AgentHistoryBatchPage {
agents: AggregatedAgent[];
searchScoreByAgentKey: Record<string, number>;
searchMatchesByAgentKey: Record<string, AgentSearchMatch[]>;
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
hostErrors: AgentHistoryHostError[];
/** Set only under a query: more sessions matched than this page can hold. */
isSearchTruncated?: boolean;
}
/** Sorts unmatched rows last so a page missing scores keeps its own order. */
const UNRANKED = Number.POSITIVE_INFINITY;
type AgentHistoryCursorByServerId = Record<string, string | null>;
export async function fetchAgentHistoryPage(input: {
client: AgentHistoryClient;
serverId: string;
cursor: string | null;
search?: string;
}): Promise<AgentHistoryPage> {
const payload = await input.client.fetchAgentHistory({
...(input.search ? { search: input.search } : {}),
sort: AGENT_HISTORY_SORT,
page: input.cursor
? { limit: AGENT_HISTORY_PAGE_LIMIT, cursor: input.cursor }
@@ -65,8 +112,22 @@ export async function fetchAgentHistoryPage(input: {
serverId: input.serverId,
entries: payload.entries,
});
const searchScoreByAgentKey: Record<string, number> = {};
const searchMatchesByAgentKey: Record<string, AgentSearchMatch[]> = {};
for (const entry of payload.entries) {
const key = agentHistoryRowKey({ serverId: input.serverId, id: entry.agent.id });
if (entry.searchScore !== undefined) {
searchScoreByAgentKey[key] = entry.searchScore;
}
if (entry.searchMatches && entry.searchMatches.length > 0) {
searchMatchesByAgentKey[key] = entry.searchMatches;
}
}
return {
searchScoreByAgentKey,
searchMatchesByAgentKey,
isSearchTruncated: payload.searchTruncated === true,
agents: Array.from(agents.values(), (agent) => ({
id: agent.id,
serverId: input.serverId,
@@ -94,6 +155,55 @@ function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}
/**
* Relevance first, recency to break ties. A search result list ordered by time
* would bury the row the user described in favour of the row they last touched.
*
* Truncating the merge back to one page's worth is what makes the merged list
* globally correct: the best N matches overall are always contained in the
* union of each host's own best N, so keeping more than N here would be showing
* rows that a further host could still outrank.
*/
function mergeByRelevance(
agents: AggregatedAgent[],
searchScoreByAgentKey: Record<string, number>,
limit: number,
): AggregatedAgent[] {
return [...agents]
.sort((a, b) => {
const scoreA = searchScoreByAgentKey[agentHistoryRowKey(a)] ?? UNRANKED;
const scoreB = searchScoreByAgentKey[agentHistoryRowKey(b)] ?? UNRANKED;
if (scoreA !== scoreB) return scoreA - scoreB;
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
})
.slice(0, limit);
}
/**
* Which hosts are missing from the list the user is looking at.
*
* Failures are collected across every loaded page, not just the newest: a host
* that rejects page one contributes no cursor and is never asked again, so a
* later page carries no error of its own while that host's history is still
* absent. A refetch replaces every page, which is what clears an error that has
* healed.
*/
export function collectAgentHistoryHostErrors(input: {
pages: readonly Pick<AgentHistoryBatchPage, "hostErrors">[];
unreachableHosts: readonly AgentHistoryHostError[];
}): AgentHistoryHostError[] {
const byServerId = new Map<string, AgentHistoryHostError>();
for (const error of input.unreachableHosts) {
byServerId.set(error.serverId, error);
}
for (const page of input.pages) {
for (const error of page.hostErrors) {
byServerId.set(error.serverId, error);
}
}
return [...byServerId.values()];
}
function getNextAgentHistoryPageParam(
page: AgentHistoryBatchPage,
): AgentHistoryCursorByServerId | null {
@@ -110,6 +220,7 @@ function getNextAgentHistoryPageParam(
export async function fetchAgentHistoryBatch(input: {
hosts: readonly AgentHistoryHost[];
cursorByServerId: AgentHistoryCursorByServerId | null;
search?: string;
}): Promise<AgentHistoryBatchPage> {
const cursorByServerId = input.cursorByServerId ?? {};
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
@@ -123,6 +234,7 @@ export async function fetchAgentHistoryBatch(input: {
client: host.client,
serverId: host.serverId,
cursor: cursorByServerId[host.serverId] ?? null,
...(input.search ? { search: input.search } : {}),
});
return { host, page };
}),
@@ -133,6 +245,18 @@ export async function fetchAgentHistoryBatch(input: {
if (pages.length === 0) {
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
}
// allSettled preserves order, so a rejection still names the host it came
// from. Losing that name is what would let the list quietly under-report.
const hostErrors = settledPages.flatMap((result, index) =>
result.status === "rejected"
? [
{
serverId: hostsToFetch[index].serverId,
serverName: hostsToFetch[index].serverLabel,
},
]
: [],
);
const agents = pages.flatMap(({ host, page }) =>
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
@@ -140,16 +264,37 @@ export async function fetchAgentHistoryBatch(input: {
const pageInfoByServerId = Object.fromEntries(
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
);
const searchScoreByAgentKey = Object.assign(
{},
...pages.map(({ page }) => page.searchScoreByAgentKey),
) as Record<string, number>;
const searchMatchesByAgentKey = Object.assign(
{},
...pages.map(({ page }) => page.searchMatchesByAgentKey),
) as Record<string, AgentSearchMatch[]>;
// Truncation has two independent sources: one host overflowed its own page,
// or several hosts each fit but their union does not. Two hosts returning 150
// matches apiece are both complete and still add up to more than the merge
// can show.
const truncated =
pages.some(({ page }) => page.isSearchTruncated) || agents.length > AGENT_HISTORY_PAGE_LIMIT;
return {
agents: sortByLatestActivity(agents),
agents: input.search
? mergeByRelevance(agents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT)
: sortByLatestActivity(agents),
searchScoreByAgentKey,
searchMatchesByAgentKey,
pageInfoByServerId,
hostErrors,
...(input.search ? { isSearchTruncated: truncated } : {}),
};
}
export function useAgentHistory(options: {
serverId?: string | null;
enabled?: boolean;
search?: string;
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
@@ -164,31 +309,54 @@ export function useAgentHistory(options: {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}, [options.serverId]);
const enabled = options.enabled ?? true;
const targetHosts = useMemo(() => {
// A host the user asked about splits two ways: one this fetch can reach, and
// one whose sessions will be missing from the answer. Both come out of here,
// because dropping the unreachable ones silently is what lets the list — and
// worse, "No sessions match" — overstate what was actually searched.
const { targetHosts, unreachableHosts } = useMemo(() => {
void runtimeVersion;
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
const hosts: AgentHistoryHost[] = [];
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || !isHostRuntimeConnected(snapshot)) {
unreachable.push({ serverId: targetServerId, serverName });
continue;
}
hosts.push({
serverId: targetServerId,
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
client,
});
hosts.push({ serverId: targetServerId, serverLabel: serverName, client });
}
return hosts;
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
// host's sessions.
const isSearchSupported = useSessionStore(
useCallback(
(state) =>
targetServerIds.length > 0 &&
targetServerIds.every(
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
),
[targetServerIds],
),
);
const search = useMemo(() => {
const trimmed = options.search?.trim() ?? "";
return isSearchSupported ? trimmed : "";
}, [isSearchSupported, options.search]);
const queryKey = useMemo(
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
[serverId, targetServerIds],
() => [
...(serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
search,
],
[search, serverId, targetServerIds],
);
const serverLabelById = useMemo(
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
@@ -214,6 +382,7 @@ export function useAgentHistory(options: {
return fetchAgentHistoryBatch({
hosts: targetHosts,
cursorByServerId: pageParam,
...(search ? { search } : {}),
});
},
});
@@ -243,16 +412,39 @@ export function useAgentHistory(options: {
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
const agents = useMemo(() => {
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
const pages = data?.pages ?? [];
const historyAgents = pages.flatMap((page) => page.agents);
const labelledAgents = historyAgents.map((agent) =>
Object.assign({}, agent, {
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
}),
);
return sortByLatestActivity(labelledAgents);
}, [data?.pages, serverLabelById]);
if (!search) {
return sortByLatestActivity(labelledAgents);
}
// A searched query never has a second page, so this is re-merging one page
// after relabelling rather than stitching a stream back together.
const searchScoreByAgentKey = Object.assign(
{},
...pages.map((page) => page.searchScoreByAgentKey),
) as Record<string, number>;
return mergeByRelevance(labelledAgents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT);
}, [data?.pages, search, serverLabelById]);
const isInitialLoad = isLoading && agents.length === 0;
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
const isSearchTruncated = Boolean(search && data?.pages.some((page) => page.isSearchTruncated));
const searchMatchesByAgentKey = useMemo(
() =>
Object.assign(
{},
...(data?.pages ?? []).map((page) => page.searchMatchesByAgentKey),
) as Record<string, AgentSearchMatch[]>,
[data?.pages],
);
const hostErrors = useMemo(
() => collectAgentHistoryHostErrors({ pages: data?.pages ?? [], unreachableHosts }),
[data?.pages, unreachableHosts],
);
return {
agents,
@@ -260,8 +452,14 @@ export function useAgentHistory(options: {
isInitialLoad,
isRevalidating,
isError,
hasMore: hasNextPage,
// Under a query the daemon returns the best matches and no cursor, so the
// list is complete as far as it goes and "Load more" would be a lie.
hasMore: search ? false : hasNextPage,
isLoadingMore: isFetchingNextPage,
isSearchSupported,
isSearchTruncated,
searchMatchesByAgentKey,
hostErrors,
refreshAll,
loadMore,
};
@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
/**
* Trails `value` by `delayMs`, so a value that drives a request settles before
* it is sent. The current value stays the caller's — this only decides when the
* expensive consumer gets to see it.
*/
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return debounced;
}
+5
View File
@@ -231,8 +231,13 @@ export const ar: TranslationResources = {
sessions: {
title: "السجل",
empty: "لا توجد جلسات بعد",
noMatches: "لا توجد جلسات مطابقة",
tooManyMatches: "نتائج كثيرة جدًا — ضيّق نطاق البحث",
hostLoadFailed: "{{host}}: تعذر تحميل السجل",
searchPlaceholder: "البحث في السجل",
actions: {
loadMore: "تحميل المزيد",
clearSearch: "مسح البحث",
},
},
agentList: {
+5
View File
@@ -230,8 +230,13 @@ export const en = {
sessions: {
title: "History",
empty: "No sessions yet",
noMatches: "No sessions match",
tooManyMatches: "Too many matches — narrow your search",
hostLoadFailed: "{{host}}: Could not load history",
searchPlaceholder: "Search history",
actions: {
loadMore: "Load more",
clearSearch: "Clear search",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const es: TranslationResources = {
sessions: {
title: "Historial",
empty: "Aún no hay sesiones",
noMatches: "No hay sesiones que coincidan",
tooManyMatches: "Demasiadas coincidencias: acota la búsqueda",
hostLoadFailed: "{{host}}: No se pudo cargar el historial",
searchPlaceholder: "Buscar en el historial",
actions: {
loadMore: "Cargar más",
clearSearch: "Borrar búsqueda",
},
},
agentList: {
+5
View File
@@ -235,8 +235,13 @@ export const fr: TranslationResources = {
sessions: {
title: "Historique",
empty: "Aucune séance pour l'instant",
noMatches: "Aucune séance correspondante",
tooManyMatches: "Trop de résultats — affinez la recherche",
hostLoadFailed: "{{host}} : Impossible de charger l'historique",
searchPlaceholder: "Rechercher dans l'historique",
actions: {
loadMore: "Charger plus",
clearSearch: "Effacer la recherche",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const ja: TranslationResources = {
sessions: {
title: "履歴",
empty: "セッションがまだありません",
noMatches: "一致するセッションはありません",
tooManyMatches: "一致が多すぎます — 検索条件を絞ってください",
hostLoadFailed: "{{host}}: 履歴を読み込めませんでした",
searchPlaceholder: "履歴を検索",
actions: {
loadMore: "さらに読み込む",
clearSearch: "検索をクリア",
},
},
agentList: {
+5
View File
@@ -232,8 +232,13 @@ export const ko: TranslationResources = {
sessions: {
title: "기록",
empty: "아직 세션이 없습니다",
noMatches: "일치하는 세션이 없습니다",
tooManyMatches: "일치 항목이 너무 많습니다 — 검색 범위를 좁히세요",
hostLoadFailed: "{{host}}: 기록을 불러오지 못했습니다",
searchPlaceholder: "기록 검색",
actions: {
loadMore: "더 불러오기",
clearSearch: "검색 지우기",
},
},
agentList: {
+5
View File
@@ -234,8 +234,13 @@ export const ptBR: TranslationResources = {
sessions: {
title: "Histórico de agentes",
empty: "Nenhuma sessão ainda",
noMatches: "Nenhuma sessão corresponde",
tooManyMatches: "Muitos resultados — refine a busca",
hostLoadFailed: "{{host}}: Não foi possível carregar o histórico",
searchPlaceholder: "Buscar no histórico",
actions: {
loadMore: "Carregar mais",
clearSearch: "Limpar busca",
},
},
agentList: {
+5
View File
@@ -233,8 +233,13 @@ export const ru: TranslationResources = {
sessions: {
title: "История",
empty: "Сеансов пока нет",
noMatches: "Нет подходящих сеансов",
tooManyMatches: "Слишком много совпадений — уточните запрос",
hostLoadFailed: "{{host}}: Не удалось загрузить историю",
searchPlaceholder: "Поиск по истории",
actions: {
loadMore: "Загрузить больше",
clearSearch: "Очистить поиск",
},
},
agentList: {
+5
View File
@@ -231,8 +231,13 @@ export const zhCN: TranslationResources = {
sessions: {
title: "历史",
empty: "还没有会话",
noMatches: "没有匹配的会话",
tooManyMatches: "匹配过多 — 请缩小搜索范围",
hostLoadFailed: "{{host}}:无法加载历史",
searchPlaceholder: "搜索历史",
actions: {
loadMore: "加载更多",
clearSearch: "清除搜索",
},
},
agentList: {
@@ -8,7 +8,7 @@ import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manife
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { i18n } from "@/i18n/i18next";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { filterSelectableModels } from "./model-catalog";
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
+155 -36
View File
@@ -1,20 +1,64 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { useMemo, useState, useCallback, useEffect, type ReactElement } from "react";
import { View, Text } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronLeft } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { MenuHeader } from "@/components/headers/menu-header";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { AgentList } from "@/components/agent-list";
import { SearchField } from "@/components/ui/search-field";
import { HostFilter } from "@/components/hosts/host-filter";
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
import { useAgentHistory } from "@/hooks/use-agent-history";
import { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useHosts } from "@/runtime/host-runtime";
import { buildOpenProjectRoute } from "@/utils/host-routes";
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_DEBOUNCE_MS = 200;
const sessionsHostOptionTestID = (serverId: string) => `sessions-host-filter-item-${serverId}`;
/**
* A host that failed while others answered. Without this the list silently
* under-reports, and under a query "No sessions match" becomes a claim the app
* has no basis for.
*/
function SessionHostErrorsBanner({
errors,
t,
}: {
errors: AgentHistoryHostError[];
t: TFunction;
}): ReactElement {
return (
<View style={styles.errorsBannerWrap}>
<View style={styles.errorsBanner} testID="sessions-host-errors">
{errors.map((error) => (
<Text key={error.serverId} style={styles.errorsBannerText}>
{t("sessions.hostLoadFailed", { host: error.serverName })}
</Text>
))}
</View>
</View>
);
}
/** An empty list means something different once a query is narrowing it. */
function resolveEmptyText(input: {
t: TFunction;
isSearching: boolean;
isAllHosts: boolean;
}): string {
if (input.isSearching) return input.t("sessions.noMatches");
if (input.isAllHosts) return input.t("sessions.empty");
return "No sessions for this host";
}
export function SessionsScreen() {
const isFocused = useIsFocused();
@@ -30,11 +74,26 @@ function SessionsScreenContent() {
const { t } = useTranslation();
const hosts = useHosts();
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
const [searchInput, setSearchInput] = useState("");
const search = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS).trim();
const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost;
const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } =
useAgentHistory({
serverId: historyServerId,
});
const {
agents,
hasMore,
isInitialLoad,
isLoadingMore,
isError,
isSearchSupported,
isSearchTruncated,
searchMatchesByAgentKey,
hostErrors,
loadMore,
refreshAll,
} = useAgentHistory({
serverId: historyServerId,
search,
});
const isSearching = isSearchSupported && search.length > 0;
useEffect(() => {
if (
@@ -52,44 +111,71 @@ function SessionsScreenContent() {
void refreshAll().finally(() => setIsManualRefresh(false));
}, [refreshAll]);
const sortedAgents = useMemo(() => {
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [agents]);
const emptyText =
selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host";
// `useAgentHistory` owns the order: recency at rest, relevance under a query.
const emptyText = resolveEmptyText({
t,
isSearching,
isAllHosts: selectedHost === ALL_HOSTS_OPTION_ID,
});
const showHostFilter = hosts.length > 1;
const showLoadError = isError && sortedAgents.length === 0;
const showFilterRow = showHostFilter || isSearchSupported;
const showLoadError = isError && agents.length === 0;
const handleBack = useCallback(() => {
router.navigate(buildOpenProjectRoute());
}, []);
const listFooterComponent = useMemo(
() =>
hasMore ? (
const handleClearSearch = useCallback(() => setSearchInput(""), []);
const listFooterComponent = useMemo(() => {
// A ranked result set has no next page — reaching a weaker match means
// narrowing the query, so the footer says that instead of offering a button.
if (isSearchTruncated) {
return (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
<Text style={styles.footerHint}>{t("sessions.tooManyMatches")}</Text>
</View>
) : null,
[hasMore, loadMore, isLoadingMore, t],
);
);
}
if (!hasMore) {
return null;
}
return (
<View style={styles.footer}>
<Button variant="ghost" onPress={loadMore} disabled={isLoadingMore}>
{isLoadingMore ? "Loading..." : t("sessions.actions.loadMore")}
</Button>
</View>
);
}, [hasMore, isLoadingMore, isSearchTruncated, loadMore, t]);
return (
<View style={styles.container}>
<MenuHeader title={t("sessions.title")} />
{showHostFilter ? (
{showFilterRow ? (
<View style={styles.filterContainer}>
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
/>
{isSearchSupported ? (
<SearchField
value={searchInput}
onChangeText={setSearchInput}
placeholder={t("sessions.searchPlaceholder")}
clearAccessibilityLabel={t("sessions.actions.clearSearch")}
testID="sessions-search-input"
clearTestID="sessions-search-clear"
/>
) : null}
{showHostFilter ? (
<HostFilter
hosts={hosts}
selectedHost={selectedHost}
onSelectHost={setSelectedHost}
triggerTestID="sessions-host-filter-trigger"
hostOptionTestID={sessionsHostOptionTestID}
/>
) : null}
</View>
) : null}
{hostErrors.length > 0 ? <SessionHostErrorsBanner errors={hostErrors} t={t} /> : null}
{isInitialLoad ? (
<View style={styles.loadingContainer}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
@@ -103,23 +189,31 @@ function SessionsScreenContent() {
</Button>
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length === 0 ? (
<View style={styles.emptyContainer}>
{!isInitialLoad && !showLoadError && agents.length === 0 ? (
<View style={styles.emptyContainer} testID="sessions-empty">
<Text style={styles.emptyText}>{emptyText}</Text>
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
{isSearching ? (
<Button variant="ghost" onPress={handleClearSearch}>
{t("sessions.actions.clearSearch")}
</Button>
) : (
<Button variant="ghost" leftIcon={ChevronLeft} onPress={handleBack}>
Back
</Button>
)}
</View>
) : null}
{!isInitialLoad && !showLoadError && sortedAgents.length > 0 ? (
{!isInitialLoad && !showLoadError && agents.length > 0 ? (
<AgentList
agents={sortedAgents}
agents={agents}
showCheckoutInfo={false}
isRefreshing={isManualRefresh}
onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
showAttentionIndicator={false}
showHostColumn
searchMatchesByAgentKey={isSearching ? searchMatchesByAgentKey : undefined}
flat={isSearching}
/>
) : null}
</View>
@@ -132,6 +226,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0,
},
filterContainer: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
@@ -158,4 +255,26 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingVertical: theme.spacing[4],
},
footerHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
errorsBannerWrap: {
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[6],
},
paddingTop: theme.spacing[3],
},
errorsBanner: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[3],
gap: theme.spacing[1],
},
errorsBannerText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
}));
@@ -1,4 +1,8 @@
import { compareMatchScores, type MatchScore, scoreTextFields } from "@/utils/score-match";
import {
compareMatchScores,
type MatchScore,
scoreTextFields,
} from "@getpaseo/protocol/search/text-match";
interface CommandAutocompleteEntry {
command: {
@@ -1,78 +0,0 @@
import { describe, expect, it } from "vitest";
import { compareMatchScores, scoreMatch } from "./score-match";
describe("scoreMatch", () => {
it("returns tier 0 for empty query", () => {
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 0 for exact match ignoring case", () => {
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
});
it("returns tier 1 for whole-word match at start", () => {
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
});
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
});
it("returns tier 2 for string prefix that does not complete a word", () => {
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
});
it("returns tier 3 for word-boundary start that does not complete a word", () => {
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
});
it("returns tier 4 for substring inside a word", () => {
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
});
it("returns null when query is not found", () => {
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
});
it("picks the best tier across multiple occurrences", () => {
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
});
it("prefers earlier offset when tiers are equal", () => {
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
});
it("treats common separators as word boundaries", () => {
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
});
it("scores PR-title-shaped text realistically", () => {
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
});
});
describe("compareMatchScores", () => {
it("sorts by tier ascending", () => {
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
});
it("tie-breaks by offset ascending at the same tier", () => {
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
});
});
-93
View File
@@ -1,93 +0,0 @@
export interface MatchScore {
tier: number;
offset: number;
spread?: number;
}
function isWordBoundaryChar(ch: string | undefined): boolean {
if (ch === undefined) return true;
return !/[a-z0-9]/.test(ch);
}
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
let best: MatchScore | null = null;
let pos = 0;
while (pos <= text.length - query.length) {
const found = text.indexOf(query, pos);
if (found === -1) break;
const before = found > 0 ? text[found - 1] : undefined;
const after = text[found + query.length];
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
let tier: number;
if (startsAtBoundary && endsAtBoundary) {
tier = 1;
} else if (found === 0) {
tier = 2;
} else if (startsAtBoundary) {
tier = 3;
} else {
tier = 4;
}
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
best = { tier, offset: found };
}
pos = found + 1;
}
return best;
}
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
let queryIndex = 0;
let firstIndex = -1;
let lastIndex = -1;
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
if (text[textIndex] !== query[queryIndex]) continue;
if (firstIndex === -1) firstIndex = textIndex;
lastIndex = textIndex;
queryIndex += 1;
}
if (queryIndex !== query.length || firstIndex === -1) return null;
return { tier: 5, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
}
export function scoreMatch(query: string, text: string): MatchScore | null {
if (!query) return { tier: 0, offset: 0 };
const q = query.toLowerCase();
const t = text.toLowerCase();
if (t === q) return { tier: 0, offset: 0 };
return scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
}
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.offset !== b.offset) return a.offset - b.offset;
return (a.spread ?? 0) - (b.spread ?? 0);
}
export function scoreTextFields(query: string, fields: string[]): MatchScore | null {
const tokens = query
.trim()
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 0);
if (tokens.length === 0) return { tier: 0, offset: 0, spread: 0 };
const aggregate: MatchScore = { tier: 0, offset: 0, spread: 0 };
for (const token of tokens) {
let best: MatchScore | null = null;
for (const field of fields) {
const score = scoreMatch(token, field);
if (score && (!best || compareMatchScores(score, best) < 0)) {
best = score;
}
}
if (!best) return null;
aggregate.tier += best.tier;
aggregate.offset += best.offset;
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
}
return aggregate;
}
@@ -1,4 +1,4 @@
import { scoreMatch } from "./score-match";
import { scoreMatch } from "@getpaseo/protocol/search/text-match";
export interface BuildWorkingDirectorySuggestionsInput {
recommendedPaths: string[];