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:
Mohamed Boudra
2026-05-09 19:00:01 +07:00
parent d1bda25a0b
commit c725352888
7 changed files with 204 additions and 176 deletions
@@ -145,6 +145,7 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: "100%",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
gap: 2,
},
containerSm: {
padding: 2,
@@ -22,7 +22,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
<Pressable
testID="composer-import-agent-pill"
accessibilityRole="button"
accessibilityLabel="Import agent"
accessibilityLabel="Import session"
onPress={onPress}
disabled={disabled}
onHoverIn={handleHoverIn}
@@ -31,7 +31,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={styles.label} numberOfLines={1}>
Import agent
Import session
</Text>
</Pressable>
</View>
@@ -11,14 +11,17 @@ import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16 },
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
borderWidth: { 1: 1 },
borderRadius: { md: 6, full: 9999 },
borderRadius: { md: 6, lg: 8, full: 9999 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400", medium: "500", semibold: "600" },
iconSize: { sm: 14, md: 16 },
opacity: { 50: 0.5 },
colors: {
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
@@ -58,6 +61,42 @@ vi.mock("@/components/provider-icons", () => ({
getProviderIcon: () => () => null,
}));
vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () =>
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
}));
vi.mock("@/components/ui/segmented-control", () => ({
SegmentedControl: ({
options,
value,
onValueChange,
testID,
}: {
options: ReadonlyArray<{ value: string; label: string; testID?: string }>;
value: string;
onValueChange: (value: string) => void;
testID?: string;
}) =>
React.createElement(
"div",
{ "data-testid": testID },
options.map((option) =>
React.createElement(
"button",
{
key: option.value,
type: "button",
"data-testid": option.testID,
"data-selected": value === option.value,
onClick: () => onValueChange(option.value),
},
option.label,
),
),
),
}));
vi.mock("@/components/adaptive-modal-sheet", () => ({
AdaptiveModalSheet: ({
visible,
@@ -349,9 +388,8 @@ describe("WorkspaceImportSheet", () => {
});
});
await screen.findByText("Claude Code");
await screen.findByText("Implement the importer sheet");
screen.getByText("2h ago");
screen.getByText("Implement the importer sheet");
screen.getByText("Make the rows readable and provider opaque");
});
@@ -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,
@@ -881,7 +881,7 @@ function WorkspaceHeaderMenu({
disabled={importAgentDisabled}
onSelect={onOpenImportSheet}
>
Import agent...
Import session
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
@@ -179,6 +179,45 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
});
});
test("listImportableProviderSessions filters out metadata generation sessions", async () => {
const cwd = "/tmp/project";
const descriptors = [
makeDescriptor({
sessionId: "metadata-session",
nativeHandle: "metadata-handle",
cwd,
title: "Generate metadata for a coding agent based on the user prom...",
lastActivityAt: "2026-04-30T12:05:00.000Z",
firstPrompt:
"Generate metadata for a coding agent based on the user prompt.\nTitle: short descriptive label (<= 40 chars).",
}),
makeDescriptor({
sessionId: "real-session",
nativeHandle: "real-handle",
cwd,
title: "Real session",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "hey hey",
}),
];
const result = await listImportableProviderSessions({
request: makeRequest({ cwd, providers: ["codex"] }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => descriptors,
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: { codex: { label: "Codex" } },
});
expect(result.entries).toHaveLength(1);
expect(result.entries[0].providerHandleId).toBe("real-handle");
expect(result.filteredAlreadyImportedCount).toBe(0);
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
@@ -16,6 +16,9 @@ import type {
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
const METADATA_GENERATION_PROMPT_PREFIX =
"Generate metadata for a coding agent based on the user prompt.";
export interface NormalizedImportAgentRequest {
provider: string;
providerHandleId: string;
@@ -91,6 +94,9 @@ export async function listImportableProviderSessions(
if (sinceTimestamp !== null && descriptor.lastActivityAt.getTime() < sinceTimestamp) {
continue;
}
if (isMetadataGenerationDescriptor(descriptor)) {
continue;
}
const providerHandleId =
descriptor.persistence.nativeHandle ?? descriptor.persistence.sessionId;
if (importedHandles.has(toProviderSessionHandleKey(descriptor.provider, providerHandleId))) {
@@ -144,6 +150,14 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
function isMetadataGenerationDescriptor(descriptor: PersistedAgentDescriptor): boolean {
for (const item of descriptor.timeline) {
if (item.type !== "user_message") continue;
return item.text.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX);
}
return false;
}
function collectProviderSessionHandleKeys(
target: Set<string>,
provider: AgentProvider | StoredAgentRecord["provider"] | string,