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:
@@ -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,
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user