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.
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
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, withUnistyles } from "react-native-unistyles";
|
||||
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";
|
||||
@@ -14,11 +16,7 @@ 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 ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const activityIndicatorColorMapping = (theme: { colors: { foregroundMuted: string } }) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
type RecentProviderSessionsClient = Pick<
|
||||
DaemonClient,
|
||||
@@ -188,8 +186,9 @@ function SheetStatusMessages({
|
||||
showEmptyState,
|
||||
allAlreadyImported,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a workspace to import agents.</Text>;
|
||||
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>;
|
||||
@@ -201,7 +200,7 @@ function SheetStatusMessages({
|
||||
) : null}
|
||||
{isLoadingSessions ? (
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedActivityIndicator size="small" uniProps={activityIndicatorColorMapping} />
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.statusText}>Loading recent sessions...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -227,65 +226,23 @@ function SheetStatusMessages({
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderFilterBadgeProps {
|
||||
testID: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function ProviderFilterBadge({ testID, label, selected, onPress }: ProviderFilterBadgeProps) {
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.filterBadge,
|
||||
selected ? styles.filterBadgeSelected : styles.filterBadgeUnselected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.filterBadgeLabel,
|
||||
selected ? styles.filterBadgeLabelSelected : styles.filterBadgeLabelUnselected,
|
||||
],
|
||||
[selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={onPress}
|
||||
style={containerStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<Text style={textStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderFilterBadgeItem({
|
||||
provider,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
provider: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: (provider: string) => void;
|
||||
}) {
|
||||
const handlePress = useCallback(() => onSelect(provider), [onSelect, provider]);
|
||||
return (
|
||||
<ProviderFilterBadge
|
||||
testID={`workspace-import-filter-${provider}`}
|
||||
label={label}
|
||||
selected={selected}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
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({
|
||||
@@ -311,6 +268,14 @@ function WorkspaceImportSheetRow({
|
||||
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
|
||||
@@ -318,25 +283,23 @@ function WorkspaceImportSheetRow({
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={styles.row}
|
||||
style={pressableStyle}
|
||||
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
|
||||
>
|
||||
<View style={styles.rowHeader}>
|
||||
<View style={styles.rowProvider}>
|
||||
<ProviderIcon size={theme.fontSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.providerLabel} numberOfLines={1}>
|
||||
{entry.providerLabel}
|
||||
<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.lastActivity}>{lastActivity}</Text>
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.promptPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
</Text>
|
||||
{importing ? <Text style={styles.importingText}>Importing...</Text> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -390,22 +353,28 @@ export function WorkspaceImportSheet({
|
||||
[queries],
|
||||
);
|
||||
|
||||
const badgeProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || (selectedProvider !== null && !badgeProviders.includes(selectedProvider))) {
|
||||
setSelectedProvider(null);
|
||||
if (
|
||||
!visible ||
|
||||
(selectedProvider !== ALL_FILTER_VALUE && !filterProviders.includes(selectedProvider))
|
||||
) {
|
||||
setSelectedProvider(ALL_FILTER_VALUE);
|
||||
}
|
||||
}, [visible, badgeProviders, selectedProvider]);
|
||||
}, [visible, filterProviders, selectedProvider]);
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
if (selectedProvider === null) return aggregatedEntries;
|
||||
if (selectedProvider === ALL_FILTER_VALUE) return aggregatedEntries;
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||
}, [aggregatedEntries, selectedProvider]);
|
||||
|
||||
const handleSelectAll = useCallback(() => setSelectedProvider(null), []);
|
||||
const filterOptions = useMemo(
|
||||
() => buildProviderFilterOptions(filterProviders, providerLabelById),
|
||||
[filterProviders, providerLabelById],
|
||||
);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
@@ -460,39 +429,30 @@ export function WorkspaceImportSheet({
|
||||
allQueriesSettled &&
|
||||
aggregatedEntries.length === 0;
|
||||
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
|
||||
const showBadges = badgeProviders.length > 1;
|
||||
const showFilter = filterProviders.length > 1;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title="Import agent"
|
||||
title="Import session"
|
||||
testID="workspace-import-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showBadges ? (
|
||||
{showFilter ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
testID="workspace-import-filters"
|
||||
>
|
||||
<ProviderFilterBadge
|
||||
testID="workspace-import-filter-all"
|
||||
label="All"
|
||||
selected={selectedProvider === null}
|
||||
onPress={handleSelectAll}
|
||||
<SegmentedControl
|
||||
testID="workspace-import-filters"
|
||||
size="sm"
|
||||
options={filterOptions}
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
/>
|
||||
{badgeProviders.map((provider) => (
|
||||
<ProviderFilterBadgeItem
|
||||
key={provider}
|
||||
provider={provider}
|
||||
label={providerLabelById.get(provider) ?? provider}
|
||||
selected={selectedProvider === provider}
|
||||
onSelect={setSelectedProvider}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
@@ -524,89 +484,65 @@ export function WorkspaceImportSheet({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
list: {
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[1],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
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,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
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: {
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowProvider: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
providerLabel: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
filterRow: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
filterBadge: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
filterBadgeSelected: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
filterBadgeUnselected: {
|
||||
backgroundColor: "transparent",
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterBadgeLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
filterBadgeLabelSelected: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
filterBadgeLabelUnselected: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
lastActivity: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
rowTitle: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
promptPreview: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 19,
|
||||
},
|
||||
importingText: {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user