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,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
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} />;
|
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
|
||||||
} else if (action.icon === "settings") {
|
} else if (action.icon === "settings") {
|
||||||
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
|
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(
|
const titleStyle = useMemo(
|
||||||
() => [styles.title, { color: theme.colors.foreground }],
|
() => [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 { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
|
||||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
@@ -69,12 +69,13 @@ vi.mock("lucide-react-native", () => {
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
Inbox: icon("Inbox"),
|
Inbox: icon("Inbox"),
|
||||||
|
RotateCw: icon("RotateCw"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/components/ui/loading-spinner", () => ({
|
vi.mock("@/components/ui/loading-spinner", () => ({
|
||||||
LoadingSpinner: () =>
|
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", () => ({
|
vi.mock("@/components/ui/segmented-control", () => ({
|
||||||
@@ -111,18 +112,19 @@ vi.mock("@/components/ui/segmented-control", () => ({
|
|||||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||||
AdaptiveModalSheet: ({
|
AdaptiveModalSheet: ({
|
||||||
visible,
|
visible,
|
||||||
title,
|
header,
|
||||||
children,
|
children,
|
||||||
testID,
|
testID,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
header?: { title: string; actions?: ReactNode };
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}) =>
|
}) =>
|
||||||
visible ? (
|
visible ? (
|
||||||
<section data-testid={testID}>
|
<section data-testid={testID}>
|
||||||
<h1>{title}</h1>
|
<h1>{header?.title}</h1>
|
||||||
|
{header?.actions}
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
) : null,
|
) : null,
|
||||||
@@ -157,6 +159,8 @@ interface RenderOptions {
|
|||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onImportedAgent?: (agentId: string) => void;
|
onImportedAgent?: (agentId: string) => void;
|
||||||
|
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
||||||
|
cwd?: string | null;
|
||||||
snapshot?: {
|
snapshot?: {
|
||||||
entries?: ProviderSnapshotEntry[];
|
entries?: ProviderSnapshotEntry[];
|
||||||
supportsSnapshot?: boolean;
|
supportsSnapshot?: boolean;
|
||||||
@@ -179,15 +183,18 @@ function renderSheet(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cwd = options && "cwd" in options ? (options.cwd ?? undefined) : "/repo/paseo";
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WorkspaceImportSheet
|
<ImportSessionSheet
|
||||||
visible={options?.visible ?? true}
|
visible={options?.visible ?? true}
|
||||||
client={client}
|
client={client}
|
||||||
serverId="server-1"
|
serverId="server-1"
|
||||||
workspaceDirectory="/repo/paseo"
|
cwd={cwd}
|
||||||
onClose={options?.onClose ?? vi.fn()}
|
onClose={options?.onClose ?? vi.fn()}
|
||||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||||
|
onImported={options?.onImported}
|
||||||
/>
|
/>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
@@ -265,7 +272,7 @@ function createSnapshotEntry(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WorkspaceImportSheet", () => {
|
describe("ImportSessionSheet", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -431,11 +438,11 @@ describe("WorkspaceImportSheet", () => {
|
|||||||
function TestSheet({ visible }: { visible: boolean }) {
|
function TestSheet({ visible }: { visible: boolean }) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WorkspaceImportSheet
|
<ImportSessionSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
client={client}
|
client={client}
|
||||||
serverId="server-1"
|
serverId="server-1"
|
||||||
workspaceDirectory="/repo/paseo"
|
cwd="/repo/paseo"
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
onImportedAgent={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(() => {
|
await waitFor(() => {
|
||||||
expect(importAgent).toHaveBeenCalledWith({
|
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.");
|
await screen.findByText("Could not import selected session.");
|
||||||
expect(importAgent).toHaveBeenCalledWith({
|
expect(importAgent).toHaveBeenCalledWith({
|
||||||
@@ -665,12 +672,12 @@ describe("WorkspaceImportSheet", () => {
|
|||||||
await screen.findByText("Session claude");
|
await screen.findByText("Session claude");
|
||||||
await screen.findByText("Session codex");
|
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");
|
screen.getByText("Session codex");
|
||||||
expect(screen.queryByText("Session claude")).toBeNull();
|
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 claude");
|
||||||
screen.getByText("Session codex");
|
screen.getByText("Session codex");
|
||||||
@@ -702,8 +709,8 @@ describe("WorkspaceImportSheet", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
expect(screen.queryByTestId("workspace-import-filters")).toBeNull();
|
expect(screen.queryByTestId("import-session-filters")).toBeNull();
|
||||||
expect(screen.queryByTestId("workspace-import-filter-all")).toBeNull();
|
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
|
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.");
|
await screen.findByText("No importable providers are enabled.");
|
||||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
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 { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
@@ -14,7 +14,6 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
|
|
||||||
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||||
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
|
|
||||||
const PER_PROVIDER_LIMIT = 15;
|
const PER_PROVIDER_LIMIT = 15;
|
||||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||||
@@ -25,13 +24,16 @@ type RecentProviderSessionsClient = Pick<
|
|||||||
"fetchRecentProviderSessions" | "importAgent"
|
"fetchRecentProviderSessions" | "importAgent"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface WorkspaceImportSheetProps {
|
type ImportedAgent = Awaited<ReturnType<RecentProviderSessionsClient["importAgent"]>>;
|
||||||
|
|
||||||
|
interface ImportSessionSheetProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
client: RecentProviderSessionsClient | null;
|
client: RecentProviderSessionsClient | null;
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
workspaceDirectory: string | null;
|
cwd?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onImportedAgent: (agentId: string) => void;
|
onImportedAgent?: (agentId: string) => void;
|
||||||
|
onImported?: (agent: ImportedAgent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecentSessionsResponse = Awaited<
|
type RecentSessionsResponse = Awaited<
|
||||||
@@ -84,20 +86,20 @@ function buildSessionsQueriesConfig(args: {
|
|||||||
sessionsQueryRoot: ReadonlyArray<string | null>;
|
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
client: RecentProviderSessionsClient | null;
|
client: RecentProviderSessionsClient | null;
|
||||||
workspaceDirectory: string | null;
|
cwd: string | null | undefined;
|
||||||
}): SessionsQueryConfig[] {
|
}): SessionsQueryConfig[] {
|
||||||
const { providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory } = args;
|
const { providersToFetch, sessionsQueryRoot, visible, client, cwd } = args;
|
||||||
if (providersToFetch === null) return [];
|
if (providersToFetch === null) return [];
|
||||||
const enabled = visible && Boolean(client && workspaceDirectory);
|
const enabled = visible && Boolean(client);
|
||||||
return providersToFetch.map((provider) => ({
|
return providersToFetch.map((provider) => ({
|
||||||
queryKey: [...sessionsQueryRoot, provider],
|
queryKey: [...sessionsQueryRoot, provider],
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.fetchRecentProviderSessions({
|
return await client.fetchRecentProviderSessions({
|
||||||
cwd: workspaceDirectory,
|
...(cwd ? { cwd } : {}),
|
||||||
providers: [provider],
|
providers: [provider],
|
||||||
limit: PER_PROVIDER_LIMIT,
|
limit: PER_PROVIDER_LIMIT,
|
||||||
});
|
});
|
||||||
@@ -186,7 +188,7 @@ function SheetStatusMessages({
|
|||||||
}: SheetStatusMessagesProps) {
|
}: SheetStatusMessagesProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
if (!isClientReady) {
|
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) {
|
if (isSnapshotUnsupported) {
|
||||||
return <Text style={styles.statusText}>Update the host to import sessions.</Text>;
|
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." };
|
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 }) {
|
function SheetEmptyState({ title }: { title: string }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState} testID="workspace-import-empty-state">
|
<View style={styles.emptyState} testID="import-session-empty-state">
|
||||||
<View style={styles.emptyStateIcon}>
|
<View style={styles.emptyStateIcon}>
|
||||||
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
|
<Inbox size={theme.iconSize.lg} color={theme.colors.foregroundMuted} strokeWidth={1.5} />
|
||||||
</View>
|
</View>
|
||||||
@@ -270,29 +301,31 @@ function buildProviderFilterOptions(
|
|||||||
providerLabelById: ReadonlyMap<string, string>,
|
providerLabelById: ReadonlyMap<string, string>,
|
||||||
): SegmentedControlOption<string>[] {
|
): SegmentedControlOption<string>[] {
|
||||||
const options: 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) {
|
for (const provider of providers) {
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const ProviderIcon = getProviderIcon(provider);
|
||||||
options.push({
|
options.push({
|
||||||
value: provider,
|
value: provider,
|
||||||
label: providerLabelById.get(provider) ?? provider,
|
label: providerLabelById.get(provider) ?? provider,
|
||||||
testID: `workspace-import-filter-${provider}`,
|
testID: `import-session-filter-${provider}`,
|
||||||
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
icon: ({ color, size }) => <ProviderIcon color={color} size={size} />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceImportSheetRow({
|
function ImportSessionSheetRow({
|
||||||
entry,
|
entry,
|
||||||
disabled,
|
disabled,
|
||||||
importing,
|
importing,
|
||||||
|
showCwd,
|
||||||
onImportSession,
|
onImportSession,
|
||||||
}: {
|
}: {
|
||||||
entry: FetchRecentProviderSessionEntry;
|
entry: FetchRecentProviderSessionEntry;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
importing: boolean;
|
importing: boolean;
|
||||||
|
showCwd: boolean;
|
||||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -323,7 +356,7 @@ function WorkspaceImportSheetRow({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityState={accessibilityState}
|
accessibilityState={accessibilityState}
|
||||||
style={pressableStyle}
|
style={pressableStyle}
|
||||||
testID={`workspace-import-session-${entry.providerId}-${entry.providerHandleId}`}
|
testID={`import-session-session-${entry.providerId}-${entry.providerHandleId}`}
|
||||||
>
|
>
|
||||||
<View style={styles.rowIconWrap}>
|
<View style={styles.rowIconWrap}>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
@@ -338,19 +371,25 @@ function WorkspaceImportSheetRow({
|
|||||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||||
{promptPreview}
|
{promptPreview}
|
||||||
</Text>
|
</Text>
|
||||||
|
{showCwd && entry.cwd ? (
|
||||||
|
<Text style={styles.rowCwd} numberOfLines={1}>
|
||||||
|
{entry.cwd}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceImportSheet({
|
export function ImportSessionSheet({
|
||||||
visible,
|
visible,
|
||||||
client,
|
client,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceDirectory,
|
cwd,
|
||||||
onClose,
|
onClose,
|
||||||
onImportedAgent,
|
onImportedAgent,
|
||||||
}: WorkspaceImportSheetProps) {
|
onImported,
|
||||||
|
}: ImportSessionSheetProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||||
@@ -368,8 +407,8 @@ export function WorkspaceImportSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sessionsQueryRoot = useMemo(
|
const sessionsQueryRoot = useMemo(
|
||||||
() => ["recent-provider-sessions", workspaceDirectory] as const,
|
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||||
[workspaceDirectory],
|
[cwd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queriesConfig = useMemo(
|
const queriesConfig = useMemo(
|
||||||
@@ -379,9 +418,9 @@ export function WorkspaceImportSheet({
|
|||||||
sessionsQueryRoot,
|
sessionsQueryRoot,
|
||||||
visible,
|
visible,
|
||||||
client,
|
client,
|
||||||
workspaceDirectory,
|
cwd,
|
||||||
}),
|
}),
|
||||||
[providersToFetch, sessionsQueryRoot, visible, client, workspaceDirectory],
|
[providersToFetch, sessionsQueryRoot, visible, client, cwd],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queries = useQueries({ queries: queriesConfig });
|
const queries = useQueries({ queries: queriesConfig });
|
||||||
@@ -417,20 +456,25 @@ export function WorkspaceImportSheet({
|
|||||||
|
|
||||||
const importMutation = useMutation({
|
const importMutation = useMutation({
|
||||||
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
mutationFn: async (entry: FetchRecentProviderSessionEntry) => {
|
||||||
if (!client || !workspaceDirectory) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
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({
|
const agent = await client.importAgent({
|
||||||
providerId: entry.providerId,
|
providerId: entry.providerId,
|
||||||
providerHandleId: entry.providerHandleId,
|
providerHandleId: entry.providerHandleId,
|
||||||
cwd: workspaceDirectory,
|
cwd: effectiveCwd,
|
||||||
});
|
});
|
||||||
return agent;
|
return agent;
|
||||||
},
|
},
|
||||||
onSuccess: async (agent) => {
|
onSuccess: async (agent) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||||
onClose();
|
onClose();
|
||||||
onImportedAgent(agent.id);
|
onImportedAgent?.(agent.id);
|
||||||
|
onImported?.(agent);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -451,6 +495,20 @@ export function WorkspaceImportSheet({
|
|||||||
[queries, providersToFetch, providerLabelById],
|
[queries, providersToFetch, providerLabelById],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isRefreshing = queries.some((query) => query.isFetching);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||||
|
}, [queryClient, sessionsQueryRoot]);
|
||||||
|
|
||||||
|
const header = useMemo<SheetHeader>(
|
||||||
|
() => ({
|
||||||
|
title: "Import session",
|
||||||
|
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||||
|
}),
|
||||||
|
[isRefreshing, handleRefresh],
|
||||||
|
);
|
||||||
|
|
||||||
const isSnapshotUnsupported = !supportsSnapshot;
|
const isSnapshotUnsupported = !supportsSnapshot;
|
||||||
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
|
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
|
||||||
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
|
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
|
||||||
@@ -478,8 +536,8 @@ export function WorkspaceImportSheet({
|
|||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
header={IMPORT_SESSION_HEADER}
|
header={header}
|
||||||
testID="workspace-import-sheet"
|
testID="import-session-sheet"
|
||||||
desktopMaxWidth={560}
|
desktopMaxWidth={560}
|
||||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||||
>
|
>
|
||||||
@@ -490,7 +548,7 @@ export function WorkspaceImportSheet({
|
|||||||
contentContainerStyle={styles.filterRow}
|
contentContainerStyle={styles.filterRow}
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
testID="workspace-import-filters"
|
testID="import-session-filters"
|
||||||
size="sm"
|
size="sm"
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
value={selectedProvider}
|
value={selectedProvider}
|
||||||
@@ -499,7 +557,7 @@ export function WorkspaceImportSheet({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
) : null}
|
||||||
<SheetStatusMessages
|
<SheetStatusMessages
|
||||||
isClientReady={Boolean(client && workspaceDirectory)}
|
isClientReady={Boolean(client)}
|
||||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||||
hasNoImportableProviders={hasNoImportableProviders}
|
hasNoImportableProviders={hasNoImportableProviders}
|
||||||
isLoadingSessions={isLoadingSessions}
|
isLoadingSessions={isLoadingSessions}
|
||||||
@@ -510,11 +568,12 @@ export function WorkspaceImportSheet({
|
|||||||
{visibleEntries.length > 0 ? (
|
{visibleEntries.length > 0 ? (
|
||||||
<View style={styles.list}>
|
<View style={styles.list}>
|
||||||
{visibleEntries.map((entry) => (
|
{visibleEntries.map((entry) => (
|
||||||
<WorkspaceImportSheetRow
|
<ImportSessionSheetRow
|
||||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
disabled={importMutation.isPending}
|
disabled={importMutation.isPending}
|
||||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||||
|
showCwd={!cwd}
|
||||||
onImportSession={handleImportSession}
|
onImportSession={handleImportSession}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -580,6 +639,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
rowCwd: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
},
|
||||||
statusRow: {
|
statusRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -606,4 +669,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
textAlign: "center",
|
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,
|
clearCommandCenterFocusRestoreElement,
|
||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} 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 type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
@@ -52,10 +52,10 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
|||||||
interface CommandCenterActionDefinition {
|
interface CommandCenterActionDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon?: "plus" | "settings";
|
icon?: "plus" | "settings" | "home";
|
||||||
actionId?: string;
|
actionId?: string;
|
||||||
keywords: string[];
|
keywords: string[];
|
||||||
routeKind: "settings" | "none";
|
routeKind: "settings" | "home" | "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||||
@@ -67,6 +67,13 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
|||||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||||
routeKind: "none",
|
routeKind: "none",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "home",
|
||||||
|
title: "Home",
|
||||||
|
icon: "home",
|
||||||
|
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||||
|
routeKind: "home",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -89,7 +96,7 @@ export interface CommandCenterActionItem {
|
|||||||
kind: "action";
|
kind: "action";
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon?: "plus" | "settings";
|
icon?: "plus" | "settings" | "home";
|
||||||
route?: Href;
|
route?: Href;
|
||||||
shortcutKeys?: ShortcutKey[][];
|
shortcutKeys?: ShortcutKey[][];
|
||||||
}
|
}
|
||||||
@@ -155,21 +162,32 @@ export function useCommandCenter() {
|
|||||||
return buildSettingsRoute();
|
return buildSettingsRoute();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const homeRoute = useMemo<Href | undefined>(() => {
|
||||||
|
if (!routeActiveServerId) return undefined;
|
||||||
|
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
|
||||||
|
}, [routeActiveServerId]);
|
||||||
|
|
||||||
const actionItems = useMemo(() => {
|
const actionItems = useMemo(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return EMPTY_ACTION_ITEMS;
|
return EMPTY_ACTION_ITEMS;
|
||||||
}
|
}
|
||||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
return COMMAND_CENTER_ACTIONS.filter((action) => {
|
||||||
matchesActionQuery(query, action),
|
if (action.routeKind === "home" && !homeRoute) return false;
|
||||||
).map<CommandCenterActionItem>((action) => ({
|
return matchesActionQuery(query, action);
|
||||||
kind: "action",
|
}).map<CommandCenterActionItem>((action) => {
|
||||||
id: action.id,
|
let route: Href | undefined;
|
||||||
title: action.title,
|
if (action.routeKind === "settings") route = settingsRoute;
|
||||||
icon: action.icon,
|
else if (action.routeKind === "home") route = homeRoute;
|
||||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
return {
|
||||||
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
kind: "action",
|
||||||
}));
|
id: action.id,
|
||||||
}, [open, query, settingsRoute, overrides]);
|
title: action.title,
|
||||||
|
icon: action.icon,
|
||||||
|
route,
|
||||||
|
shortcutKeys: resolveActionShortcutKeys(action.actionId, overrides),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [open, query, settingsRoute, homeRoute, overrides]);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type ComponentType } from "react";
|
||||||
import { View, Text } from "react-native";
|
import { View, Text, Pressable } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { FolderOpen, Smartphone } from "lucide-react-native";
|
import { useRouter } from "expo-router";
|
||||||
|
import { FolderOpen, Inbox, Plug, Smartphone } from "lucide-react-native";
|
||||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { useHasWorkspaces } from "@/stores/session-store-hooks";
|
|
||||||
import {
|
import {
|
||||||
useIsCompactFormFactor,
|
useIsCompactFormFactor,
|
||||||
HEADER_INNER_HEIGHT,
|
HEADER_INNER_HEIGHT,
|
||||||
@@ -18,14 +16,21 @@ import {
|
|||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
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 }) {
|
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||||
|
const router = useRouter();
|
||||||
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
||||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||||
const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false);
|
|
||||||
const hasProjects = useHasWorkspaces(serverId);
|
|
||||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const openProject = useOpenProject(serverId);
|
||||||
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||||
|
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
|
||||||
@@ -42,40 +47,62 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
||||||
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<MenuHeader borderless />
|
<MenuHeader borderless />
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
<View style={styles.logo}>
|
<View style={styles.logo}>
|
||||||
<PaseoLogo size={56} />
|
<PaseoLogo size={52} />
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headingGroup}>
|
<View style={styles.tiles}>
|
||||||
<Text style={styles.heading}>What shall we build today?</Text>
|
<HomeTile
|
||||||
{hasHydrated && !hasProjects ? (
|
icon={FolderOpen}
|
||||||
<Text style={styles.subtitle}>
|
title="Add a project"
|
||||||
Add a project folder to start running agents on your codebase
|
description="Open a folder on your machine"
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={styles.cta}>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftIcon={FolderOpen}
|
|
||||||
onPress={handleOpenPicker}
|
onPress={handleOpenPicker}
|
||||||
testID="open-project-submit"
|
testID="open-project-submit"
|
||||||
>
|
accent
|
||||||
Add a project
|
/>
|
||||||
</Button>
|
<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 ? (
|
{isLocalDaemon ? (
|
||||||
<Button
|
<HomeTile
|
||||||
variant="outline"
|
icon={Smartphone}
|
||||||
leftIcon={Smartphone}
|
title="Pair device"
|
||||||
|
description="Connect your phone to this daemon"
|
||||||
onPress={handleOpenPairDevice}
|
onPress={handleOpenPairDevice}
|
||||||
testID="open-project-pair-device"
|
testID="open-project-pair-device"
|
||||||
>
|
/>
|
||||||
Pair device
|
|
||||||
</Button>
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -84,10 +111,61 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
|||||||
onClose={handleClosePairDevice}
|
onClose={handleClosePairDevice}
|
||||||
testID="open-project-pair-device-modal"
|
testID="open-project-pair-device-modal"
|
||||||
/>
|
/>
|
||||||
|
<ImportSessionSheet
|
||||||
|
visible={isImportSheetOpen}
|
||||||
|
client={client}
|
||||||
|
serverId={serverId}
|
||||||
|
onClose={handleCloseImportSession}
|
||||||
|
onImported={handleImported}
|
||||||
|
/>
|
||||||
</View>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -97,10 +175,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
content: {
|
content: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: "center",
|
justifyContent: { xs: "flex-start", md: "center" },
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 0,
|
gap: 0,
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
|
paddingTop: { xs: theme.spacing[12], md: theme.spacing[6] },
|
||||||
paddingBottom: {
|
paddingBottom: {
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
xs: HEADER_INNER_HEIGHT_MOBILE + HEADER_TOP_PADDING_MOBILE + theme.spacing[6],
|
||||||
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
md: HEADER_INNER_HEIGHT + theme.spacing[6],
|
||||||
@@ -109,25 +188,43 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
logo: {
|
logo: {
|
||||||
marginBottom: theme.spacing[8],
|
marginBottom: theme.spacing[8],
|
||||||
},
|
},
|
||||||
headingGroup: {
|
tiles: {
|
||||||
alignItems: "center",
|
marginTop: { xs: theme.spacing[6], md: theme.spacing[12] },
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 452,
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "flex-start",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
cta: {
|
tile: {
|
||||||
marginTop: theme.spacing[12],
|
width: { xs: "100%", md: 220 },
|
||||||
flexDirection: "row",
|
minHeight: { xs: 0, md: 132 },
|
||||||
alignItems: "center",
|
padding: theme.spacing[4],
|
||||||
gap: theme.spacing[2],
|
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,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize["2xl"],
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.base,
|
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 { WorkspaceGitActions } from "@/git/workspace-actions";
|
||||||
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
|
||||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-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 { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
@@ -3358,11 +3358,11 @@ function WorkspaceScreenContent({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<WorkspaceImportSheet
|
<ImportSessionSheet
|
||||||
visible={isImportSheetVisible}
|
visible={isImportSheetVisible}
|
||||||
client={client}
|
client={client}
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
workspaceDirectory={workspaceDirectory}
|
cwd={workspaceDirectory}
|
||||||
onClose={closeImportSheet}
|
onClose={closeImportSheet}
|
||||||
onImportedAgent={handleImportedAgent}
|
onImportedAgent={handleImportedAgent}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3389,6 +3389,7 @@ export class Session {
|
|||||||
paseoHome: this.paseoHome,
|
paseoHome: this.paseoHome,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
|
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||||
this.emit({
|
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(
|
private async handleOpenProjectRequest(
|
||||||
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ interface SessionTestAccess {
|
|||||||
agentManager: {
|
agentManager: {
|
||||||
listAgents(): unknown[];
|
listAgents(): unknown[];
|
||||||
listImportablePersistedAgents(options?: unknown): Promise<PersistedAgentDescriptor[]>;
|
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: {
|
workspaceRegistry: {
|
||||||
list(...args: unknown[]): Promise<unknown[]>;
|
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);
|
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 () => {
|
test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
|
|||||||
Reference in New Issue
Block a user