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