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 }],
|
||||
|
||||
+138
-16
@@ -7,7 +7,7 @@ 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";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
@@ -69,12 +69,13 @@ vi.mock("lucide-react-native", () => {
|
||||
};
|
||||
return {
|
||||
Inbox: icon("Inbox"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||
LoadingSpinner: () =>
|
||||
React.createElement("span", { "data-testid": "workspace-import-loading-spinner" }),
|
||||
React.createElement("span", { "data-testid": "import-session-loading-spinner" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/segmented-control", () => ({
|
||||
@@ -111,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
|
||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
title,
|
||||
header,
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
header?: { title: string; actions?: ReactNode };
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{title}</h1>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.actions}
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
@@ -157,6 +159,8 @@ 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;
|
||||
@@ -179,15 +183,18 @@ function renderSheet(
|
||||
},
|
||||
});
|
||||
|
||||
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceImportSheet
|
||||
<ImportSessionSheet
|
||||
visible={options?.visible ?? true}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceDirectory="/repo/paseo"
|
||||
cwd={cwd}
|
||||
onClose={options?.onClose ?? vi.fn()}
|
||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||
onImported={options?.onImported}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -265,7 +272,7 @@ function createSnapshotEntry(
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceImportSheet", () => {
|
||||
describe("ImportSessionSheet", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
@@ -431,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
|
||||
function TestSheet({ visible }: { visible: boolean }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceImportSheet
|
||||
<ImportSessionSheet
|
||||
visible={visible}
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
workspaceDirectory="/repo/paseo"
|
||||
cwd="/repo/paseo"
|
||||
onClose={vi.fn()}
|
||||
onImportedAgent={vi.fn()}
|
||||
/>
|
||||
@@ -483,7 +490,7 @@ describe("WorkspaceImportSheet", () => {
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
@@ -519,7 +526,7 @@ describe("WorkspaceImportSheet", () => {
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workspace-import-session-claude-provider-thread-1"));
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
|
||||
await screen.findByText("Could not import selected session.");
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
@@ -665,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
|
||||
fireEvent.click(screen.getByTestId("workspace-import-filter-codex"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-codex"));
|
||||
|
||||
screen.getByText("Session codex");
|
||||
expect(screen.queryByText("Session claude")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workspace-import-filter-all"));
|
||||
fireEvent.click(screen.getByTestId("import-session-filter-all"));
|
||||
|
||||
screen.getByText("Session claude");
|
||||
screen.getByText("Session codex");
|
||||
@@ -702,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
|
||||
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 () => {
|
||||
@@ -731,4 +738,119 @@ describe("WorkspaceImportSheet", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+108
-31
@@ -4,7 +4,7 @@ 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 } from "lucide-react-native";
|
||||
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";
|
||||
@@ -14,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
|
||||
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
|
||||
const PER_PROVIDER_LIMIT = 15;
|
||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||
@@ -25,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
|
||||
"fetchRecentProviderSessions" | "importAgent"
|
||||
>;
|
||||
|
||||
interface WorkspaceImportSheetProps {
|
||||
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
|
||||
|
||||
interface ImportSessionSheetProps {
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
serverId: string | null;
|
||||
workspaceDirectory: string | null;
|
||||
cwd?: string | null;
|
||||
onClose: () => void;
|
||||
onImportedAgent: (agentId: string) => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: ImportedAgent) => void;
|
||||
}
|
||||
|
||||
type RecentSessionsResponse = Awaited<
|
||||
@@ -84,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
|
||||
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
workspaceDirectory: string | null;
|
||||
cwd: string | null | undefined;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client && workspaceDirectory);
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
queryKey: [...sessionsQueryRoot, provider],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.fetchRecentProviderSessions({
|
||||
cwd: workspaceDirectory,
|
||||
...(cwd ? { cwd } : {}),
|
||||
providers: [provider],
|
||||
limit: PER_PROVIDER_LIMIT,
|
||||
});
|
||||
@@ -186,7 +188,7 @@ function SheetStatusMessages({
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
if (!isClientReady) {
|
||||
return <Text style={styles.statusText}>Connect to a workspace to import sessions</Text>;
|
||||
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>;
|
||||
@@ -253,10 +255,39 @@ function computeEmptyState(input: EmptyStateInputs): {
|
||||
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="workspace-import-empty-state">
|
||||
<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>
|
||||
@@ -270,29 +301,31 @@ function buildProviderFilterOptions(
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): SegmentedControlOption<string>[] {
|
||||
const options: SegmentedControlOption<string>[] = [
|
||||
{ value: ALL_FILTER_VALUE, label: "All", testID: "workspace-import-filter-all" },
|
||||
{ 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: `workspace-import-filter-${provider}`,
|
||||
testID: `import-session-filter-${provider}`,
|
||||
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function WorkspaceImportSheetRow({
|
||||
function ImportSessionSheetRow({
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
showCwd,
|
||||
onImportSession,
|
||||
}: {
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: boolean;
|
||||
showCwd: boolean;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -323,7 +356,7 @@ function WorkspaceImportSheetRow({
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
style={pressableStyle}
|
||||
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
|
||||
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
|
||||
>
|
||||
<View style={styles.rowIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
@@ -338,19 +371,25 @@ function WorkspaceImportSheetRow({
|
||||
<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 WorkspaceImportSheet({
|
||||
export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
serverId,
|
||||
workspaceDirectory,
|
||||
cwd,
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
}: WorkspaceImportSheetProps) {
|
||||
onImported,
|
||||
}: ImportSessionSheetProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||
@@ -368,8 +407,8 @@ export function WorkspaceImportSheet({
|
||||
);
|
||||
|
||||
const sessionsQueryRoot = useMemo(
|
||||
() => ["recent-provider-sessions", workspaceDirectory] as const,
|
||||
[workspaceDirectory],
|
||||
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||
[cwd],
|
||||
);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
@@ -379,9 +418,9 @@ export function WorkspaceImportSheet({
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
workspaceDirectory,
|
||||
cwd,
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
@@ -417,20 +456,25 @@ export function WorkspaceImportSheet({
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
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: workspaceDirectory,
|
||||
cwd: effectiveCwd,
|
||||
});
|
||||
return agent;
|
||||
},
|
||||
onSuccess: async (agent) => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
onClose();
|
||||
onImportedAgent(agent.id);
|
||||
onImportedAgent?.(agent.id);
|
||||
onImported?.(agent);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -451,6 +495,20 @@ export function WorkspaceImportSheet({
|
||||
[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;
|
||||
@@ -478,8 +536,8 @@ export function WorkspaceImportSheet({
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={IMPORT_SESSION_HEADER}
|
||||
testID="workspace-import-sheet"
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
@@ -490,7 +548,7 @@ export function WorkspaceImportSheet({
|
||||
contentContainerStyle={styles.filterRow}
|
||||
>
|
||||
<SegmentedControl
|
||||
testID="workspace-import-filters"
|
||||
testID="import-session-filters"
|
||||
size="sm"
|
||||
options={filterOptions}
|
||||
value={selectedProvider}
|
||||
@@ -499,7 +557,7 @@ export function WorkspaceImportSheet({
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client && workspaceDirectory)}
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
@@ -510,11 +568,12 @@ export function WorkspaceImportSheet({
|
||||
{visibleEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<WorkspaceImportSheetRow
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={importMutation.isPending}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
showCwd={!cwd}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
))}
|
||||
@@ -580,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
rowCwd: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -606,4 +669,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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",
|
||||
},
|
||||
}));
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
@@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
interface CommandCenterActionDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
icon?: "plus" | "settings" | "home";
|
||||
actionId?: string;
|
||||
keywords: string[];
|
||||
routeKind: "settings" | "none";
|
||||
routeKind: "settings" | "home" | "none";
|
||||
}
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
routeKind: "none",
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
title: "Home",
|
||||
icon: "home",
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
routeKind: "home",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
@@ -89,7 +96,7 @@ export interface CommandCenterActionItem {
|
||||
kind: "action";
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
icon?: "plus" | "settings" | "home";
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
}
|
||||
@@ -155,21 +162,32 @@ export function useCommandCenter() {
|
||||
return buildSettingsRoute();
|
||||
}, []);
|
||||
|
||||
const homeRoute = useMemo<Href | undefined>(() => {
|
||||
if (!routeActiveServerId) return undefined;
|
||||
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
|
||||
}, [routeActiveServerId]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
if (!open) {
|
||||
return EMPTY_ACTION_ITEMS;
|
||||
}
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
matchesActionQuery(query, action),
|
||||
).map<CommandCenterActionItem>((action) => ({
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
}));
|
||||
}, [open, query, settingsRoute, overrides]);
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) => {
|
||||
if (action.routeKind === "home" && !homeRoute) return false;
|
||||
return matchesActionQuery(query, action);
|
||||
}).map<CommandCenterActionItem>((action) => {
|
||||
let route: Href | undefined;
|
||||
if (action.routeKind === "settings") route = settingsRoute;
|
||||
else if (action.routeKind === "home") route = homeRoute;
|
||||
return {
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route,
|
||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||
};
|
||||
});
|
||||
}, [open, query, settingsRoute, homeRoute, overrides]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { FolderOpen, Smartphone } from "lucide-react-native";
|
||||
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useRouter } from "expo-router";
|
||||
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHasWorkspaces } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useIsCompactFormFactor,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -18,14 +16,21 @@ import {
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import type { Href } from "expo-router";
|
||||
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const router = useRouter();
|
||||
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
||||
const hasProjects = useHasWorkspaces(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const openProject = useOpenProject(serverId);
|
||||
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
|
||||
@@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
||||
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
||||
|
||||
const handleOpenImportSession = useCallback(() => setIsImportSheetOpen(true), []);
|
||||
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
|
||||
|
||||
const handleImported = useCallback(
|
||||
(agent: { id: string; cwd: string }) => {
|
||||
void (async () => {
|
||||
await openProject(agent.cwd);
|
||||
router.push(buildHostAgentDetailRoute(serverId, agent.id) as Href);
|
||||
})();
|
||||
},
|
||||
[openProject, router, serverId],
|
||||
);
|
||||
|
||||
const handleOpenProviders = useCallback(() => {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
}, [router, serverId]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<View style={styles.logo}>
|
||||
<PaseoLogo size={56} />
|
||||
<PaseoLogo size={52} />
|
||||
</View>
|
||||
<View style={styles.headingGroup}>
|
||||
<Text style={styles.heading}>What shall we build today?</Text>
|
||||
{hasHydrated && !hasProjects ? (
|
||||
<Text style={styles.subtitle}>
|
||||
Add a project folder to start running agents on your codebase
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.cta}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftIcon={FolderOpen}
|
||||
<View style={styles.tiles}>
|
||||
<HomeTile
|
||||
icon={FolderOpen}
|
||||
title="Add a project"
|
||||
description="Open a folder on your machine"
|
||||
onPress={handleOpenPicker}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
Add a project
|
||||
</Button>
|
||||
accent
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Inbox}
|
||||
title="Import session"
|
||||
description="Bring in recent CLI sessions"
|
||||
onPress={handleOpenImportSession}
|
||||
testID="open-project-import-session"
|
||||
/>
|
||||
<HomeTile
|
||||
icon={Plug}
|
||||
title="Setup providers"
|
||||
description="Configure Claude Code, Codex, and more"
|
||||
onPress={handleOpenProviders}
|
||||
testID="open-project-setup-providers"
|
||||
/>
|
||||
{isLocalDaemon ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={Smartphone}
|
||||
<HomeTile
|
||||
icon={Smartphone}
|
||||
title="Pair device"
|
||||
description="Connect your phone to this daemon"
|
||||
onPress={handleOpenPairDevice}
|
||||
testID="open-project-pair-device"
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
onClose={handleClosePairDevice}
|
||||
testID="open-project-pair-device-modal"
|
||||
/>
|
||||
<ImportSessionSheet
|
||||
visible={isImportSheetOpen}
|
||||
client={client}
|
||||
serverId={serverId}
|
||||
onClose={handleCloseImportSession}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface HomeTileProps {
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
onPress: () => void;
|
||||
testID?: string;
|
||||
accent?: boolean;
|
||||
}
|
||||
|
||||
function HomeTile({ icon: Icon, title, description, onPress, testID, accent }: HomeTileProps) {
|
||||
// useUnistyles is acceptable here: leaf component, off the hot path (home screen renders once).
|
||||
const { theme } = useUnistyles();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setHovered(false), []);
|
||||
|
||||
const iconColor = accent ? theme.colors.accent : theme.colors.foregroundMuted;
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => [
|
||||
styles.tile,
|
||||
hovered && styles.tileHovered,
|
||||
pressed && styles.tilePressed,
|
||||
],
|
||||
[hovered],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
testID={testID}
|
||||
style={pressableStyle}
|
||||
>
|
||||
<Icon size={20} color={iconColor} />
|
||||
<View style={styles.tileText}>
|
||||
<Text style={styles.tileTitle}>{title}</Text>
|
||||
<Text style={styles.tileDescription}>{description}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
content: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
justifyContent: { xs: "flex-start", md: "center" },
|
||||
alignItems: "center",
|
||||
gap: 0,
|
||||
padding: theme.spacing[6],
|
||||
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
|
||||
paddingBottom: {
|
||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||
@@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({
|
||||
logo: {
|
||||
marginBottom: theme.spacing[8],
|
||||
},
|
||||
headingGroup: {
|
||||
alignItems: "center",
|
||||
tiles: {
|
||||
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
|
||||
width: "100%",
|
||||
maxWidth: 452,
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
cta: {
|
||||
marginTop: theme.spacing[12],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
tile: {
|
||||
width: { xs: "100%", md: 220 },
|
||||
minHeight: { xs: 0, md: 132 },
|
||||
padding: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
heading: {
|
||||
tileHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
tilePressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
tileText: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
tileTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize["2xl"],
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
textAlign: "center",
|
||||
},
|
||||
subtitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tileDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,7 +59,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 { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<WorkspaceImportSheet
|
||||
<ImportSessionSheet
|
||||
visible={isImportSheetVisible}
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
workspaceDirectory={workspaceDirectory}
|
||||
cwd={workspaceDirectory}
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
|
||||
@@ -3389,6 +3389,7 @@ export class Session {
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
|
||||
await this.forwardAgentUpdate(snapshot);
|
||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||
this.emit({
|
||||
@@ -7143,6 +7144,20 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
|
||||
try {
|
||||
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
await this.describeWorkspaceRecord(workspace);
|
||||
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, cwd },
|
||||
"Failed to register workspace for imported agent",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOpenProjectRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -66,6 +66,20 @@ interface SessionTestAccess {
|
||||
agentManager: {
|
||||
listAgents(): unknown[];
|
||||
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
|
||||
findPersistedAgent(
|
||||
provider: string,
|
||||
providerHandleId: string,
|
||||
options?: unknown,
|
||||
): Promise<PersistedAgentDescriptor | null>;
|
||||
resumeAgentFromPersistence(
|
||||
handle: unknown,
|
||||
overrides?: unknown,
|
||||
preferredId?: string,
|
||||
extras?: unknown,
|
||||
): Promise<unknown>;
|
||||
hydrateTimelineFromProvider(agentId: string): Promise<unknown>;
|
||||
getTimeline(agentId: string): readonly unknown[];
|
||||
setTitle(agentId: string, title: string): Promise<unknown>;
|
||||
};
|
||||
workspaceRegistry: {
|
||||
list(...args: unknown[]): Promise<unknown[]>;
|
||||
@@ -2660,6 +2674,105 @@ test("open_project_request registers a workspace before any agent exists", async
|
||||
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
|
||||
});
|
||||
|
||||
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
},
|
||||
});
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const importedCwd = path.resolve("/tmp/imported-project");
|
||||
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
workspaces.get(workspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
};
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "imported",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
});
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: "imported-agent",
|
||||
cwd: importedCwd,
|
||||
lifecycle: "idle",
|
||||
updatedAt: "2026-05-21T00:00:00.000Z",
|
||||
});
|
||||
session.agentManager.listAgents = () => [managed];
|
||||
session.agentManager.findPersistedAgent = async () => null;
|
||||
session.agentManager.resumeAgentFromPersistence = async () => managed;
|
||||
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
||||
session.agentManager.getTimeline = () => [];
|
||||
session.agentManager.setTitle = async () => undefined;
|
||||
session.agentStorage.list = async () => [];
|
||||
session.agentStorage.get = async () => null;
|
||||
session.forwardAgentUpdate = async () => undefined;
|
||||
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-import",
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
lastEmittedByWorkspaceId: new Map(),
|
||||
};
|
||||
session.buildWorkspaceDescriptorMap = async () =>
|
||||
new Map([
|
||||
[
|
||||
importedCwd,
|
||||
{
|
||||
id: importedCwd,
|
||||
projectId: importedCwd,
|
||||
projectDisplayName: "imported-project",
|
||||
projectRootPath: importedCwd,
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "imported-project",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "import_agent_request",
|
||||
requestId: "req-import",
|
||||
providerId: "codex",
|
||||
providerHandleId: "session-xyz",
|
||||
cwd: importedCwd,
|
||||
});
|
||||
|
||||
expect(workspaces.get(importedCwd)).toBeTruthy();
|
||||
const workspaceUpdates = filterByType(emitted, "workspace_update");
|
||||
expect(workspaceUpdates.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
workspaceUpdates.some(
|
||||
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
Reference in New Issue
Block a user