From c725352888a6cb65e32e4be4a93da18d47ef5072 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 19:00:01 +0700 Subject: [PATCH] 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. --- .../src/components/ui/segmented-control.tsx | 1 + .../workspace/composer-import-pill.tsx | 4 +- .../workspace/workspace-import-sheet.test.tsx | 46 ++- .../workspace/workspace-import-sheet.tsx | 274 +++++++----------- .../screens/workspace/workspace-screen.tsx | 2 +- .../src/server/agent/import-sessions.test.ts | 39 +++ .../src/server/agent/import-sessions.ts | 14 + 7 files changed, 204 insertions(+), 176 deletions(-) diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index 78426e69b..4e0495d35 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -145,6 +145,7 @@ const styles = StyleSheet.create((theme) => ({ maxWidth: "100%", backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, + gap: 2, }, containerSm: { padding: 2, diff --git a/packages/app/src/screens/workspace/composer-import-pill.tsx b/packages/app/src/screens/workspace/composer-import-pill.tsx index 25267a834..98b452dc5 100644 --- a/packages/app/src/screens/workspace/composer-import-pill.tsx +++ b/packages/app/src/screens/workspace/composer-import-pill.tsx @@ -22,7 +22,7 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport - Import agent + Import session diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx b/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx index 9ce5dc958..0f82febd1 100644 --- a/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx +++ b/packages/app/src/screens/workspace/workspace-import-sheet.test.tsx @@ -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"); }); diff --git a/packages/app/src/screens/workspace/workspace-import-sheet.tsx b/packages/app/src/screens/workspace/workspace-import-sheet.tsx index 9073254e0..accef3a93 100644 --- a/packages/app/src/screens/workspace/workspace-import-sheet.tsx +++ b/packages/app/src/screens/workspace/workspace-import-sheet.tsx @@ -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 = 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 Connect to a workspace to import agents.; + return Connect to a workspace to import sessions; } if (isSnapshotUnsupported) { return Update the host to import sessions.; @@ -201,7 +200,7 @@ function SheetStatusMessages({ ) : null} {isLoadingSessions ? ( - + Loading recent sessions... ) : 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 ( - - - {label} - - - ); -} - -function ProviderFilterBadgeItem({ - provider, - label, - selected, - onSelect, -}: { - provider: string; - label: string; - selected: boolean; - onSelect: (provider: string) => void; -}) { - const handlePress = useCallback(() => onSelect(provider), [onSelect, provider]); - return ( - - ); +function buildProviderFilterOptions( + providers: ReadonlyArray, + providerLabelById: ReadonlyMap, +): SegmentedControlOption[] { + const options: SegmentedControlOption[] = [ + { 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 }) => , + }); + } + 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 ( - - - - - {entry.providerLabel} + + + + + + + {title} + {importing ? "Importing..." : lastActivity} - {lastActivity} + + {promptPreview} + - - {title} - - - {promptPreview} - - {importing ? Importing... : null} ); } @@ -390,22 +353,28 @@ export function WorkspaceImportSheet({ [queries], ); - const badgeProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); + const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); - const [selectedProvider, setSelectedProvider] = useState(null); + const [selectedProvider, setSelectedProvider] = useState(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 ( - {showBadges ? ( + {showFilter ? ( - - {badgeProviders.map((provider) => ( - - ))} ) : null} ({ + 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, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2725c430c..272d5f395 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -881,7 +881,7 @@ function WorkspaceHeaderMenu({ disabled={importAgentDisabled} onSelect={onOpenImportSheet} > - Import agent... + Import session { + 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, + agentStorage: { + list: async () => [], + } satisfies Pick, + 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({ diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 118b0b55d..3317f6daf 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -16,6 +16,9 @@ import type { type ImportAgentRequestMessage = z.infer; +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, provider: AgentProvider | StoredAgentRecord["provider"] | string,