feat: import existing CLI sessions from the home screen
Home screen now leads with action tiles (Add a project, Import session, Setup providers, Pair device) instead of a single button, giving new users a clear path in even before they've added a project. Import session is the onboarding hook: the sheet now works without a workspace context and lists recent sessions from every provider's native history (Claude, Codex, OpenCode, Pi, ACP). Picking one imports the agent and lands the user on its workspace. To make that work end-to-end, the daemon upserts the imported agent's cwd as a workspace so the sidebar surfaces it even when the user never explicitly opened the folder. Also adds a Home entry to cmd+K and a refresh button to the import sheet header for explicit re-fetch.
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
@@ -106,6 +106,8 @@ function CommandCenterActionRow({
|
||||
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
|
||||
} else if (action.icon === "settings") {
|
||||
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
|
||||
} else if (action.icon === "home") {
|
||||
actionIcon = <Home size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
borderWidth: { 1: 1 },
|
||||
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",
|
||||
border: "#444",
|
||||
borderAccent: "#555",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.hoisted(() => {
|
||||
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
withUnistyles:
|
||||
(Component: React.ComponentType<Record<string, unknown>>) =>
|
||||
({
|
||||
uniProps,
|
||||
...rest
|
||||
}: {
|
||||
uniProps?: (theme: unknown) => Record<string, unknown>;
|
||||
} & Record<string, unknown>) => {
|
||||
const themed = uniProps ? uniProps(theme) : {};
|
||||
return React.createElement(Component, { ...rest, ...themed });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/provider-icons", () => ({
|
||||
getProviderIcon: () => () => null,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const icon = (name: string) => {
|
||||
const Icon = () => React.createElement("span", { "data-icon": name });
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Inbox: icon("Inbox"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () =>
|
||||
React.createElement("span", { "data-testid": "import-session-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,
|
||||
header,
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; actions?: ReactNode };
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.actions}
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("react-native", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
||||
return actual;
|
||||
});
|
||||
|
||||
const mockSnapshot = vi.hoisted(() => ({
|
||||
current: {
|
||||
entries: undefined as ProviderSnapshotEntry[] | undefined,
|
||||
supportsSnapshot: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-providers-snapshot", () => ({
|
||||
useProvidersSnapshot: () => ({
|
||||
entries: mockSnapshot.current.entries,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
supportsSnapshot: mockSnapshot.current.supportsSnapshot,
|
||||
refresh: vi.fn(),
|
||||
refetchIfStale: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
interface RenderOptions {
|
||||
visible?: boolean;
|
||||
onClose?: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
||||
cwd?: string | null;
|
||||
snapshot?: {
|
||||
entries?: ProviderSnapshotEntry[];
|
||||
supportsSnapshot?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||
options?: RenderOptions,
|
||||
) {
|
||||
mockSnapshot.current = {
|
||||
entries: options?.snapshot?.entries,
|
||||
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ImportSessionSheet
|
||||
visible={options?.visible ?? true}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
cwd={cwd}
|
||||
onClose={options?.onClose ?? vi.fn()}
|
||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||
onImported={options?.onImported}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function createRecentSessionsClient(
|
||||
fetchRecentProviderSessions: Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions"
|
||||
>["fetchRecentProviderSessions"],
|
||||
importAgent: Pick<DaemonClient, "importAgent">["importAgent"],
|
||||
): Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent"> {
|
||||
return { fetchRecentProviderSessions, importAgent };
|
||||
}
|
||||
|
||||
function createImportedAgentSnapshot(id: string): Awaited<ReturnType<DaemonClient["importAgent"]>> {
|
||||
return {
|
||||
id,
|
||||
provider: "custom-provider",
|
||||
cwd: "/repo/paseo",
|
||||
model: null,
|
||||
createdAt: "2026-04-30T10:00:00.000Z",
|
||||
updatedAt: "2026-04-30T10:00:00.000Z",
|
||||
lastUserMessageAt: "2026-04-30T10:00:00.000Z",
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSessionEntry(
|
||||
overrides?: Partial<FetchRecentProviderSessionEntry>,
|
||||
): FetchRecentProviderSessionEntry {
|
||||
return {
|
||||
providerId: "custom-provider",
|
||||
providerLabel: "Custom Agent",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
title: "Import me",
|
||||
firstPromptPreview: "Import this external provider session",
|
||||
lastPromptPreview: "Import this external provider session",
|
||||
lastActivityAt: "2026-04-30T10:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
|
||||
function createSnapshotEntry(
|
||||
provider: string,
|
||||
overrides?: Partial<ProviderSnapshotEntry>,
|
||||
): ProviderSnapshotEntry {
|
||||
return {
|
||||
provider,
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
label: PROVIDER_LABELS[provider] ?? provider,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ImportSessionSheet", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows an update-host message when the daemon does not support provider snapshots", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn();
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet({ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>);
|
||||
|
||||
await screen.findByText("Update the host to import sessions.");
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a loading state while provider snapshot is loading", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
() => new Promise<Awaited<ReturnType<DaemonClient["fetchRecentProviderSessions"]>>>(() => {}),
|
||||
);
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: undefined },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Loading recent sessions...");
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an empty state when there are no recent provider sessions to import", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("No recent sessions to import.");
|
||||
});
|
||||
|
||||
it("shows the all-already-imported empty state when filteredAlreadyImportedCount is positive", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [],
|
||||
filteredAlreadyImportedCount: 3,
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("All recent sessions are already imported.");
|
||||
expect(screen.queryByText("No recent sessions to import.")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a fetch error state when recent provider sessions cannot be loaded", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => {
|
||||
throw new Error("recent sessions unavailable");
|
||||
});
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Could not load recent sessions.");
|
||||
});
|
||||
|
||||
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-30T12:00:00.000Z"));
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
title: null,
|
||||
firstPromptPreview: "Implement the importer sheet",
|
||||
lastPromptPreview: "Make the rows readable and provider opaque",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
});
|
||||
|
||||
await screen.findByText("Implement the importer sheet");
|
||||
screen.getByText("2h ago");
|
||||
screen.getByText("Make the rows readable and provider opaque");
|
||||
});
|
||||
|
||||
it("keeps cached rows visible and revalidates when reopened", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
title: "Cached importable session",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
mockSnapshot.current = {
|
||||
entries: [createSnapshotEntry("claude")],
|
||||
supportsSnapshot: true,
|
||||
};
|
||||
|
||||
function TestSheet({ visible }: { visible: boolean }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ImportSessionSheet
|
||||
visible={visible}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
cwd="/repo/paseo"
|
||||
onClose={vi.fn()}
|
||||
onImportedAgent={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { rerender } = render(<TestSheet visible />);
|
||||
|
||||
await screen.findByText("Cached importable session");
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(<TestSheet visible={false} />);
|
||||
fetchRecentProviderSessions.mockClear();
|
||||
rerender(<TestSheet visible />);
|
||||
|
||||
await screen.findByText("Cached importable session");
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onClose = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
});
|
||||
});
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an import error state without closing when selected session import fails", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [createProviderSessionEntry({ providerId: "claude", providerLabel: "Claude Code" })],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => {
|
||||
throw new Error("import unavailable");
|
||||
});
|
||||
const onClose = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
|
||||
await screen.findByText("Could not import selected session.");
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
});
|
||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => ({
|
||||
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: options?.providers?.[0] ?? "custom-provider",
|
||||
providerLabel: options?.providers?.[0] ?? "Custom",
|
||||
providerHandleId: `${options?.providers?.[0] ?? "custom-provider"}-thread`,
|
||||
title: `Session ${options?.providers?.[0] ?? "all"}`,
|
||||
lastActivityAt: "2026-04-30T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [
|
||||
createSnapshotEntry("claude"),
|
||||
createSnapshotEntry("codex"),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
});
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["codex"],
|
||||
limit: 15,
|
||||
});
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["opencode"] }),
|
||||
);
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["z-ai"] }),
|
||||
);
|
||||
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
});
|
||||
|
||||
it("shows partial-failure note when one provider request fails but others succeed", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => {
|
||||
const provider = options?.providers?.[0];
|
||||
if (provider === "claude") {
|
||||
throw new Error("claude offline");
|
||||
}
|
||||
return {
|
||||
requestId: `recent-${provider ?? "all"}`,
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: provider ?? "custom-provider",
|
||||
providerHandleId: `${provider}-thread`,
|
||||
providerLabel: provider ?? "Custom",
|
||||
title: `Session ${provider}`,
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Could not load sessions for Claude Code.");
|
||||
});
|
||||
|
||||
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => {
|
||||
const provider = options?.providers?.[0] ?? "claude";
|
||||
return {
|
||||
requestId: `recent-${provider}`,
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: provider,
|
||||
providerLabel: provider === "claude" ? "Claude Code" : "Codex",
|
||||
providerHandleId: `${provider}-thread`,
|
||||
title: `Session ${provider}`,
|
||||
lastActivityAt:
|
||||
provider === "claude" ? "2026-04-30T09:00:00.000Z" : "2026-04-30T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
|
||||
|
||||
screen.getByText("Session codex");
|
||||
expect(screen.queryByText("Session claude")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-all"));
|
||||
|
||||
screen.getByText("Session claude");
|
||||
screen.getByText("Session codex");
|
||||
});
|
||||
|
||||
it("does not render filter badges when only one importable provider is enabled", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-codex",
|
||||
entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [
|
||||
createSnapshotEntry("codex"),
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId("import-session-filters")).toBeNull();
|
||||
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn();
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: {
|
||||
supportsSnapshot: true,
|
||||
entries: [
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
createSnapshotEntry("codex", { enabled: false }),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("No importable providers are enabled.");
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/home/me/work/other-project",
|
||||
title: "Cross-project session",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
providers: ["claude"],
|
||||
limit: 15,
|
||||
});
|
||||
});
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: expect.anything() }),
|
||||
);
|
||||
await screen.findByText("/home/me/work/other-project");
|
||||
});
|
||||
|
||||
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/home/me/work/other-project",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onImported = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
cwd: null,
|
||||
onClose,
|
||||
onImported,
|
||||
onImportedAgent,
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/home/me/work/other-project",
|
||||
});
|
||||
});
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refetches sessions when the refresh button is clicked", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
title: "Refreshable session",
|
||||
}),
|
||||
],
|
||||
}));
|
||||
const importAgent = vi.fn();
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>,
|
||||
{
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Refreshable session");
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByTestId("import-session-refresh"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,686 @@
|
||||
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 { Inbox, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } 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"
|
||||
>;
|
||||
|
||||
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
|
||||
|
||||
interface ImportSessionSheetProps {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
serverId: string | null;
|
||||
cwd?: string | null;
|
||||
onClose: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: ImportedAgent) => 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;
|
||||
cwd: string | null | undefined;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
queryKey: [...sessionsQueryRoot, provider],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(cwd ? { cwd } : {}),
|
||||
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;
|
||||
}
|
||||
|
||||
function SheetStatusMessages({
|
||||
isClientReady,
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a host 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateInputs {
|
||||
isLoadingSessions: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
isQueryingProviders: boolean;
|
||||
allQueriesSettled: boolean;
|
||||
selectedProvider: string;
|
||||
aggregatedCount: number;
|
||||
visibleCount: number;
|
||||
totalAlreadyImportedCount: number;
|
||||
providerLabelById: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
function computeEmptyState(input: EmptyStateInputs): {
|
||||
showEmptyState: boolean;
|
||||
emptyStateTitle: string;
|
||||
} {
|
||||
const showEmptyState =
|
||||
!input.isLoadingSessions &&
|
||||
!input.allQueriesErrored &&
|
||||
input.isQueryingProviders &&
|
||||
input.allQueriesSettled &&
|
||||
input.visibleCount === 0;
|
||||
if (!showEmptyState) {
|
||||
return { showEmptyState, emptyStateTitle: "" };
|
||||
}
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
|
||||
}
|
||||
if (input.totalAlreadyImportedCount > 0) {
|
||||
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
|
||||
}
|
||||
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
|
||||
}
|
||||
|
||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType) => [
|
||||
styles.refreshButton,
|
||||
pressed && styles.refreshButtonPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isRefreshing}
|
||||
accessibilityLabel="Refresh sessions"
|
||||
accessibilityRole="button"
|
||||
testID="import-session-refresh"
|
||||
style={pressableStyle}
|
||||
>
|
||||
<View style={styles.refreshIconSlot}>
|
||||
{isRefreshing ? (
|
||||
<LoadingSpinner color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetEmptyState({ title }: { title: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<View style={styles.emptyState} testID="import-session-empty-state">
|
||||
<View style={styles.emptyStateIcon}>
|
||||
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
|
||||
</View>
|
||||
<Text style={styles.emptyStateTitle}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function buildProviderFilterOptions(
|
||||
providers: ReadonlyArray<string>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): SegmentedControlOption<string>[] {
|
||||
const options: SegmentedControlOption<string>[] = [
|
||||
{ value: ALL_FILTER_VALUE, label: "All", testID: "import-session-filter-all" },
|
||||
];
|
||||
for (const provider of providers) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
options.push({
|
||||
value: provider,
|
||||
label: providerLabelById.get(provider) ?? provider,
|
||||
testID: `import-session-filter-${provider}`,
|
||||
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function ImportSessionSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
showCwd,
|
||||
onImportSession,
|
||||
}: {
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: boolean;
|
||||
showCwd: 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={`import-session-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>
|
||||
{showCwd && entry.cwd ? (
|
||||
<Text style={styles.rowCwd} numberOfLines={1}>
|
||||
{entry.cwd}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
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", cwd ?? null] as const,
|
||||
[cwd],
|
||||
);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
() =>
|
||||
buildSessionsQueriesConfig({
|
||||
providersToFetch,
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
|
||||
);
|
||||
|
||||
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) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const effectiveCwd = cwd ?? entry.cwd;
|
||||
if (!effectiveCwd) {
|
||||
throw new Error("Session is missing a working directory");
|
||||
}
|
||||
const agent = await client.importAgent({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: effectiveCwd,
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
onSuccess: async (agent) => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
onClose();
|
||||
onImportedAgent?.(agent.id);
|
||||
onImported?.(agent);
|
||||
},
|
||||
});
|
||||
|
||||
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 isRefreshing = queries.some((query) => query.isFetching);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
}, [queryClient, sessionsQueryRoot]);
|
||||
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: "Import session",
|
||||
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||
}),
|
||||
[isRefreshing, handleRefresh],
|
||||
);
|
||||
|
||||
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, emptyStateTitle } = computeEmptyState({
|
||||
isLoadingSessions,
|
||||
allQueriesErrored,
|
||||
isQueryingProviders,
|
||||
allQueriesSettled,
|
||||
selectedProvider,
|
||||
aggregatedCount: aggregatedEntries.length,
|
||||
visibleCount: visibleEntries.length,
|
||||
totalAlreadyImportedCount,
|
||||
providerLabelById,
|
||||
});
|
||||
const showFilter = filterProviders.length > 1;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showFilter ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
>
|
||||
<SegmentedControl
|
||||
testID="import-session-filters"
|
||||
size="sm"
|
||||
options={filterOptions}
|
||||
value={selectedProvider}
|
||||
onValueChange={setSelectedProvider}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{visibleEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={importMutation.isPending}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
showCwd={!cwd}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : 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,
|
||||
},
|
||||
rowCwd: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[8],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
emptyStateIcon: {
|
||||
opacity: 0.6,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
emptyStateTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
refreshButton: {
|
||||
padding: theme.spacing[2],
|
||||
marginRight: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
refreshButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
refreshIconSlot: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user