Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ if (projectId) {
const config: ExpoConfig = {
name: appName,
slug,
version: "0.1.2",
version: "0.1.3",
newArchEnabled: true,
platforms: ["ios", "android"],
icon: "./assets/icon.png",
Expand All @@ -54,7 +54,7 @@ const config: ExpoConfig = {
: { enabled: false },
ios: {
bundleIdentifier: iosBundleIdentifier,
buildNumber: "5",
buildNumber: "7",
supportsTablet: true,
config: {
usesNonExemptEncryption: false,
Expand All @@ -70,9 +70,10 @@ const config: ExpoConfig = {
android: {
package: androidPackage,
...(googleServicesFile ? { googleServicesFile } : {}),
versionCode: 3,
versionCode: 5,
allowBackup: false,
predictiveBackGestureEnabled: false,
softwareKeyboardLayoutMode: "resize",
adaptiveIcon: {
foregroundImage: "./assets/adaptive-icon.png",
monochromeImage: "./assets/adaptive-icon-monochrome.png",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode2-mobile/mobile",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"main": "index.ts",
"scripts": {
Expand Down
21 changes: 14 additions & 7 deletions apps/mobile/src/components/modal-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ import { palette, space, typeRamp, usesLargeTextLayout } from "../theme";
export function ModalSheet({
children,
onClose,
scrollable = true,
subtitle,
title,
visible,
}: {
children: ReactNode;
onClose: () => void;
scrollable?: boolean;
subtitle?: string;
title: string;
visible: boolean;
Expand Down Expand Up @@ -87,13 +89,17 @@ export function ModalSheet({
</Text>
</Pressable>
</View>
<ScrollView
contentContainerStyle={styles.content}
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
{scrollable ? (
<ScrollView
contentContainerStyle={styles.content}
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
) : (
<View style={styles.fixedContent}>{children}</View>
)}
</KeyboardAvoidingView>
</SafeAreaView>
</Modal>
Expand All @@ -104,6 +110,7 @@ const styles = StyleSheet.create({
closeButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.sm },
closeLabel: { color: palette.signal, fontSize: 16, fontWeight: "700" },
content: { gap: space.md, padding: space.lg, paddingBottom: space.xl },
fixedContent: { flex: 1, gap: space.md, padding: space.lg, paddingBottom: space.xl },
header: {
alignItems: "center",
borderBottomColor: palette.border,
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/navigation/root-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ jest.mock("../screens/followed-projects-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
return { FollowedProjectsScreen: () => <Text>Followed projects screen</Text> };
});
jest.mock("../screens/new-session-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
return { NewSessionScreen: () => <Text>New session screen</Text> };
});
jest.mock("./workspace-header-actions", () => ({ WorkspaceHeaderActions: () => null }));
jest.mock("../screens/workspace-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
Expand All @@ -35,10 +39,6 @@ jest.mock("../screens/workspace-screen", () => {
WorkspaceScreen: () => <Text>Workspace shell</Text>,
};
});
jest.mock("../screens/followed-projects-screen", () => {
const { Text } = jest.requireActual<typeof import("react-native")>("react-native");
return { FollowedProjectsScreen: () => <Text>Followed projects screen</Text> };
});
jest.mock("../screens/connection-screen", () => {
const { Pressable, Text } = jest.requireActual<typeof import("react-native")>("react-native");
return {
Expand Down Expand Up @@ -120,6 +120,11 @@ test("pushes session detail and presents workspace management routes over it", a
);
expect(await screen.findByText("Session screen")).toBeOnTheScreen();

act(() => navigation.navigate("NewSession"));
expect(await screen.findByText("New session screen")).toBeOnTheScreen();
act(() => navigation.goBack());
expect(await screen.findByText("Session screen")).toBeOnTheScreen();

act(() => navigation.navigate("Pending"));
expect(await screen.findByText("Pending screen")).toBeOnTheScreen();
act(() => navigation.goBack());
Expand Down
14 changes: 13 additions & 1 deletion apps/mobile/src/navigation/root-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "../screens/app-shell";
import { ConnectionScreen } from "../screens/connection-screen";
import { FollowedProjectsScreen } from "../screens/followed-projects-screen";
import { NewSessionScreen } from "../screens/new-session-screen";
import { NotificationPairingScreen } from "../screens/notification-pairing-screen";
import { SessionScreen, WorkspaceScreen } from "../screens/workspace-screen";
import { palette } from "../theme";
Expand All @@ -31,8 +32,14 @@ import { WorkspaceHeaderActions } from "./workspace-header-actions";
export type RootStackParamList = {
Connections: undefined;
FollowedProjects: undefined;
NewSession: undefined;
Pending: undefined;
Session: { connectionId: string; location: LocationRef; sessionID: string };
Session: {
connectionId: string;
focusComposer?: boolean;
location: LocationRef;
sessionID: string;
};
Settings: undefined;
Workspace: undefined;
};
Expand Down Expand Up @@ -141,6 +148,11 @@ export function RootNavigation() {
title: "Session",
})}
/>
<Stack.Screen
component={NewSessionScreen}
name="NewSession"
options={{ headerShown: false, presentation: "modal", title: "New session" }}
/>
<Stack.Screen
component={PendingInteractionsScreen}
name="Pending"
Expand Down
194 changes: 194 additions & 0 deletions apps/mobile/src/screens/new-session-screen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { beforeEach, expect, jest, test } from "@jest/globals";
import {
createOpenCodeSession,
getDefaultOpenCodeLocation,
getOpenCodeLocation,
} from "@opencode2-mobile/opencode-adapter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native";

import { NewSessionScreen } from "./new-session-screen";

const alpha = {
canonical: "/projects/alpha",
id: "project-alpha",
name: "Alpha",
sandboxes: [],
time: { created: 1, updated: 1 },
};
const beta = {
canonical: "/projects/beta",
id: "project-beta",
name: "Beta",
sandboxes: ["/worktrees/beta-feature"],
time: { created: 1, updated: 1 },
};
const alphaLocation = {
directory: alpha.canonical,
project: { canonical: alpha.canonical, directory: alpha.canonical, id: alpha.id },
workspaceID: "workspace-alpha",
};
const betaLocation = {
directory: "/worktrees/beta-feature",
project: { canonical: beta.canonical, directory: beta.canonical, id: beta.id },
workspaceID: "workspace-beta",
};
const mockSetFollowedProjectIds = jest.fn<(...args: unknown[]) => Promise<void>>(
async () => undefined,
);
const mockRefetch = jest.fn<() => Promise<void>>(async () => undefined);
let mockSelection: Record<string, unknown>;

jest.mock("@opencode2-mobile/opencode-adapter", () => ({
createOpenCodeSession: jest.fn(),
getDefaultOpenCodeLocation: jest.fn(),
getOpenCodeLocation: jest.fn(),
}));
jest.mock("../state/connection-runtime-context", () => ({
useConnectionRuntime: () => ({ connectionId: "connection-1", restClient: {} }),
}));
jest.mock("../state/workspace-selection-context", () => ({
useWorkspaceSelection: () => mockSelection,
}));
jest.mock("expo-haptics", () => ({
NotificationFeedbackType: { Success: "success" },
notificationAsync: jest.fn(async () => undefined),
}));

const mockCreateSession = jest.mocked(createOpenCodeSession);
const mockGetDefaultLocation = jest.mocked(getDefaultOpenCodeLocation);
const mockGetLocation = jest.mocked(getOpenCodeLocation);

beforeEach(() => {
jest.clearAllMocks();
mockSelection = {
followedProjectIds: [alpha.id],
preferencesError: false,
preferencesLoading: false,
preferencesSaving: false,
projects: [alpha, beta],
projectsError: false,
projectsLoading: false,
refetch: mockRefetch,
setFollowedProjectIds: mockSetFollowedProjectIds,
};
mockGetDefaultLocation.mockResolvedValue(alphaLocation);
mockGetLocation.mockImplementation(async (_client, requested) =>
requested.directory === betaLocation.directory ? betaLocation : alphaLocation,
);
mockCreateSession.mockImplementation(async (_client, location) => ({
cost: 0,
id: "ses-created",
location,
projectID: location.directory === betaLocation.directory ? beta.id : alpha.id,
time: { created: 2, updated: 2 },
tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 },
}));
});

test("shows followed projects first and reveals other projects through browse and search", async () => {
renderScreen();

expect(await screen.findByText("Followed projects")).toBeOnTheScreen();
expect(screen.getByText("Alpha")).toBeOnTheScreen();
expect(screen.queryByText("Beta")).toBeNull();

fireEvent.press(screen.getByRole("button", { name: "Browse all projects" }));
expect(await screen.findByText("Other projects")).toBeOnTheScreen();
expect(screen.getByText("Beta")).toBeOnTheScreen();
fireEvent.changeText(screen.getByLabelText("Search projects"), "beta-feature");
await waitFor(() => expect(screen.queryByText("Alpha")).toBeNull());
expect(screen.getByText("Beta")).toBeOnTheScreen();
});

test("creates in a followed project's only location and replaces the modal", async () => {
const navigation = { goBack: jest.fn(), replace: jest.fn() };
mockRefetch.mockImplementationOnce(() => new Promise(() => undefined));
renderScreen(navigation);

fireEvent.press(await screen.findByRole("button", { name: /Alpha, followed/ }));

await waitFor(() =>
expect(mockGetLocation).toHaveBeenCalledWith(
{},
{ directory: alpha.canonical, workspaceID: "workspace-alpha" },
expect.anything(),
),
);
await waitFor(() =>
expect(mockCreateSession).toHaveBeenCalledWith({}, alphaLocation, {}, expect.anything()),
);
expect(mockSetFollowedProjectIds).not.toHaveBeenCalled();
await waitFor(() =>
expect(navigation.replace).toHaveBeenCalledWith("Session", {
connectionId: "connection-1",
focusComposer: true,
location: alphaLocation,
sessionID: "ses-created",
}),
);
});

test("waits for the default location before enabling project selection", async () => {
let resolveDefault: ((location: typeof alphaLocation) => void) | undefined;
mockGetDefaultLocation.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveDefault = resolve;
}),
);
renderScreen();

expect(await screen.findByLabelText("Loading projects")).toBeOnTheScreen();
expect(screen.queryByText("Alpha")).toBeNull();
await act(async () => resolveDefault?.(alphaLocation));
expect(await screen.findByText("Alpha")).toBeOnTheScreen();
});

test("chooses a worktree and follows an unfollowed project before creation", async () => {
const navigation = { goBack: jest.fn(), replace: jest.fn() };
renderScreen(navigation);
fireEvent.press(await screen.findByRole("button", { name: "Browse all projects" }));
fireEvent.press(await screen.findByRole("button", { name: /Beta, not followed/ }));

expect(await screen.findByRole("header", { name: "Choose location" })).toBeOnTheScreen();
expect(mockCreateSession).not.toHaveBeenCalled();
fireEvent.press(screen.getByRole("button", { name: /Worktree 1/ }));

await waitFor(() => expect(mockSetFollowedProjectIds).toHaveBeenCalledWith([alpha.id, beta.id]));
await waitFor(() =>
expect(mockCreateSession).toHaveBeenCalledWith({}, betaLocation, {}, expect.anything()),
);
expect(mockSetFollowedProjectIds.mock.invocationCallOrder[0]).toBeLessThan(
mockCreateSession.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
await waitFor(() => expect(navigation.replace).toHaveBeenCalled());
});

test("does not create when adding an unfollowed project fails", async () => {
mockSetFollowedProjectIds.mockRejectedValueOnce(new Error("storage failed"));
renderScreen();
fireEvent.press(await screen.findByRole("button", { name: "Browse all projects" }));
fireEvent.press(await screen.findByRole("button", { name: /Beta, not followed/ }));
fireEvent.press(screen.getByRole("button", { name: /Worktree 1/ }));

expect(await screen.findByRole("alert")).toHaveTextContent(/could not be created/i);
expect(mockCreateSession).not.toHaveBeenCalled();
});

function renderScreen(navigation = { goBack: jest.fn(), replace: jest.fn() }) {
const queryClient = new QueryClient({
defaultOptions: {
mutations: { gcTime: Number.POSITIVE_INFINITY, networkMode: "always" },
queries: { gcTime: Number.POSITIVE_INFINITY, retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<NewSessionScreen
navigation={navigation as never}
route={{ key: "new-session", name: "NewSession" } as never}
/>
</QueryClientProvider>,
);
}
Loading