Import existing Claude/Codex/OpenCode sessions (#766)
* Restrict provider import fan-out to enabled, non-derived allowlist Move provider eligibility from a session-level post-filter to the agent manager's fan-out so disabled providers are never spawned (fixes the Gemini-disabled hang) and derived providers (like a custom Z.AI extending Claude) don't duplicate their base provider's sessions. Add an IMPORTABLE_PROVIDERS allowlist (claude, codex, opencode); ACP-based providers are excluded from import discovery entirely. * Filter Codex persisted threads by cwd before hydrating turns Codex's listPersistedAgents previously called thread/read includeTurns on every thread returned by thread/list, even when the import sheet was scoped to one cwd. With many recent threads outside the scope, that fan of reads dominated the import-sheet open latency. Add an optional cwd hint to ListPersistedAgentsOptions, thread it through the agent manager, and have Codex pre-filter the cheap thread/list rows by cwd before scheduling the per-thread hydration. Widen the thread/list window when filtering since most rows will be from other cwds. Other providers ignore the hint. * Fan out import session fetch per enabled provider Sheet now reads the providers snapshot already warmed by the workspace screen, filters to enabled importable providers (claude/codex/opencode), and issues one fetchRecentProviderSessions request per provider via useQueries with limit 15. Results are merged, deduped by handle, and sorted by last activity. Falls back to a single unfiltered request when the daemon does not expose a provider snapshot (older servers). Per-provider failures show inline so a single broken provider does not hide the rest. * Add provider filter badges to import sheet Show "All" plus one badge per enabled importable provider above the session list when more than one is available. Single-select narrows the already-merged list — no refetch. Each row now shows the provider's icon next to its label so multiple providers stay visually distinct. Selection resets when the sheet closes. * Distinguish already-imported empty state in import sheet Track how many descriptors were dropped because their handle was already imported, surface the count back to the client as an optional `filteredAlreadyImportedCount`, and switch the sheet copy to "All recent sessions are already imported." when the merged list is empty solely due to that filter. * Cover already-imported provider session filtering * List persisted OpenCode sessions for import * Revalidate import sessions when sheet reopens * Add agent persistSession plumbing * Drop redundant visibility-transition refetch and test boilerplate The import sheet's queries are gated on `enabled: visible`, and react-query already refetches stale data when `enabled` flips on, so the manual `wasVisibleRef` + `invalidateQueries` effect was redundant. Collapse the two `selectedProvider` reset effects into one. Drop `expect(...).toBeTruthy()` after `findByText` / `getByText` — those helpers throw on miss, so the assertion was noise. Drop `| null` from `buildOpenCodePersistedAgentDescriptor`'s return type (it never returns null) and the dead `Boolean(descriptor)` filter at the caller. * Add provider-opaque session import path Introduces fetchRecentProviderSessions on the daemon client plus an importAgent overload accepting providerId/providerHandleId, and a projection helper that renders persisted descriptors as provider-opaque recent-session payloads with prompt previews. * Unslop the import-session feature Centralizes the importable-providers list, normalizes import_agent_request shape at one boundary, and removes the per-provider fan-out fallback in the import sheet. Old daemons without features.providersSnapshot now show "Update host" instead of being served a degraded path. Replaces internal-helper unit tests on the OpenCode and Codex providers with boundary tests through listPersistedAgents; makes storageRoot injectable on the OpenCode provider so the boundary test can target a temp directory. Splits the CLAUDE.md back-compat rule into two contracts: protocol stays backward-compatible (always); features may require new daemons (no fallback paths). Tags the two new compat shims with COMPAT(name) and target removal dates. * Extract importable-session listing into its own module Move listImportableProviderSessions out of session.ts and drop the unused generic listPersistedAgents from AgentManager.
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* @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 { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6, full: 9999 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
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("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
title,
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{title}</h1>
|
||||
{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;
|
||||
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 },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceImportSheet
|
||||
visible={options?.visible ?? true}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceDirectory="/repo/paseo"
|
||||
onClose={options?.onClose ?? vi.fn()}
|
||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||
/>
|
||||
</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("WorkspaceImportSheet", () => {
|
||||
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("Claude Code");
|
||||
screen.getByText("2h ago");
|
||||
screen.getByText("Implement the importer sheet");
|
||||
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}>
|
||||
<WorkspaceImportSheet
|
||||
visible={visible}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceDirectory="/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("workspace-import-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("workspace-import-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("workspace-import-filter-codex"));
|
||||
|
||||
screen.getByText("Session codex");
|
||||
expect(screen.queryByText("Session claude")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workspace-import-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("workspace-import-filters")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-import-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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,615 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, 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 { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
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 ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const activityIndicatorColorMapping = (theme: { colors: { foregroundMuted: string } }) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
type RecentProviderSessionsClient = Pick<
|
||||
DaemonClient,
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>;
|
||||
|
||||
interface WorkspaceImportSheetProps {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
serverId: string | null;
|
||||
workspaceDirectory: string | null;
|
||||
onClose: () => void;
|
||||
onImportedAgent: (agentId: string) => 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;
|
||||
workspaceDirectory: string | null;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client && workspaceDirectory);
|
||||
return providersToFetch.map((provider) => ({
|
||||
queryKey: [...sessionsQueryRoot, provider],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
cwd: workspaceDirectory,
|
||||
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;
|
||||
showEmptyState: boolean;
|
||||
allAlreadyImported: boolean;
|
||||
}
|
||||
|
||||
function SheetStatusMessages({
|
||||
isClientReady,
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
showEmptyState,
|
||||
allAlreadyImported,
|
||||
}: SheetStatusMessagesProps) {
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a workspace to import agents.</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}>
|
||||
<ThemedActivityIndicator size="small" uniProps={activityIndicatorColorMapping} />
|
||||
<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}
|
||||
{showEmptyState ? (
|
||||
<Text style={styles.statusText}>
|
||||
{allAlreadyImported
|
||||
? "All recent sessions are already imported."
|
||||
: "No recent sessions to import."}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 WorkspaceImportSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
onImportSession,
|
||||
}: {
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: 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]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={styles.row}
|
||||
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}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.lastActivity}>{lastActivity}</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>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceImportSheet({
|
||||
visible,
|
||||
client,
|
||||
serverId,
|
||||
workspaceDirectory,
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
}: WorkspaceImportSheetProps) {
|
||||
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", workspaceDirectory] as const,
|
||||
[workspaceDirectory],
|
||||
);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
() =>
|
||||
buildSessionsQueriesConfig({
|
||||
providersToFetch,
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
|
||||
const aggregatedEntries = useMemo(() => aggregateSessionEntries(queries), [queries]);
|
||||
const totalAlreadyImportedCount = useMemo(
|
||||
() => sumFilteredAlreadyImportedCount(queries),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const badgeProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || (selectedProvider !== null && !badgeProviders.includes(selectedProvider))) {
|
||||
setSelectedProvider(null);
|
||||
}
|
||||
}, [visible, badgeProviders, selectedProvider]);
|
||||
|
||||
const visibleEntries = useMemo(() => {
|
||||
if (selectedProvider === null) return aggregatedEntries;
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||
}, [aggregatedEntries, selectedProvider]);
|
||||
|
||||
const handleSelectAll = useCallback(() => setSelectedProvider(null), []);
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const agent = await client.importAgent({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: workspaceDirectory,
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
onSuccess: async (agent) => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
onClose();
|
||||
onImportedAgent(agent.id);
|
||||
},
|
||||
});
|
||||
|
||||
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 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 =
|
||||
!isLoadingSessions &&
|
||||
!allQueriesErrored &&
|
||||
isQueryingProviders &&
|
||||
allQueriesSettled &&
|
||||
aggregatedEntries.length === 0;
|
||||
const allAlreadyImported = showEmptyState && totalAlreadyImportedCount > 0;
|
||||
const showBadges = badgeProviders.length > 1;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title="Import agent"
|
||||
testID="workspace-import-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showBadges ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.filterRow}
|
||||
testID="workspace-import-filters"
|
||||
>
|
||||
<ProviderFilterBadge
|
||||
testID="workspace-import-filter-all"
|
||||
label="All"
|
||||
selected={selectedProvider === null}
|
||||
onPress={handleSelectAll}
|
||||
/>
|
||||
{badgeProviders.map((provider) => (
|
||||
<ProviderFilterBadgeItem
|
||||
key={provider}
|
||||
provider={provider}
|
||||
label={providerLabelById.get(provider) ?? provider}
|
||||
selected={selectedProvider === provider}
|
||||
onSelect={setSelectedProvider}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client && workspaceDirectory)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
showEmptyState={showEmptyState}
|
||||
allAlreadyImported={allAlreadyImported}
|
||||
/>
|
||||
{visibleEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<WorkspaceImportSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={importMutation.isPending}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
row: {
|
||||
gap: theme.spacing[1],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
rowHeader: {
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
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: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
promptPreview: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 19,
|
||||
},
|
||||
importingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Ellipsis,
|
||||
EllipsisVertical,
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
PanelRight,
|
||||
RotateCw,
|
||||
Settings,
|
||||
@@ -57,6 +58,7 @@ import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-
|
||||
import { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||
import { WorkspaceImportSheet } from "@/screens/workspace/workspace-import-sheet";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -174,6 +176,7 @@ const ThemedX = withUnistyles(X);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedImport = withUnistyles(ImportIcon);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedPanelRight = withUnistyles(PanelRight);
|
||||
const ThemedSourceControlPanelIcon = withUnistyles(SourceControlPanelIcon);
|
||||
@@ -186,6 +189,7 @@ const sourceControlPanelStrokeWidth15 = { strokeWidth: 1.5 };
|
||||
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_NEW_TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||
const GATED_WORKSPACE_HEADER_LEFT = <SidebarMenuToggle />;
|
||||
@@ -777,14 +781,17 @@ interface WorkspaceHeaderMenuProps {
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
menuImportIcon: ReactElement;
|
||||
menuCopyIcon: ReactElement;
|
||||
menuSettingsIcon: ReactElement;
|
||||
onCreateDraftTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateBrowser: () => void;
|
||||
onOpenImportSheet: () => void;
|
||||
onCopyWorkspacePath: () => void;
|
||||
onCopyBranchName: () => void;
|
||||
onOpenSetupTab: () => void;
|
||||
@@ -811,14 +818,17 @@ function WorkspaceHeaderMenu({
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
menuImportIcon,
|
||||
menuCopyIcon,
|
||||
menuSettingsIcon,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onOpenImportSheet,
|
||||
onCopyWorkspacePath,
|
||||
onCopyBranchName,
|
||||
onOpenSetupTab,
|
||||
@@ -865,6 +875,14 @@ function WorkspaceHeaderMenu({
|
||||
New browser tab
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-import-agent"
|
||||
leading={menuImportIcon}
|
||||
disabled={importAgentDisabled}
|
||||
onSelect={onOpenImportSheet}
|
||||
>
|
||||
Import agent...
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={menuCopyIcon}
|
||||
@@ -912,14 +930,17 @@ interface WorkspaceHeaderTitleBarProps {
|
||||
showCreateBrowserTab: boolean;
|
||||
isMobile: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
importAgentDisabled: boolean;
|
||||
menuNewAgentIcon: ReactElement;
|
||||
menuNewTerminalIcon: ReactElement;
|
||||
menuNewBrowserIcon: ReactElement;
|
||||
menuImportIcon: ReactElement;
|
||||
menuCopyIcon: ReactElement;
|
||||
menuSettingsIcon: ReactElement;
|
||||
onCreateDraftTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateBrowser: () => void;
|
||||
onOpenImportSheet: () => void;
|
||||
onCopyWorkspacePath: () => void;
|
||||
onCopyBranchName: () => void;
|
||||
onOpenSetupTab: () => void;
|
||||
@@ -938,14 +959,17 @@ function WorkspaceHeaderTitleBar({
|
||||
showCreateBrowserTab,
|
||||
isMobile,
|
||||
createTerminalDisabled,
|
||||
importAgentDisabled,
|
||||
menuNewAgentIcon,
|
||||
menuNewTerminalIcon,
|
||||
menuNewBrowserIcon,
|
||||
menuImportIcon,
|
||||
menuCopyIcon,
|
||||
menuSettingsIcon,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminal,
|
||||
onCreateBrowser,
|
||||
onOpenImportSheet,
|
||||
onCopyWorkspacePath,
|
||||
onCopyBranchName,
|
||||
onOpenSetupTab,
|
||||
@@ -983,14 +1007,17 @@ function WorkspaceHeaderTitleBar({
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={importAgentDisabled}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={menuNewBrowserIcon}
|
||||
menuImportIcon={menuImportIcon}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={onCreateDraftTab}
|
||||
onCreateTerminal={onCreateTerminal}
|
||||
onCreateBrowser={onCreateBrowser}
|
||||
onOpenImportSheet={onOpenImportSheet}
|
||||
onCopyWorkspacePath={onCopyWorkspacePath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onOpenSetupTab={onOpenSetupTab}
|
||||
@@ -1388,6 +1415,14 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const { workspaceDirectory, isMissingWorkspaceExecutionAuthority } =
|
||||
resolveWorkspaceAuthorityState(workspaceAuthority, workspaceDescriptor);
|
||||
const [isImportSheetVisible, setIsImportSheetVisible] = useState(false);
|
||||
const canOpenImportSheet = [client, isConnected, workspaceDirectory].every(Boolean);
|
||||
const openImportSheet = useCallback(() => {
|
||||
setIsImportSheetVisible(true);
|
||||
}, []);
|
||||
const closeImportSheet = useCallback(() => {
|
||||
setIsImportSheetVisible(false);
|
||||
}, []);
|
||||
|
||||
// Warm the global provider snapshot so the model picker is ready when opened.
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
@@ -1933,6 +1968,18 @@ function WorkspaceScreenContent({
|
||||
},
|
||||
[focusWorkspaceTab, persistenceKey],
|
||||
);
|
||||
const handleImportedAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const tabId = openWorkspaceTabFocused(persistenceKey, { kind: "agent", agentId });
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
},
|
||||
[navigateToTabId, openWorkspaceTabFocused, persistenceKey],
|
||||
);
|
||||
|
||||
const emptyWorkspaceSeedRef = useRef<string | null>(null);
|
||||
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
|
||||
@@ -3212,14 +3259,17 @@ function WorkspaceScreenContent({
|
||||
showCreateBrowserTab={showCreateBrowserTab}
|
||||
isMobile={isMobile}
|
||||
createTerminalDisabled={createTerminalDisabled}
|
||||
importAgentDisabled={!canOpenImportSheet}
|
||||
menuNewAgentIcon={menuNewAgentIcon}
|
||||
menuNewTerminalIcon={menuNewTerminalIcon}
|
||||
menuNewBrowserIcon={MENU_NEW_BROWSER_ICON}
|
||||
menuImportIcon={MENU_IMPORT_ICON}
|
||||
menuCopyIcon={menuCopyIcon}
|
||||
menuSettingsIcon={menuSettingsIcon}
|
||||
onCreateDraftTab={handleCreateDraftTab}
|
||||
onCreateTerminal={handleCreateTerminal}
|
||||
onCreateBrowser={handleCreateBrowserTab}
|
||||
onOpenImportSheet={openImportSheet}
|
||||
onCopyWorkspacePath={handleCopyWorkspacePath}
|
||||
onCopyBranchName={handleCopyBranchName}
|
||||
onOpenSetupTab={handleOpenSetupTab}
|
||||
@@ -3301,6 +3351,14 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<WorkspaceImportSheet
|
||||
visible={isImportSheetVisible}
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
workspaceDirectory={workspaceDirectory}
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
</View>
|
||||
</WorkspaceFocusProvider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user