Import provider sessions as ready agents

Provider listing now returns picker rows only. Importing a selected provider session hydrates its timeline before the daemon publishes the Paseo agent.
This commit is contained in:
Mohamed Boudra
2026-06-10 11:29:27 +07:00
parent cfd72b815c
commit 51d1d007ce
28 changed files with 1035 additions and 744 deletions
@@ -71,7 +71,9 @@ vi.mock("lucide-react-native", () => {
return Icon;
};
return {
ChevronDown: icon("ChevronDown"),
Inbox: icon("Inbox"),
Layers: icon("Layers"),
RotateCw: icon("RotateCw"),
};
});
@@ -81,35 +83,38 @@ vi.mock("@/components/ui/loading-spinner", () => ({
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
SegmentedControl: ({
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
options,
value,
onValueChange,
testID,
onSelect,
open,
}: {
options: ReadonlyArray<{ value: string; label: string; testID?: string }>;
options: ReadonlyArray<{ id: string; label: string }>;
value: string;
onValueChange: (value: string) => void;
testID?: string;
}) =>
React.createElement(
onSelect: (id: string) => void;
open?: boolean;
}) => {
if (!open) return null;
return React.createElement(
"div",
{ "data-testid": testID },
{ "data-testid": "import-session-combobox" },
options.map((option) =>
React.createElement(
"button",
{
key: option.value,
key: option.id,
type: "button",
"data-testid": option.testID,
"data-selected": value === option.value,
onClick: () => onValueChange(option.value),
"data-testid": `import-session-filter-${option.id === "__all__" ? "all" : option.id}`,
"data-selected": value === option.id,
onClick: () => onSelect(option.id),
},
option.label,
),
),
),
);
},
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
@@ -475,7 +480,13 @@ describe("ImportSessionSheet", () => {
it("imports a selected session by provider handle and reports the imported agent", async () => {
const fetchRecentProviderSessions = vi.fn(async () => ({
requestId: "recent-provider-sessions",
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
entries: [
createProviderSessionEntry({
providerId: "claude",
providerLabel: "Claude Code",
cwd: "/repo/paseo-realpath",
}),
],
}));
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
const onClose = vi.fn();
@@ -499,7 +510,7 @@ describe("ImportSessionSheet", () => {
expect(importAgent).toHaveBeenCalledWith({
providerId: "claude",
providerHandleId: "provider-thread-1",
cwd: "/repo/paseo",
cwd: "/repo/paseo-realpath",
});
});
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
@@ -678,11 +689,13 @@ describe("ImportSessionSheet", () => {
await screen.findByText("Session claude");
await screen.findByText("Session codex");
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
screen.getByText("Session codex");
expect(screen.queryByText("Session claude")).toBeNull();
fireEvent.click(screen.getByTestId("import-session-filter-trigger"));
fireEvent.click(screen.getByTestId("import-session-filter-all"));
screen.getByText("Session claude");
@@ -1,16 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type {
DaemonClient,
FetchRecentProviderSessionEntry,
} from "@getpaseo/client/internal/daemon-client";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { Inbox, RotateCw } from "lucide-react-native";
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -88,6 +88,7 @@ interface SheetStatusMessagesProps {
isSnapshotUnsupported: boolean;
hasNoImportableProviders: boolean;
isLoadingSessions: boolean;
hasRows: boolean;
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
@@ -98,6 +99,7 @@ function SheetStatusMessages({
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
hasRows,
allQueriesErrored,
erroredProviderLabels,
importErrored,
@@ -114,7 +116,7 @@ function SheetStatusMessages({
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null}
{isLoadingSessions ? (
{isLoadingSessions && !hasRows ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
@@ -176,25 +178,6 @@ function SheetEmptyState({ title }: { title: string }) {
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `import-session-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function ImportSessionSheetRow({
entry,
disabled,
@@ -271,6 +254,7 @@ export function ImportSessionSheet({
onImported,
}: ImportSessionSheetProps) {
const queryClient = useQueryClient();
const { theme } = useUnistyles();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
cwd,
@@ -315,6 +299,8 @@ export function ImportSessionSheet({
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
const [isFilterOpen, setIsFilterOpen] = useState(false);
const filterAnchorRef = useRef<View>(null);
useEffect(() => {
if (
@@ -330,24 +316,84 @@ export function ImportSessionSheet({
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]);
const filterOptions = useMemo(
() => buildProviderFilterOptions(filterProviders, providerLabelById),
const filterComboboxOptions = useMemo<ComboboxOption[]>(
() => [
{ id: ALL_FILTER_VALUE, label: "All providers" },
...filterProviders.map((provider) => ({
id: provider,
label: providerLabelById.get(provider) ?? provider,
})),
],
[filterProviders, providerLabelById],
);
const selectedProviderLabel = useMemo(
() =>
filterComboboxOptions.find((opt) => opt.id === selectedProvider)?.label ?? "All providers",
[filterComboboxOptions, selectedProvider],
);
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
const filterTriggerStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.filterTrigger,
Boolean(hovered) && styles.filterTriggerHovered,
pressed && styles.filterTriggerPressed,
],
[],
);
const handleFilterSelect = useCallback((id: string) => {
setSelectedProvider(id);
setIsFilterOpen(false);
}, []);
const filterOptionIcons = useMemo(() => {
const map = new Map<string, React.ReactNode>();
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
for (const provider of filterProviders) {
const ProviderIcon = getProviderIcon(provider);
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
}
return map;
}, [filterProviders, theme.colors.foregroundMuted]);
const renderFilterOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={filterOptionIcons.get(option.id)}
/>
),
[filterOptionIcons],
);
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client) {
throw new Error("Host is not connected");
}
const effectiveCwd = cwd ?? entry.cwd;
if (!effectiveCwd) {
if (!entry.cwd) {
throw new Error("Session is missing a working directory");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: effectiveCwd,
cwd: entry.cwd,
});
return agent;
},
@@ -423,25 +469,48 @@ export function ImportSessionSheet({
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showFilter ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="import-session-filters"
size="sm"
options={filterOptions}
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
<Pressable
onPress={handleFilterOpen}
style={filterTriggerStyle}
testID="import-session-filter-trigger"
accessibilityRole="button"
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
>
{selectedProvider === ALL_FILTER_VALUE ? (
<Layers size={14} color={theme.colors.foregroundMuted} />
) : (
(() => {
const ProviderIcon = getProviderIcon(selectedProvider);
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
})()
)}
<Text style={styles.filterTriggerText} numberOfLines={1}>
{selectedProviderLabel}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={filterComboboxOptions}
value={selectedProvider}
onValueChange={setSelectedProvider}
onSelect={handleFilterSelect}
renderOption={renderFilterOption}
searchable={false}
title="Filter by provider"
open={isFilterOpen}
onOpenChange={setIsFilterOpen}
anchorRef={filterAnchorRef}
desktopPlacement="bottom-start"
desktopPreventInitialFlash
/>
</ScrollView>
</View>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
hasRows={visibleEntries.length > 0}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
@@ -466,10 +535,32 @@ export function ImportSessionSheet({
}
const styles = StyleSheet.create((theme) => ({
filterRow: {
flexDirection: "row",
filterTriggerWrap: {
paddingBottom: theme.spacing[2],
},
filterTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
alignSelf: "flex-start",
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,
},
filterTriggerHovered: {
backgroundColor: theme.colors.surface2,
},
filterTriggerPressed: {
backgroundColor: theme.colors.surface3,
},
filterTriggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
list: {
gap: theme.spacing[1],
},