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,