Files
paseo/packages/app/src/screens/workspace/workspace-import-sheet.tsx
T
Mohamed Boudra c725352888 feat: import session sheet redesign and metadata-gen filter
Filter persisted metadata-generation sessions out of the import list so
existing on-disk entries stop polluting it for users.

Rename the surface to "Import session" everywhere, swap the bespoke
filter pills for SegmentedControl with provider icons, drop the trailing
provider label on rows (icon is enough), tighten padding, and align the
row icon with the segmented control's left edge.
2026-05-09 19:00:01 +07:00

552 lines
17 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
const ALL_FILTER_VALUE = "__all__";
type RecentProviderSessionsClient = Pick<
DaemonClient,
"fetchRecentProviderSessions" | "importAgent"
>;
interface WorkspaceImportSheetProps {
visible: boolean;
client: RecentProviderSessionsClient | null;
serverId: string | null;
workspaceDirectory: string | null;
onClose: () => void;
onImportedAgent: (agentId: string) => void;
}
type RecentSessionsResponse = Awaited<
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
>;
interface SessionsQueryConfig {
queryKey: ReadonlyArray<string | null>;
enabled: boolean;
queryFn: () => Promise<RecentSessionsResponse>;
}
interface SessionsQueryResult {
data: RecentSessionsResponse | undefined;
isError: boolean;
isLoading: boolean;
isPending: boolean;
}
function resolveProvidersToFetch(
supportsSnapshot: boolean,
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
): AgentProvider[] | null {
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
// we render an "update host" empty state instead of degrading. Drop this gate
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
if (!supportsSnapshot) return null;
if (!snapshotEntries) return null;
return snapshotEntries
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
.map((entry) => entry.provider);
}
function buildProviderLabelMap(
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
): Map<string, string> {
const map = new Map<string, string>();
if (!snapshotEntries) return map;
for (const entry of snapshotEntries) {
if (entry.label) {
map.set(entry.provider, entry.label);
}
}
return map;
}
function buildSessionsQueriesConfig(args: {
providersToFetch: AgentProvider[] | null;
sessionsQueryRoot: ReadonlyArray<string | null>;
visible: boolean;
client: RecentProviderSessionsClient | null;
workspaceDirectory: string | null;
}): SessionsQueryConfig[] {
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
if (providersToFetch === null) return [];
const enabled = visible && Boolean(client && workspaceDirectory);
return providersToFetch.map((provider) => ({
queryKey: [...sessionsQueryRoot, provider],
enabled,
queryFn: async () => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
return await client.fetchRecentProviderSessions({
cwd: workspaceDirectory,
providers: [provider],
limit: PER_PROVIDER_LIMIT,
});
},
}));
}
function aggregateSessionEntries(
queries: ReadonlyArray<SessionsQueryResult>,
): FetchRecentProviderSessionEntry[] {
const seen = new Set<string>();
const collected: FetchRecentProviderSessionEntry[] = [];
for (const query of queries) {
if (!query.data) continue;
for (const entry of query.data.entries) {
const key = `${entry.providerId}:${entry.providerHandleId}`;
if (seen.has(key)) continue;
seen.add(key);
collected.push(entry);
}
}
collected.sort(
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
);
return collected;
}
function sumFilteredAlreadyImportedCount(queries: ReadonlyArray<SessionsQueryResult>): number {
let total = 0;
for (const query of queries) {
total += query.data?.filteredAlreadyImportedCount ?? 0;
}
return total;
}
function collectErroredProviderLabels(
providersToFetch: AgentProvider[] | null,
queries: ReadonlyArray<SessionsQueryResult>,
providerLabelById: ReadonlyMap<string, string>,
): string[] {
if (providersToFetch === null) return [];
const labels: string[] = [];
for (let index = 0; index < queries.length; index++) {
if (queries[index]?.isError) {
const provider = providersToFetch[index];
labels.push(providerLabelById.get(provider) ?? provider);
}
}
return labels;
}
function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
const title = entry.title?.trim();
if (title) {
return title;
}
const firstPromptPreview = entry.firstPromptPreview?.trim();
if (firstPromptPreview) {
return firstPromptPreview;
}
return "Untitled session";
}
function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
}
interface SheetStatusMessagesProps {
isClientReady: boolean;
isSnapshotUnsupported: boolean;
hasNoImportableProviders: boolean;
isLoadingSessions: boolean;
allQueriesErrored: boolean;
erroredProviderLabels: ReadonlyArray<string>;
importErrored: boolean;
showEmptyState: boolean;
allAlreadyImported: boolean;
}
function SheetStatusMessages({
isClientReady,
isSnapshotUnsupported,
hasNoImportableProviders,
isLoadingSessions,
allQueriesErrored,
erroredProviderLabels,
importErrored,
showEmptyState,
allAlreadyImported,
}: SheetStatusMessagesProps) {
const { theme } = useUnistyles();
if (!isClientReady) {
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
}
if (isSnapshotUnsupported) {
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
}
return (
<>
{hasNoImportableProviders ? (
<Text style={styles.statusText}>No importable providers are enabled.</Text>
) : null}
{isLoadingSessions ? (
<View style={styles.statusRow}>
<LoadingSpinner color={theme.colors.foregroundMuted} />
<Text style={styles.statusText}>Loading recent sessions...</Text>
</View>
) : null}
{allQueriesErrored ? (
<Text style={styles.statusText}>Could not load recent sessions.</Text>
) : null}
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
<Text style={styles.statusText}>
Could not load sessions for {erroredProviderLabels.join(", ")}.
</Text>
) : null}
{importErrored ? (
<Text style={styles.statusText}>Could not import selected session.</Text>
) : null}
{showEmptyState ? (
<Text style={styles.statusText}>
{allAlreadyImported
? "All recent sessions are already imported."
: "No recent sessions to import."}
</Text>
) : null}
</>
);
}
function buildProviderFilterOptions(
providers: ReadonlyArray<string>,
providerLabelById: ReadonlyMap<string, string>,
): SegmentedControlOption<string>[] {
const options: SegmentedControlOption<string>[] = [
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
];
for (const provider of providers) {
const ProviderIcon = getProviderIcon(provider);
options.push({
value: provider,
label: providerLabelById.get(provider) ?? provider,
testID: `workspace-import-filter-${provider}`,
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
});
}
return options;
}
function WorkspaceImportSheetRow({
entry,
disabled,
importing,
onImportSession,
}: {
entry: FetchRecentProviderSessionEntry;
disabled: boolean;
importing: boolean;
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
}) {
const { theme } = useUnistyles();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
const ProviderIcon = getProviderIcon(entry.providerId);
const accessibilityState = useMemo(
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
[disabled],
);
const handlePress = useCallback(() => {
onImportSession(entry);
}, [entry, onImportSession]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
Boolean(hovered) && styles.rowHovered,
pressed && styles.rowPressed,
],
[],
);
return (
<Pressable
disabled={disabled}
onPress={handlePress}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={pressableStyle}
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
>
<View style={styles.rowIconWrap}>
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.rowContent}>
<View style={styles.rowHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.rowMeta}>{importing ? "Importing..." : lastActivity}</Text>
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
</Text>
</View>
</Pressable>
);
}
export function WorkspaceImportSheet({
visible,
client,
serverId,
workspaceDirectory,
onClose,
onImportedAgent,
}: WorkspaceImportSheetProps) {
const queryClient = useQueryClient();
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
enabled: visible,
});
const providersToFetch = useMemo(
() => resolveProvidersToFetch(supportsSnapshot, snapshotEntries),
[supportsSnapshot, snapshotEntries],
);
const providerLabelById = useMemo(
() => buildProviderLabelMap(snapshotEntries),
[snapshotEntries],
);
const sessionsQueryRoot = useMemo(
() => ["recent-provider-sessions", workspaceDirectory] as const,
[workspaceDirectory],
);
const queriesConfig = useMemo(
() =>
buildSessionsQueriesConfig({
providersToFetch,
sessionsQueryRoot,
visible,
client,
workspaceDirectory,
}),
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
);
const queries = useQueries({ queries: queriesConfig });
const aggregatedEntries = useMemo(() => aggregateSessionEntries(queries), [queries]);
const totalAlreadyImportedCount = useMemo(
() => sumFilteredAlreadyImportedCount(queries),
[queries],
);
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
useEffect(() => {
if (
!visible ||
(selectedProvider !== ALL_FILTER_VALUE && !filterProviders.includes(selectedProvider))
) {
setSelectedProvider(ALL_FILTER_VALUE);
}
}, [visible, filterProviders, selectedProvider]);
const visibleEntries = useMemo(() => {
if (selectedProvider === ALL_FILTER_VALUE) return aggregatedEntries;
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
}, [aggregatedEntries, selectedProvider]);
const filterOptions = useMemo(
() => buildProviderFilterOptions(filterProviders, providerLabelById),
[filterProviders, providerLabelById],
);
const importMutation = useMutation({
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
if (!client || !workspaceDirectory) {
throw new Error("Host is not connected");
}
const agent = await client.importAgent({
providerId: entry.providerId,
providerHandleId: entry.providerHandleId,
cwd: workspaceDirectory,
});
return agent;
},
onSuccess: async (agent) => {
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
onClose();
onImportedAgent(agent.id);
},
});
const importingSessionKey =
importMutation.isPending && importMutation.variables
? `${importMutation.variables.providerId}:${importMutation.variables.providerHandleId}`
: null;
const handleImportSession = useCallback(
(entry: FetchRecentProviderSessionEntry) => {
importMutation.mutate(entry);
},
[importMutation],
);
const erroredProviderLabels = useMemo(
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
[queries, providersToFetch, providerLabelById],
);
const isSnapshotUnsupported = !supportsSnapshot;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
const isQueryingProviders = queries.length > 0;
const isLoadingSessions =
isWaitingForSnapshot ||
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
const allQueriesSettled =
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
const showEmptyState =
!isLoadingSessions &&
!allQueriesErrored &&
isQueryingProviders &&
allQueriesSettled &&
aggregatedEntries.length === 0;
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
const showFilter = filterProviders.length > 1;
return (
<AdaptiveModalSheet
visible={visible}
onClose={onClose}
title="Import session"
testID="workspace-import-sheet"
desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS}
>
{showFilter ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterRow}
>
<SegmentedControl
testID="workspace-import-filters"
size="sm"
options={filterOptions}
value={selectedProvider}
onValueChange={setSelectedProvider}
/>
</ScrollView>
) : null}
<SheetStatusMessages
isClientReady={Boolean(client && workspaceDirectory)}
isSnapshotUnsupported={isSnapshotUnsupported}
hasNoImportableProviders={hasNoImportableProviders}
isLoadingSessions={isLoadingSessions}
allQueriesErrored={allQueriesErrored}
erroredProviderLabels={erroredProviderLabels}
importErrored={importMutation.isError}
showEmptyState={showEmptyState}
allAlreadyImported={allAlreadyImported}
/>
{visibleEntries.length > 0 ? (
<View style={styles.list}>
{visibleEntries.map((entry) => (
<WorkspaceImportSheetRow
key={`${entry.providerId}:${entry.providerHandleId}`}
entry={entry}
disabled={importMutation.isPending}
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
onImportSession={handleImportSession}
/>
))}
</View>
) : null}
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
filterRow: {
flexDirection: "row",
paddingBottom: theme.spacing[2],
},
list: {
gap: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
marginHorizontal: -theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
rowHovered: {
backgroundColor: theme.colors.surface1,
},
rowPressed: {
backgroundColor: theme.colors.surface2,
},
rowIconWrap: {
width: theme.iconSize.md,
paddingTop: 2,
alignItems: "center",
justifyContent: "center",
},
rowContent: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
rowHeader: {
flexDirection: "row",
alignItems: "baseline",
justifyContent: "space-between",
gap: theme.spacing[2],
},
rowTitle: {
flex: 1,
minWidth: 0,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
rowMeta: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
rowPreview: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
},
statusText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));