diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 40e31b3..48c5297 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -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",
@@ -54,7 +54,7 @@ const config: ExpoConfig = {
: { enabled: false },
ios: {
bundleIdentifier: iosBundleIdentifier,
- buildNumber: "5",
+ buildNumber: "7",
supportsTablet: true,
config: {
usesNonExemptEncryption: false,
@@ -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",
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index a52e521..297c40f 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode2-mobile/mobile",
- "version": "0.1.2",
+ "version": "0.1.3",
"private": true,
"main": "index.ts",
"scripts": {
diff --git a/apps/mobile/src/components/modal-sheet.tsx b/apps/mobile/src/components/modal-sheet.tsx
index 3fd5624..0772518 100644
--- a/apps/mobile/src/components/modal-sheet.tsx
+++ b/apps/mobile/src/components/modal-sheet.tsx
@@ -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;
@@ -87,13 +89,17 @@ export function ModalSheet({
-
- {children}
-
+ {scrollable ? (
+
+ {children}
+
+ ) : (
+ {children}
+ )}
@@ -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,
diff --git a/apps/mobile/src/navigation/root-navigation.test.tsx b/apps/mobile/src/navigation/root-navigation.test.tsx
index 7432071..3cdd921 100644
--- a/apps/mobile/src/navigation/root-navigation.test.tsx
+++ b/apps/mobile/src/navigation/root-navigation.test.tsx
@@ -27,6 +27,10 @@ jest.mock("../screens/followed-projects-screen", () => {
const { Text } = jest.requireActual("react-native");
return { FollowedProjectsScreen: () => Followed projects screen };
});
+jest.mock("../screens/new-session-screen", () => {
+ const { Text } = jest.requireActual("react-native");
+ return { NewSessionScreen: () => New session screen };
+});
jest.mock("./workspace-header-actions", () => ({ WorkspaceHeaderActions: () => null }));
jest.mock("../screens/workspace-screen", () => {
const { Text } = jest.requireActual("react-native");
@@ -35,10 +39,6 @@ jest.mock("../screens/workspace-screen", () => {
WorkspaceScreen: () => Workspace shell,
};
});
-jest.mock("../screens/followed-projects-screen", () => {
- const { Text } = jest.requireActual("react-native");
- return { FollowedProjectsScreen: () => Followed projects screen };
-});
jest.mock("../screens/connection-screen", () => {
const { Pressable, Text } = jest.requireActual("react-native");
return {
@@ -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());
diff --git a/apps/mobile/src/navigation/root-navigation.tsx b/apps/mobile/src/navigation/root-navigation.tsx
index f142f3a..d179982 100644
--- a/apps/mobile/src/navigation/root-navigation.tsx
+++ b/apps/mobile/src/navigation/root-navigation.tsx
@@ -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";
@@ -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;
};
@@ -141,6 +148,11 @@ export function RootNavigation() {
title: "Session",
})}
/>
+
Promise>(
+ async () => undefined,
+);
+const mockRefetch = jest.fn<() => Promise>(async () => undefined);
+let mockSelection: Record;
+
+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(
+
+
+ ,
+ );
+}
diff --git a/apps/mobile/src/screens/new-session-screen.tsx b/apps/mobile/src/screens/new-session-screen.tsx
new file mode 100644
index 0000000..67f30eb
--- /dev/null
+++ b/apps/mobile/src/screens/new-session-screen.tsx
@@ -0,0 +1,637 @@
+import {
+ createOpenCodeSession,
+ getDefaultOpenCodeLocation,
+ getOpenCodeLocation,
+ type LocationRef,
+ type ProjectListOutput,
+} from "@opencode2-mobile/opencode-adapter";
+import type { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import * as Haptics from "expo-haptics";
+import { useDeferredValue, useEffect, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ Keyboard,
+ KeyboardAvoidingView,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import type { RootStackParamList } from "../navigation/root-navigation";
+import { useConnectionRuntime } from "../state/connection-runtime-context";
+import { openCodeQueryKeys } from "../state/open-code-query-keys";
+import { useWorkspaceSelection } from "../state/workspace-selection-context";
+import { palette, radius, space, typeRamp } from "../theme";
+import { sanitizeTranscriptText } from "./session-transcript-model";
+
+type Props = NativeStackScreenProps;
+type Project = ProjectListOutput[number];
+type LocationChoice = { key: string; label: string; location: LocationRef };
+type ProjectRow = { id: string; project?: Project; title?: string; type: "project" | "section" };
+
+export function NewSessionScreen({ navigation }: Props) {
+ const runtime = useConnectionRuntime();
+ const selection = useWorkspaceSelection();
+ const queryClient = useQueryClient();
+ const createAbortRef = useRef(null);
+ const closedRef = useRef(false);
+ const [browseAll, setBrowseAll] = useState(false);
+ const [error, setError] = useState();
+ const [search, setSearch] = useState("");
+ const [selectedProject, setSelectedProject] = useState();
+ const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase());
+ const connectionID = runtime.connectionId;
+ const client = runtime.restClient;
+ const defaultLocationQuery = useQuery({
+ enabled: Boolean(client && connectionID),
+ queryFn: ({ signal }) => {
+ if (!client) throw new Error("CONNECTION_NOT_READY");
+ return getDefaultOpenCodeLocation(client, { signal });
+ },
+ queryKey: openCodeQueryKeys.defaultLocation(connectionID ?? "unselected"),
+ });
+ const followed = new Set(selection.followedProjectIds);
+ const followedOrder = new Map(
+ selection.followedProjectIds.map((projectID, index) => [projectID, index]),
+ );
+ const orderedProjects = selection.projects
+ .map((project, serverIndex) => ({ project, serverIndex }))
+ .sort((first, second) => {
+ const firstPosition = followedOrder.get(first.project.id);
+ const secondPosition = followedOrder.get(second.project.id);
+ if (firstPosition !== undefined && secondPosition !== undefined) {
+ return firstPosition - secondPosition;
+ }
+ if (firstPosition !== undefined) return -1;
+ if (secondPosition !== undefined) return 1;
+ return first.serverIndex - second.serverIndex;
+ })
+ .map(({ project }) => project);
+ const matchingProjects = deferredSearch
+ ? orderedProjects.filter((project) => projectSearchText(project).includes(deferredSearch))
+ : orderedProjects;
+ const showOtherProjects = browseAll || Boolean(deferredSearch);
+ const rows = projectRows(matchingProjects, followed, showOtherProjects);
+ const otherProjectCount = selection.projects.filter(
+ (project) => !followed.has(project.id),
+ ).length;
+ const selectedLocations = projectLocations(selectedProject, defaultLocationQuery.data);
+
+ useEffect(() => {
+ void connectionID;
+ closedRef.current = false;
+ createAbortRef.current?.abort();
+ setError(undefined);
+ setSearch("");
+ setBrowseAll(false);
+ setSelectedProject(undefined);
+ return () => createAbortRef.current?.abort();
+ }, [connectionID]);
+
+ const createMutation = useMutation({
+ mutationFn: async ({ project, requested }: { project: Project; requested: LocationRef }) => {
+ if (!client || !connectionID) throw new Error("CONNECTION_NOT_READY");
+ const controller = new AbortController();
+ createAbortRef.current?.abort();
+ createAbortRef.current = controller;
+ try {
+ const location = await getOpenCodeLocation(client, requested, {
+ signal: controller.signal,
+ });
+ if (location.project.id !== project.id) throw new Error("LOCATION_PROJECT_MISMATCH");
+ if (!selection.followedProjectIds.includes(project.id)) {
+ await selection.setFollowedProjectIds([...selection.followedProjectIds, project.id]);
+ }
+ const session = await createOpenCodeSession(
+ client,
+ location,
+ {},
+ { signal: controller.signal },
+ );
+ return { location, session };
+ } catch (caught) {
+ if (controller.signal.aborted) throw new Error("REQUEST_ABORTED");
+ throw caught;
+ } finally {
+ if (createAbortRef.current === controller) createAbortRef.current = null;
+ }
+ },
+ onError: (caught) => {
+ if (caught instanceof Error && caught.message === "REQUEST_ABORTED") return;
+ setError(
+ caught instanceof Error && caught.message === "LOCATION_PROJECT_MISMATCH"
+ ? "That directory no longer belongs to this project. Refresh projects and try again."
+ : "The session could not be created. Check this project and connection, then try again.",
+ );
+ },
+ onMutate: () => setError(undefined),
+ onSuccess: ({ location, session }) => {
+ if (!connectionID) return;
+ queryClient.setQueryData(
+ openCodeQueryKeys.session(connectionID, location, session.id),
+ session,
+ );
+ void selection.refetch();
+ if (closedRef.current) return;
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
+ () => undefined,
+ );
+ navigation.replace("Session", {
+ connectionId: connectionID,
+ focusComposer: true,
+ location,
+ sessionID: session.id,
+ });
+ },
+ });
+
+ function chooseProject(project: Project) {
+ if (createMutation.isPending || selection.preferencesSaving) return;
+ Keyboard.dismiss();
+ const locations = projectLocations(project, defaultLocationQuery.data);
+ if (locations.length === 1 && locations[0]) {
+ createMutation.mutate({ project, requested: locations[0].location });
+ return;
+ }
+ setError(undefined);
+ setSelectedProject(project);
+ }
+
+ function showAllProjects() {
+ setBrowseAll(true);
+ }
+
+ function close() {
+ closedRef.current = true;
+ createAbortRef.current?.abort();
+ navigation.goBack();
+ }
+
+ const loading =
+ selection.preferencesLoading || selection.projectsLoading || defaultLocationQuery.isPending;
+ const disabled = createMutation.isPending || selection.preferencesSaving;
+
+ return (
+
+
+
+
+
+ {selectedProject ? "Choose location" : "New session"}
+
+
+ {selectedProject
+ ? projectLabel(selectedProject)
+ : "Choose where this session should start."}
+
+
+ [styles.cancelButton, pressed && styles.pressed]}
+ >
+
+ Cancel
+
+
+
+
+ {selectedProject ? (
+
+ {!followed.has(selectedProject.id) ? (
+
+ Starting here will add this project to Sessions on this device.
+
+ ) : null}
+ {
+ setError(undefined);
+ setSelectedProject(undefined);
+ }}
+ style={({ pressed }) => [
+ styles.backButton,
+ disabled && styles.disabled,
+ pressed && styles.pressed,
+ ]}
+ >
+ < Projects
+
+ choice.key}
+ renderItem={({ item: choice }) => (
+
+ createMutation.mutate({ project: selectedProject, requested: choice.location })
+ }
+ />
+ )}
+ />
+
+ ) : loading ? (
+
+ ) : selection.projectsError ||
+ selection.preferencesError ||
+ defaultLocationQuery.isError ? (
+
+
+ Project locations could not be loaded from this connection.
+
+ {
+ setError(undefined);
+ void Promise.all([selection.refetch(), defaultLocationQuery.refetch()]);
+ }}
+ />
+
+ ) : selection.projects.length === 0 ? (
+
+ No server projects found
+ OpenCode did not return any known projects.
+ void selection.refetch()} />
+
+ ) : (
+
+
+
+ {search ? (
+ setSearch("")}
+ style={({ pressed }) => [styles.clearButton, pressed && styles.pressed]}
+ >
+ Clear
+
+ ) : null}
+
+ row.id}
+ ListEmptyComponent={
+
+
+ {deferredSearch
+ ? `No projects match "${search.trim()}"`
+ : "No followed projects"}
+
+
+ {deferredSearch
+ ? "Try a project name or path."
+ : "Browse server projects to start a session."}
+
+
+ }
+ renderItem={({ item }) =>
+ item.type === "section" ? (
+
+ {item.title}
+
+ ) : item.project ? (
+ chooseProject(item.project as Project)}
+ project={item.project}
+ />
+ ) : null
+ }
+ />
+ {!showOtherProjects && otherProjectCount > 0 ? (
+
+ ) : null}
+
+ )}
+
+ {createMutation.isPending ? (
+
+
+ Creating session...
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ );
+}
+
+function projectLocations(
+ project: Project | undefined,
+ defaultLocation: Awaited> | undefined,
+) {
+ if (!project) return [];
+ const isDefaultProject = defaultLocation?.project.id === project.id;
+ const defaultRef = defaultLocation
+ ? {
+ directory: defaultLocation.directory,
+ ...(defaultLocation.workspaceID ? { workspaceID: defaultLocation.workspaceID } : {}),
+ }
+ : undefined;
+ const mainLocation =
+ isDefaultProject && defaultRef?.directory === project.canonical
+ ? defaultRef
+ : { directory: project.canonical };
+ const choices: LocationChoice[] = [
+ { key: locationKey(mainLocation), label: "Main directory", location: mainLocation },
+ ];
+ if (isDefaultProject && defaultRef && defaultRef.directory !== project.canonical) {
+ choices.push({
+ key: locationKey(defaultRef),
+ label: "Current server directory",
+ location: defaultRef,
+ });
+ }
+ let worktreeIndex = 0;
+ for (const directory of project.sandboxes) {
+ if (choices.some((choice) => choice.location.directory === directory)) continue;
+ worktreeIndex += 1;
+ const location = { directory };
+ choices.push({
+ key: locationKey(location),
+ label: `Worktree ${worktreeIndex}`,
+ location,
+ });
+ }
+ return choices;
+}
+
+function locationKey(location: LocationRef) {
+ return `${location.directory}\u0000${location.workspaceID ?? ""}`;
+}
+
+function projectRows(projects: Project[], followed: ReadonlySet, showOther: boolean) {
+ const followedProjects = projects.filter((project) => followed.has(project.id));
+ const otherProjects = showOther ? projects.filter((project) => !followed.has(project.id)) : [];
+ const rows: ProjectRow[] = [];
+ if (followedProjects.length) {
+ rows.push({ id: "section-followed", title: "Followed projects", type: "section" });
+ rows.push(
+ ...followedProjects.map((project) => ({
+ id: `project-${project.id}`,
+ project,
+ type: "project" as const,
+ })),
+ );
+ }
+ if (otherProjects.length) {
+ rows.push({ id: "section-other", title: "Other projects", type: "section" });
+ rows.push(
+ ...otherProjects.map((project) => ({
+ id: `project-${project.id}`,
+ project,
+ type: "project" as const,
+ })),
+ );
+ }
+ return rows;
+}
+
+function ProjectButton({
+ disabled,
+ followed,
+ onPress,
+ project,
+}: {
+ disabled: boolean;
+ followed: boolean;
+ onPress: () => void;
+ project: Project;
+}) {
+ const label = projectLabel(project);
+ const path = sanitizeTranscriptText(project.canonical, 1_024);
+ return (
+ [
+ styles.project,
+ disabled && styles.disabled,
+ pressed && styles.pressed,
+ ]}
+ >
+
+ {label}
+
+
+ {path}
+
+ {!followed ? Will be added to Sessions : null}
+
+ );
+}
+
+function LocationButton({
+ directory,
+ disabled,
+ label,
+ onPress,
+}: {
+ directory: string;
+ disabled: boolean;
+ label: string;
+ onPress: () => void;
+}) {
+ const path = sanitizeTranscriptText(directory, 1_024);
+ return (
+ [
+ styles.project,
+ disabled && styles.disabled,
+ pressed && styles.pressed,
+ ]}
+ >
+
+ {label}
+
+
+ {path}
+
+
+ );
+}
+
+function ActionButton({ label, onPress }: { label: string; onPress: () => void }) {
+ return (
+ [styles.actionButton, pressed && styles.pressed]}
+ >
+
+ {label}
+
+
+ );
+}
+
+function StateMessage({ label, loading }: { label: string; loading?: boolean }) {
+ return (
+
+ {loading ? : null}
+ {label}
+
+ );
+}
+
+function RowSeparator() {
+ return ;
+}
+
+function projectLabel(project: { canonical: string; id: string; name?: string }) {
+ return (
+ project.name?.trim() || project.canonical.split(/[\\/]/).filter(Boolean).at(-1) || project.id
+ );
+}
+
+function projectSearchText(project: Project) {
+ return `${projectLabel(project)}\n${project.canonical}\n${project.sandboxes.join("\n")}\n${project.id}`.toLocaleLowerCase();
+}
+
+const styles = StyleSheet.create({
+ actionButton: {
+ alignItems: "center",
+ borderColor: palette.signal,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ justifyContent: "center",
+ minHeight: 48,
+ paddingHorizontal: space.md,
+ },
+ actionLabel: { color: palette.signal, fontSize: 15, fontWeight: "800" },
+ backButton: { alignSelf: "flex-start", justifyContent: "center", minHeight: 44 },
+ backLabel: { color: palette.signal, fontSize: 15, fontWeight: "700" },
+ body: { flex: 1, gap: space.md, padding: space.lg, paddingTop: space.md },
+ cancelButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.xs },
+ cancelLabel: { color: palette.signal, fontSize: 16, fontWeight: "700" },
+ clearButton: { justifyContent: "center", minHeight: 44, paddingHorizontal: space.md },
+ clearLabel: { color: palette.signal, fontSize: 13, fontWeight: "800" },
+ creatingState: {
+ alignItems: "center",
+ borderTopColor: palette.border,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ flexDirection: "row",
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ disabled: { opacity: 0.45 },
+ emptyState: { alignItems: "center", gap: space.xs, paddingVertical: space.xl },
+ error: { color: palette.danger, fontSize: 14, lineHeight: 20, textAlign: "center" },
+ followNote: { color: palette.signal, fontSize: 11, fontWeight: "800", marginTop: space.xs },
+ followingCopy: { color: palette.dim, fontSize: 13, lineHeight: 19 },
+ footerError: {
+ color: palette.danger,
+ fontSize: 13,
+ lineHeight: 18,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.md,
+ },
+ header: {
+ alignItems: "flex-start",
+ borderBottomColor: palette.border,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ flexDirection: "row",
+ gap: space.md,
+ padding: space.lg,
+ },
+ heading: { flex: 1, minWidth: 0 },
+ keyboardView: { flex: 1 },
+ listContent: { paddingBottom: space.xl },
+ pressed: { opacity: 0.58 },
+ project: {
+ backgroundColor: palette.card,
+ borderColor: palette.border,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ minHeight: 72,
+ padding: space.md,
+ },
+ projectPath: { color: palette.dim, fontSize: 12, lineHeight: 18, marginTop: 3 },
+ projectTitle: { color: palette.ink, fontSize: 16, fontWeight: "800" },
+ screen: { backgroundColor: palette.background, flex: 1 },
+ searchField: {
+ alignItems: "center",
+ borderColor: palette.border,
+ borderRadius: radius.md,
+ borderWidth: 1,
+ flexDirection: "row",
+ minHeight: 48,
+ },
+ searchInput: {
+ color: palette.ink,
+ flex: 1,
+ fontSize: 16,
+ minHeight: 48,
+ paddingHorizontal: space.md,
+ },
+ sectionTitle: {
+ color: palette.dim,
+ fontSize: 11,
+ fontWeight: "900",
+ letterSpacing: 1,
+ paddingBottom: space.xs,
+ paddingTop: space.sm,
+ textTransform: "uppercase",
+ },
+ separator: { height: space.sm },
+ state: {
+ alignItems: "center",
+ flex: 1,
+ gap: space.sm,
+ justifyContent: "center",
+ padding: space.xl,
+ },
+ stateCopy: { color: palette.dim, fontSize: 14, lineHeight: 20, textAlign: "center" },
+ stateTitle: { color: palette.ink, fontSize: 17, fontWeight: "800", textAlign: "center" },
+ subtitle: { color: palette.dim, fontSize: 14, lineHeight: 20, marginTop: 3 },
+ title: { color: palette.ink, fontSize: 28, fontWeight: "800", lineHeight: 34 },
+});
diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx
index aa2e3e7..5e6d351 100644
--- a/apps/mobile/src/screens/session-composer.test.tsx
+++ b/apps/mobile/src/screens/session-composer.test.tsx
@@ -148,10 +148,13 @@ test("selects a server agent and model variant", () => {
fireEvent(screen.getByLabelText("Prompt"), "focus");
fireEvent.press(screen.getByRole("button", { name: "Agent: Choose agent" }));
+ expect(screen.getByLabelText("Agent results").props.inverted).toBe(true);
+ fireEvent.changeText(screen.getByLabelText("Search agents"), "build");
fireEvent.press(screen.getByRole("button", { name: "Build" }));
expect(onAgentChange).toHaveBeenCalledWith("build");
fireEvent.press(screen.getByRole("button", { name: "Model: Choose model" }));
+ expect(screen.getByLabelText("Model results").props.inverted).toBe(true);
fireEvent.press(screen.getByRole("button", { name: /Model One \/ deep/ }));
expect(onModelChange).toHaveBeenCalledWith({
id: "model-1",
diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx
index 5a9a99c..04cfeab 100644
--- a/apps/mobile/src/screens/session-composer.tsx
+++ b/apps/mobile/src/screens/session-composer.tsx
@@ -1,6 +1,16 @@
import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter";
-import { useDeferredValue, useRef, useState } from "react";
-import { Keyboard, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native";
+import { useDeferredValue, useEffect, useRef, useState } from "react";
+import {
+ FlatList,
+ Keyboard,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
import { ModalSheet } from "../components/modal-sheet";
import { palette, radius, space, typeRamp } from "../theme";
@@ -17,6 +27,7 @@ export function SessionComposer({
draft,
editable = true,
error,
+ focusOnMount,
largeText,
model,
models,
@@ -34,6 +45,7 @@ export function SessionComposer({
draft: string;
editable?: boolean | undefined;
error?: string | undefined;
+ focusOnMount?: boolean | undefined;
largeText: boolean;
model?: ModelRef | undefined;
models: ModelInfo[];
@@ -45,10 +57,12 @@ export function SessionComposer({
}) {
const inputRef = useRef(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
+ const [agentSearch, setAgentSearch] = useState("");
const [focused, setFocused] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const deferredModelSearch = useDeferredValue(modelSearch.trim().toLocaleLowerCase());
+ const deferredAgentSearch = useDeferredValue(agentSearch.trim().toLocaleLowerCase());
const selectedAgent = agents.find((candidate) => candidate.id === agent);
const selectedModel = models.find(
(candidate) => candidate.id === model?.id && candidate.providerID === model.providerID,
@@ -60,12 +74,25 @@ export function SessionComposer({
.includes(deferredModelSearch),
)
: models;
+ const visibleAgents = deferredAgentSearch
+ ? agents.filter((candidate) =>
+ `${candidate.name}\n${candidate.id}\n${candidate.description ?? ""}`
+ .toLocaleLowerCase()
+ .includes(deferredAgentSearch),
+ )
+ : agents;
const expanded = largeText || focused || agentPickerOpen || modelPickerOpen;
const canSubmit =
!disabled &&
draft.trim().length > 0 &&
(!active || delivery === "queue" || delivery === "steer");
+ useEffect(() => {
+ if (!focusOnMount || !editable) return;
+ const frame = requestAnimationFrame(() => inputRef.current?.focus());
+ return () => cancelAnimationFrame(frame);
+ }, [editable, focusOnMount]);
+
function submit() {
if (!canSubmit) return;
inputRef.current?.blur();
@@ -84,6 +111,7 @@ export function SessionComposer({
setAgentPickerOpen(true)}
+ onPress={() => {
+ setAgentSearch("");
+ setAgentPickerOpen(true);
+ }}
prefix="Agent"
/>
{draft.length > 0 ? (
@@ -170,81 +201,126 @@ export function SessionComposer({
setAgentPickerOpen(false)}
+ scrollable={false}
subtitle="Primary agents available at this session location"
title="Choose agent"
visible={agentPickerOpen}
>
- {agents.map((candidate) => (
- {
- onAgentChange(candidate.id);
- setAgentPickerOpen(false);
- }}
- selected={candidate.id === agent}
- />
- ))}
+ candidate.id}
+ ListEmptyComponent={}
+ renderItem={({ item: candidate }) => (
+ {
+ onAgentChange(candidate.id);
+ setAgentPickerOpen(false);
+ }}
+ selected={candidate.id === agent}
+ />
+ )}
+ style={styles.pickerList}
+ />
+
setModelPickerOpen(false)}
+ scrollable={false}
subtitle="Enabled models and variants from the server catalog"
title="Choose model"
visible={modelPickerOpen}
>
-
- {visibleModels.map((candidate) => (
-
- {
- onModelChange({ id: candidate.id, providerID: candidate.providerID });
- setModelPickerOpen(false);
- }}
- selected={
- candidate.id === model?.id &&
- candidate.providerID === model.providerID &&
- model.variant === undefined
- }
- />
- {candidate.variants.map((variant) => (
+ `${candidate.providerID}/${candidate.id}`}
+ ListEmptyComponent={}
+ renderItem={({ item: candidate }) => (
+
{
- onModelChange({
- id: candidate.id,
- providerID: candidate.providerID,
- variant: variant.id,
- });
+ onModelChange({ id: candidate.id, providerID: candidate.providerID });
setModelPickerOpen(false);
}}
selected={
candidate.id === model?.id &&
candidate.providerID === model.providerID &&
- variant.id === model.variant
+ model.variant === undefined
}
/>
- ))}
-
- ))}
+ {candidate.variants.map((variant) => (
+ {
+ onModelChange({
+ id: candidate.id,
+ providerID: candidate.providerID,
+ variant: variant.id,
+ });
+ setModelPickerOpen(false);
+ }}
+ selected={
+ candidate.id === model?.id &&
+ candidate.providerID === model.providerID &&
+ variant.id === model.variant
+ }
+ />
+ ))}
+
+ )}
+ style={styles.pickerList}
+ />
+
);
}
+function OptionSeparator() {
+ return ;
+}
+
+function EmptyResults({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
function SendButton({
active,
canSubmit,
@@ -396,6 +472,7 @@ const styles = StyleSheet.create({
deliveryRow: { flexDirection: "row", gap: space.xs },
editorRow: { alignItems: "center", flexDirection: "row", minWidth: 0 },
editorRowExpanded: { alignItems: "stretch" },
+ emptyResults: { color: palette.dim, paddingVertical: space.lg, textAlign: "center" },
error: { color: palette.danger, fontSize: 13, lineHeight: 18 },
input: {
color: palette.ink,
@@ -423,6 +500,9 @@ const styles = StyleSheet.create({
optionDescription: { color: palette.dim, fontSize: 12, marginTop: 3 },
optionLabel: { color: palette.ink, fontSize: 15, fontWeight: "700" },
optionSelected: { backgroundColor: palette.signalDark, borderColor: palette.signal },
+ optionSeparator: { height: space.xs },
+ pickerList: { flex: 1 },
+ pickerListContent: { flexGrow: 1, justifyContent: "flex-start" },
pressed: { opacity: 0.62 },
searchInput: {
borderColor: palette.border,
diff --git a/apps/mobile/src/screens/session-transcript-live-follow.test.ts b/apps/mobile/src/screens/session-transcript-live-follow.test.ts
index 06a25f2..f66ffe0 100644
--- a/apps/mobile/src/screens/session-transcript-live-follow.test.ts
+++ b/apps/mobile/src/screens/session-transcript-live-follow.test.ts
@@ -41,6 +41,13 @@ test("does not mistake layout compensation for user scrolling", () => {
userScrollSessionActive: false,
}),
).toBe(true);
+ expect(
+ resolveTranscriptLiveFollow(false, {
+ isAtLiveEdge: true,
+ type: "scroll",
+ userScrollSessionActive: false,
+ }),
+ ).toBe(false);
expect(
resolveTranscriptLiveFollow(true, {
isAtLiveEdge: false,
diff --git a/apps/mobile/src/screens/session-transcript-live-follow.ts b/apps/mobile/src/screens/session-transcript-live-follow.ts
index 2c4807a..a981abe 100644
--- a/apps/mobile/src/screens/session-transcript-live-follow.ts
+++ b/apps/mobile/src/screens/session-transcript-live-follow.ts
@@ -22,6 +22,6 @@ export function resolveTranscriptLiveFollow(current: boolean, event: TranscriptL
return event.userScrollSessionActive ? event.isAtLiveEdge : current;
case "scroll":
if (event.userScrollSessionActive) return false;
- return event.isAtLiveEdge ? true : current;
+ return current;
}
}
diff --git a/apps/mobile/src/screens/use-session-execution.test.tsx b/apps/mobile/src/screens/use-session-execution.test.tsx
index 4365b5c..0417f53 100644
--- a/apps/mobile/src/screens/use-session-execution.test.tsx
+++ b/apps/mobile/src/screens/use-session-execution.test.tsx
@@ -17,7 +17,7 @@ import type { ReactNode } from "react";
import { openCodeQueryKeys } from "../state/open-code-query-keys";
import type { PromptAdmission } from "./prompt-admission-model";
-import { useSessionExecution } from "./use-session-execution";
+import { resolveSessionAgent, useSessionExecution } from "./use-session-execution";
const mockLocation = { directory: "/workspace" } satisfies LocationRef;
const mockAdmissionDb = {
@@ -55,6 +55,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({
backgroundOpenCodeSession: (...args: unknown[]) => mockBackground(...args),
cancelOpenCodeSessionInboxItem: (...args: unknown[]) => mockCancel(...args),
classifyOpenCodeError: (error: unknown) => mockClassifyError(error),
+ getDefaultOpenCodeAgent: jest.fn(async () => null),
getDefaultOpenCodeModel: jest.fn(async () => ({ data: null, location: mockLocation })),
getOpenCodeSessionMessage: (...args: unknown[]) => mockGetMessage(...args),
interruptOpenCodeSession: (...args: unknown[]) => mockInterrupt(...args),
@@ -108,6 +109,13 @@ afterAll(() => {
notifyManager.setNotifyFunction((callback) => callback());
});
+test("resolves session, configured, and build agent defaults in priority order", () => {
+ expect(resolveSessionAgent("review", "plan", ["build", "plan", "review"])).toBe("review");
+ expect(resolveSessionAgent(undefined, "plan", ["build", "plan"])).toBe("plan");
+ expect(resolveSessionAgent(undefined, "missing", ["review", "build"])).toBe("build");
+ expect(resolveSessionAgent(undefined, undefined, ["review"])).toBe("review");
+});
+
test("admits only once when the send control is tapped twice before rerender", async () => {
let resolvePrompt: ((item: SessionInboxInfo) => void) | undefined;
mockPrompt.mockImplementation(
diff --git a/apps/mobile/src/screens/use-session-execution.ts b/apps/mobile/src/screens/use-session-execution.ts
index 1c76058..197f3a7 100644
--- a/apps/mobile/src/screens/use-session-execution.ts
+++ b/apps/mobile/src/screens/use-session-execution.ts
@@ -2,6 +2,7 @@ import {
backgroundOpenCodeSession,
cancelOpenCodeSessionInboxItem,
classifyOpenCodeError,
+ getDefaultOpenCodeAgent,
getDefaultOpenCodeModel,
getOpenCodeSessionMessage,
interruptOpenCodeSession,
@@ -141,6 +142,14 @@ export function useSessionExecution({
},
queryKey: [...openCodeQueryKeys.models(scopedConnectionId, location), "default"],
});
+ const defaultAgentQuery = useQuery({
+ enabled,
+ queryFn: ({ signal }) => {
+ if (!client) throw new Error("CONNECTION_NOT_READY");
+ return getDefaultOpenCodeAgent(client, location, { signal });
+ },
+ queryKey: [...openCodeQueryKeys.agents(scopedConnectionId, location), "default"],
+ });
const active = Boolean(activeSessionsQuery.data?.[sessionID]);
const executionStateReady = activeSessionsQuery.isSuccess && inboxQuery.isSuccess;
const inbox = inboxQuery.data ?? [];
@@ -155,6 +164,12 @@ export function useSessionExecution({
const models = (modelsQuery.data?.data ?? []).filter(
(candidate) => candidate.enabled && candidate.status !== "deprecated",
);
+ const selectedAgent = resolveSessionAgent(
+ session?.agent,
+ defaultAgentQuery.data,
+ agents.map((agent) => agent.id),
+ );
+ const selectedAgentInfo = agents.find((agent) => agent.id === selectedAgent);
const updateAdmissions = useCallback(
(update: (current: PromptAdmission[]) => PromptAdmission[]) => {
@@ -734,8 +749,9 @@ export function useSessionExecution({
background: () => mutateControl("background"),
wait: () => mutateControl("wait"),
reconcileAdmission: (admissionID: string) => void reconcileAdmission(admissionID),
- selectedAgent: session?.agent,
- selectedModel: session?.model ?? modelRef(defaultModelQuery.data?.data),
+ selectedAgent,
+ selectedModel:
+ session?.model ?? selectedAgentInfo?.model ?? modelRef(defaultModelQuery.data?.data),
};
function mutateInbox(action: "cancel" | "queue" | "steer", inboxID: string) {
@@ -763,6 +779,17 @@ export function useSessionExecution({
}
}
+export function resolveSessionAgent(
+ sessionAgent: string | undefined,
+ configuredDefault: string | null | undefined,
+ availableAgentIDs: readonly string[],
+) {
+ if (sessionAgent) return sessionAgent;
+ if (configuredDefault && availableAgentIDs.includes(configuredDefault)) return configuredDefault;
+ if (availableAgentIDs.includes("build")) return "build";
+ return availableAgentIDs[0];
+}
+
function modelRef(model: { id: string; providerID: string } | null | undefined) {
return model ? { id: model.id, providerID: model.providerID } : undefined;
}
diff --git a/apps/mobile/src/screens/workspace-screen-model.ts b/apps/mobile/src/screens/workspace-screen-model.ts
index 967e3db..f18ec08 100644
--- a/apps/mobile/src/screens/workspace-screen-model.ts
+++ b/apps/mobile/src/screens/workspace-screen-model.ts
@@ -7,11 +7,11 @@ import type {
type Project = ProjectListOutput[number];
export function needsComposerDockMeasurement(
- measuredWindowHeight: number | undefined,
- windowHeight: number,
+ measuredScreenHeight: number | undefined,
+ screenHeight: number,
keyboardVisible: boolean,
) {
- return !keyboardVisible && measuredWindowHeight !== windowHeight;
+ return !keyboardVisible && measuredScreenHeight !== screenHeight;
}
export function getComposerDockKeyboardOffset(
diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx
index 80a28e9..d9eacd0 100644
--- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx
+++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx
@@ -391,10 +391,12 @@ test("keeps session-list chrome stable during background location updates", asyn
queryClient.clear();
});
-test("uses one location picker and keeps the phone session header compact", async () => {
+test("opens project selection before creating a session", async () => {
+ const navigation = { navigate: jest.fn() };
+ mockWorkspaceRefetch.mockClear();
const queryClient = new QueryClient({
defaultOptions: {
- mutations: { networkMode: "always" },
+ mutations: { gcTime: Infinity, networkMode: "always" },
queries: { gcTime: Infinity, retry: false },
},
});
@@ -402,7 +404,7 @@ test("uses one location picker and keeps the phone session header compact", asyn
@@ -417,17 +419,11 @@ test("uses one location picker and keeps the phone session header compact", asyn
expect(screen.getByText("Recent")).toBeOnTheScreen();
expect(screen.queryByText("Succeeded")).toBeNull();
- fireEvent.press(screen.getByRole("button", { name: "New" }));
- expect(await screen.findByRole("header", { name: "New session" })).toBeOnTheScreen();
- fireEvent.changeText(screen.getByLabelText("Session title optional"), "Fix the session list");
- expect(screen.getByRole("button", { name: "Create session" })).toBeEnabled();
- fireEvent.press(screen.getByLabelText("Change new session location"));
- expect(await screen.findByRole("header", { name: "Session location" })).toBeOnTheScreen();
- expect(screen.queryByText("Inbox projects")).toBeNull();
- expect(screen.getByText("Followed projects")).toBeOnTheScreen();
- expect(screen.queryByRole("header", { name: "New session" })).toBeNull();
- fireEvent.press(screen.getByLabelText("Close Session location"));
- expect(await screen.findByRole("header", { name: "New session" })).toBeOnTheScreen();
+ const newButton = screen.getByRole("button", { name: "New" });
+ expect(newButton).toBeEnabled();
+ fireEvent.press(newButton);
+ expect(navigation.navigate).toHaveBeenCalledWith("NewSession");
+ expect(mockWorkspaceRefetch).not.toHaveBeenCalled();
view.unmount();
queryClient.clear();
@@ -503,8 +499,10 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn
await expect(mockListMessages.mock.results.at(-1)?.value).resolves.toMatchObject({
data: expect.any(Array),
});
- await screen.findByRole("header", { name: "Transcript session" });
await screen.findByText("Current question");
+ expect(screen.queryByRole("header", { name: "Transcript session" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "DELETE SESSION" })).toBeNull();
+ expect(screen.queryByLabelText("Transcript controls")).toBeNull();
expect(screen.getByText("Newest answer")).toBeOnTheScreen();
expect(screen.getByText("note.txt")).toBeOnTheScreen();
expect(screen.getByText("Private reasoning")).toBeOnTheScreen();
@@ -532,13 +530,16 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn
},
});
const liveEdgeEvent = scrollEvent(0);
+ const justAwayFromLiveEdge = scrollEvent(3);
fireEvent(transcript, "scrollBeginDrag", liveEdgeEvent);
- fireEvent.scroll(transcript, scrollEvent(120));
+ fireEvent.scroll(transcript, justAwayFromLiveEdge);
+ fireEvent(transcript, "momentumScrollEnd", justAwayFromLiveEdge);
expect(screen.getByRole("button", { name: "Scroll to latest" })).toHaveStyle({
position: "relative",
});
expect(screen.getByText("Latest").props.dynamicTypeRamp).toBe("footnote");
+ fireEvent(transcript, "scrollBeginDrag", justAwayFromLiveEdge);
fireEvent.scroll(transcript, liveEdgeEvent);
expect(screen.getByRole("button", { name: "Scroll to latest" })).toBeOnTheScreen();
fireEvent(transcript, "momentumScrollEnd", liveEdgeEvent);
@@ -548,7 +549,8 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn
await waitFor(() => expect(scrollToOffset).toHaveBeenCalledWith({ animated: false, offset: 0 }));
fireEvent(transcript, "scrollBeginDrag", liveEdgeEvent);
- fireEvent.scroll(transcript, scrollEvent(120));
+ fireEvent.scroll(transcript, justAwayFromLiveEdge);
+ fireEvent(transcript, "momentumScrollEnd", justAwayFromLiveEdge);
scrollToOffset.mockClear();
fireEvent(transcript, "contentSizeChange", 320, 1_200);
expect(scrollToOffset).not.toHaveBeenCalled();
@@ -623,8 +625,7 @@ test("shows running background subagents and opens their child sessions", async
,
);
- expect(await screen.findByText("1 background subagent running")).toBeOnTheScreen();
- expect(screen.getByText("Inspect event handling")).toBeOnTheScreen();
+ expect(await screen.findByText("Inspect event handling")).toBeOnTheScreen();
fireEvent.press(screen.getByRole("button", { name: "Open child" }));
expect(push).toHaveBeenCalledWith("Session", {
connectionId: "connection-1",
@@ -760,7 +761,6 @@ test("remeasures the transcript when the system font scale changes", async () =>
try {
await screen.findByText("Newest answer");
expect(screen.UNSAFE_getByType(FlatList).props.extraData).toBe(defaultFontScale);
- expect(screen.getByLabelText("Transcript controls")).toHaveStyle({ flexDirection: "row" });
expect(screen.getByRole("button", { name: /Thought/ })).toHaveStyle({
flexDirection: "row",
});
@@ -797,18 +797,12 @@ test("remeasures the transcript when the system font scale changes", async () =>
});
expect(screen.UNSAFE_getByType(FlatList).props.extraData).toBe(accessibilityFontScale);
- expect(screen.getByLabelText("Transcript controls")).toHaveStyle({
- flexDirection: "column",
- });
expect(screen.getByRole("button", { name: /Thought/ })).toHaveStyle({
flexDirection: "column",
});
expect(screen.queryByText("Detailed reasoning\nSecond step")).toBeNull();
expect(screen.getByText("Test server").props.numberOfLines).toBeUndefined();
expect(screen.getByText("Test server")).toHaveStyle({ flex: 0, width: "100%" });
- expect(
- screen.getByRole("header", { name: "Transcript session" }).props.maxFontSizeMultiplier,
- ).toBe(1.4);
const awayFromLiveEdge = {
nativeEvent: {
diff --git a/apps/mobile/src/screens/workspace-screen.test.ts b/apps/mobile/src/screens/workspace-screen.test.ts
index 7637a02..ed422d8 100644
--- a/apps/mobile/src/screens/workspace-screen.test.ts
+++ b/apps/mobile/src/screens/workspace-screen.test.ts
@@ -7,7 +7,7 @@ import {
projectDirectories,
} from "./workspace-screen-model";
-test("measures the keyboard dock once per window size", () => {
+test("measures the keyboard dock once per physical screen size", () => {
expect(needsComposerDockMeasurement(undefined, 2_000, false)).toBe(true);
expect(needsComposerDockMeasurement(2_000, 2_000, false)).toBe(false);
expect(needsComposerDockMeasurement(2_000, 1_000, false)).toBe(true);
diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx
index 3260295..d162755 100644
--- a/apps/mobile/src/screens/workspace-screen.tsx
+++ b/apps/mobile/src/screens/workspace-screen.tsx
@@ -1,16 +1,11 @@
import {
- createOpenCodeSession,
getDefaultOpenCodeLocation,
getOpenCodeLocation,
getOpenCodeSession,
type LocationRef,
- listOpenCodeAgents,
listOpenCodeMessages,
- listOpenCodeModels,
listOpenCodeProjects,
- type ProjectListOutput,
removeOpenCodeSession,
- renameOpenCodeSession,
type SessionInfo,
type SessionMessageInfo,
type SessionMessagesResponse,
@@ -23,6 +18,7 @@ import { useCallback, useDeferredValue, useEffect, useRef, useState } from "reac
import {
ActivityIndicator,
Alert,
+ Dimensions,
FlatList,
Keyboard,
type KeyboardEvent,
@@ -39,7 +35,6 @@ import {
View,
} from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
-import { ModalSheet } from "../components/modal-sheet";
import { useConnections } from "../connections/connections-context";
import type { RootStackParamList } from "../navigation/root-navigation";
import { useConnectionRuntime } from "../state/connection-runtime-context";
@@ -70,27 +65,21 @@ import {
resolveTranscriptLiveFollow,
type TranscriptLiveFollowEvent,
} from "./session-transcript-live-follow";
-import {
- countRunningBackgroundSubagents,
- flattenTranscriptPages,
- sanitizeTranscriptText,
-} from "./session-transcript-model";
+import { flattenTranscriptPages } from "./session-transcript-model";
import { useSessionDraft } from "./use-session-draft";
import { useSessionExecution } from "./use-session-execution";
import {
getComposerDockKeyboardOffset,
needsComposerDockMeasurement,
- projectDirectories,
} from "./workspace-screen-model";
type WorkspaceProps = NativeStackScreenProps;
type SessionProps = NativeStackScreenProps;
-type Project = ProjectListOutput[number];
const messagePageSize = 40;
const maxTranscriptPages = 5;
const iosKeyboardTransparentTopInset = 32;
-const liveEdgeThreshold = 48;
+const liveEdgeThreshold = 2;
const userScrollSettleMs = 160;
const unresolvedLocation = { directory: "__unresolved__" } satisfies LocationRef;
@@ -107,16 +96,8 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
const client = runtime.restClient;
const [selectedProjectId, setSelectedProjectId] = useState();
const [selectedDirectory, setSelectedDirectory] = useState();
- const [projectPickerOpen, setProjectPickerOpen] = useState(false);
- const [projectSearch, setProjectSearch] = useState("");
const [sessionSearch, setSessionSearch] = useState("");
- const [createOpen, setCreateOpen] = useState(false);
- const [title, setTitle] = useState("");
- const [selectedAgentId, setSelectedAgentId] = useState();
- const [selectedModelKey, setSelectedModelKey] = useState();
- const [formError, setFormError] = useState();
const [refreshing, setRefreshing] = useState(false);
- const createAbortRef = useRef(null);
const removeAbortRef = useRef(null);
const refreshGenerationRef = useRef(0);
const deferredSessionSearch = useDeferredValue(sessionSearch.trim());
@@ -125,13 +106,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
void connectionId;
setSelectedProjectId(undefined);
setSelectedDirectory(undefined);
- setProjectPickerOpen(false);
- setProjectSearch("");
setSessionSearch("");
- setCreateOpen(false);
- setSelectedAgentId(undefined);
- setSelectedModelKey(undefined);
- setFormError(undefined);
refreshGenerationRef.current += 1;
setRefreshing(false);
}, [connectionId]);
@@ -181,8 +156,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
workspaceSelection.preferencesLoading,
]);
- const selectedProject = projects.find((project) => project.id === selectedProjectId);
- const directories = projectDirectories(selectedProject, defaultProjectId, defaultDirectory);
const requestedLocation = selectedDirectory
? ({ directory: selectedDirectory } satisfies LocationRef)
: undefined;
@@ -198,7 +171,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
),
});
const location = locationQuery.data;
- const queryLocation = location ?? unresolvedLocation;
const mutationScope = `${connectionId ?? ""}\u0000${location?.directory ?? ""}\u0000${location?.workspaceID ?? ""}`;
useEffect(() => {
@@ -208,7 +180,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
useEffect(() => {
void mutationScope;
return () => {
- createAbortRef.current?.abort();
removeAbortRef.current?.abort();
};
}, [mutationScope]);
@@ -217,104 +188,16 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
workspaceSelection.setLocation(location);
}, [location, workspaceSelection.setLocation]);
- const agentsQuery = useQuery({
- enabled: Boolean(client && connectionId && location),
- queryFn: ({ signal }) => {
- if (!client || !location) throw new Error("LOCATION_NOT_RESOLVED");
- return listOpenCodeAgents(client, location, { signal });
- },
- queryKey: openCodeQueryKeys.agents(connectionId ?? "unselected", queryLocation),
- });
- const modelsQuery = useQuery({
- enabled: Boolean(client && connectionId && location),
- queryFn: ({ signal }) => {
- if (!client || !location) throw new Error("LOCATION_NOT_RESOLVED");
- return listOpenCodeModels(client, location, { signal });
- },
- queryKey: openCodeQueryKeys.models(connectionId ?? "unselected", queryLocation),
- });
const inboxItems = workspaceInboxItems(workspaceSelection.inbox, Boolean(deferredSessionSearch));
const ambiguousProjectIDs = ambiguousInboxProjectIDs(workspaceSelection.inbox);
const sessionCount =
workspaceSelection.inbox.needsYou.length +
workspaceSelection.inbox.working.length +
workspaceSelection.inbox.recent.length;
- const agents = (agentsQuery.data?.data ?? []).filter(
- (candidate) => !candidate.hidden && candidate.mode !== "subagent",
- );
- const models = (modelsQuery.data?.data ?? []).filter(
- (candidate) => candidate.enabled && candidate.status !== "deprecated",
- );
- const selectedAgent = agents.find((candidate) => candidate.id === selectedAgentId);
- const selectedModel = models.find(
- (candidate) => modelKey(candidate.providerID, candidate.id) === selectedModelKey,
- );
- const normalizedProjectSearch = projectSearch.trim().toLocaleLowerCase();
- const visibleProjects = normalizedProjectSearch
- ? projects.filter((project) =>
- `${project.name ?? ""}\n${project.canonical}\n${project.id}`
- .toLocaleLowerCase()
- .includes(normalizedProjectSearch),
- )
- : projects;
- const visibleLocationProjects = visibleProjects.filter((project) =>
- workspaceSelection.followedProjectIds.includes(project.id),
- );
const selectedConnection = connections.profiles.find(
(profile) => profile.id === connections.selectedProfileId,
);
- const createMutation = useMutation({
- mutationFn: async () => {
- if (!client || !connectionId || !location) throw new Error("LOCATION_NOT_RESOLVED");
- const controller = new AbortController();
- createAbortRef.current?.abort();
- createAbortRef.current = controller;
- const nextTitle = title.trim();
- try {
- return await createOpenCodeSession(
- client,
- location,
- {
- ...(nextTitle ? { title: nextTitle } : {}),
- ...(selectedAgent ? { agent: selectedAgent.id } : {}),
- ...(selectedModel
- ? { model: { id: selectedModel.id, providerID: selectedModel.providerID } }
- : {}),
- },
- { signal: controller.signal },
- );
- } catch (error) {
- if (controller.signal.aborted) throw new Error("REQUEST_ABORTED");
- throw error;
- } finally {
- if (createAbortRef.current === controller) createAbortRef.current = null;
- }
- },
- onError: (error) => {
- if (error instanceof Error && error.message === "REQUEST_ABORTED") return;
- setFormError("The session could not be created. Check the selected location and connection.");
- },
- onSuccess: (session) => {
- if (!connectionId || !location) return;
- queryClient.setQueryData(
- openCodeQueryKeys.session(connectionId, location, session.id),
- session,
- );
- void queryClient.invalidateQueries({
- queryKey: openCodeQueryKeys.connection(connectionId),
- });
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
- () => undefined,
- );
- setFormError(undefined);
- setCreateOpen(false);
- setTitle("");
- setSelectedAgentId(undefined);
- setSelectedModelKey(undefined);
- navigation.navigate("Session", { connectionId, location, sessionID: session.id });
- },
- });
const removeMutation = useMutation({
mutationFn: async (session: SessionInfo) => {
if (!client || !connectionId) throw new Error("CONNECTION_NOT_READY");
@@ -380,25 +263,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
},
});
- function selectProject(project: Project) {
- setSelectedProjectId(project.id);
- setSelectedDirectory(
- project.id === defaultProjectId && defaultDirectory ? defaultDirectory : project.canonical,
- );
- setSessionSearch("");
- setFormError(undefined);
- void Haptics.selectionAsync().catch(() => undefined);
- }
-
- function selectDirectory(directory: string) {
- setSelectedDirectory(directory);
- setSessionSearch("");
- setFormError(undefined);
- setProjectPickerOpen(false);
- setCreateOpen(true);
- void Haptics.selectionAsync().catch(() => undefined);
- }
-
async function refresh() {
if (refreshing) return;
const generation = refreshGenerationRef.current + 1;
@@ -465,10 +329,10 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
setCreateOpen(true)}
+ onPress={() => navigation.navigate("NewSession")}
/>
) : null}
@@ -514,10 +378,10 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
/>
) : null}
setCreateOpen(true)}
+ onPress={() => navigation.navigate("NewSession")}
/>
>
) : null}
@@ -529,7 +393,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
: "Waiting for current server data."}
) : null}
-
);
- const locationSheet = (
- {
- setProjectPickerOpen(false);
- setCreateOpen(true);
- }}
- subtitle="Choose where new sessions start"
- title="Session location"
- visible={projectPickerOpen}
- >
-
-
- Followed projects
- {projectsQuery.isPending ? : null}
- {projectsQuery.isError ? : null}
- {visibleLocationProjects.map((project) => (
- selectProject(project)}
- style={({ pressed }) => [
- styles.sheetRow,
- project.id === selectedProjectId && styles.sheetRowSelected,
- pressed && styles.pressed,
- ]}
- >
-
- {projectLabel(project)}
-
- {project.canonical}
-
-
-
- {project.id === selectedProjectId ? "Selected" : ""}
-
-
- ))}
- {!projectsQuery.isPending && visibleLocationProjects.length === 0 ? (
- No matching followed projects.
- ) : null}
-
- {selectedProject ? (
-
- Location
- {directories.map((directory, index) => (
- selectDirectory(directory)}
- style={({ pressed }) => [
- styles.sheetRow,
- directory === selectedDirectory && styles.sheetRowSelected,
- pressed && styles.pressed,
- ]}
- >
-
-
- {index === 0 ? "Current checkout" : "Worktree"}
-
-
- {directory}
-
-
-
- {directory === selectedDirectory ? "Selected" : ""}
-
-
- ))}
-
- ) : null}
- {
- setProjectPickerOpen(false);
- navigation.navigate("FollowedProjects");
- }}
- secondary
- />
-
- );
-
- const newSessionSheet = (
- setCreateOpen(false)}
- {...(selectedProject ? { subtitle: projectLabel(selectedProject) } : {})}
- title="New session"
- visible={createOpen}
- >
-
-
- What needs doing?
-
-
- Name the task now. You can send the first message from the session transcript.
-
-
-
- {
- setCreateOpen(false);
- setProjectPickerOpen(true);
- }}
- style={({ pressed }) => [
- styles.contextRow,
- largeText && styles.contextRowLargeText,
- pressed && styles.pressed,
- ]}
- >
-
- Location
-
- {selectedProject ? projectLabel(selectedProject) : "Choose a followed project"}
-
- {selectedDirectory ? (
-
- {selectedDirectory}
-
- ) : null}
-
- {locationQuery.isPending ? (
-
- ) : (
-
- {selectedDirectory ? "Change" : "Choose"}
-
- )}
-
- {locationQuery.isError ? (
-
- ) : null}
- ({
- description: candidate.description ?? candidate.id,
- key: candidate.id,
- label: candidate.name,
- }))}
- selectedKey={selectedAgentId}
- selectedLabel={selectedAgent?.name}
- />
- ({
- description: `${candidate.providerID} / ${candidate.id}`,
- key: modelKey(candidate.providerID, candidate.id),
- label: candidate.name,
- }))}
- selectedKey={selectedModelKey}
- selectedLabel={selectedModel?.name}
- />
- {agentsQuery.isError || modelsQuery.isError ? (
-
- ) : null}
- {formError ? : null}
- createMutation.mutate()}
- />
-
- );
-
return (
- {locationSheet}
- {newSessionSheet}
);
}
export function SessionScreen({ navigation, route }: SessionProps) {
- const db = useSQLiteContext();
const runtime = useConnectionRuntime();
const workspaceSelection = useWorkspaceSelection();
- const connections = useConnections();
- const queryClient = useQueryClient();
- const { fontScale, height: windowHeight } = useWindowDimensions();
+ const { fontScale } = useWindowDimensions();
+ const screenHeight = Dimensions.get("screen").height;
const largeText = usesLargeTextLayout(fontScale);
- const { connectionId: routeConnectionId, location, sessionID } = route.params;
+ const { connectionId: routeConnectionId, focusComposer, location, sessionID } = route.params;
const connectionId = runtime.connectionId;
const client = runtime.restClient;
- const [title, setTitle] = useState("");
- const [error, setError] = useState();
const [liveFollowEnabled, setLiveFollowEnabled] = useState(true);
const [latestJumpPending, setLatestJumpPending] = useState(false);
const [composerDockHeight, setComposerDockHeight] = useState(66);
const [composerDockScreenBottom, setComposerDockScreenBottom] = useState(0);
const [composerKeyboardOffset, setComposerKeyboardOffset] = useState(0);
const composerDockRef = useRef(null);
- const measuredComposerDockWindowHeightRef = useRef(undefined);
+ const measuredComposerDockScreenHeightRef = useRef(undefined);
const transcriptListRef = useRef>(null);
const liveFollowEnabledRef = useRef(true);
const latestJumpPendingRef = useRef(false);
@@ -868,11 +524,6 @@ export function SessionScreen({ navigation, route }: SessionProps) {
const userScrollSettleTimerRef = useRef>(null);
const followFrameRef = useRef(null);
const lastScrollOffsetRef = useRef(0);
- const renameAbortRef = useRef(null);
- const removeAbortRef = useRef(null);
- const selectedConnection = connections.profiles.find(
- (profile) => profile.id === connections.selectedProfileId,
- );
const sessionQuery = useQuery({
enabled: Boolean(client && connectionId === routeConnectionId),
queryFn: ({ signal }) => {
@@ -922,7 +573,6 @@ export function SessionScreen({ navigation, route }: SessionProps) {
(request) => request.sessionID === sessionID,
);
const sessionForms = workspaceSelection.forms.filter((form) => form.sessionID === sessionID);
- const runningBackgroundSubagents = countRunningBackgroundSubagents(messages);
const sessionMutationScope = `${routeConnectionId}\u0000${sessionID}`;
const transcriptPageCount = messagesQuery.data?.pages.length ?? 0;
const canLoadOlder = Boolean(
@@ -944,13 +594,15 @@ export function SessionScreen({ navigation, route }: SessionProps) {
}, [location, workspaceSelection.setLocation]);
useEffect(() => {
+ if (Platform.OS !== "ios") return;
+
function updateKeyboardFrame(event: KeyboardEvent) {
Keyboard.scheduleLayoutAnimation(event);
setComposerKeyboardOffset(
getComposerDockKeyboardOffset(
composerDockScreenBottom,
event.endCoordinates.screenY,
- Platform.OS === "ios" ? iosKeyboardTransparentTopInset : 0,
+ iosKeyboardTransparentTopInset,
),
);
}
@@ -961,18 +613,10 @@ export function SessionScreen({ navigation, route }: SessionProps) {
}
const frameSubscriptions = [
- Keyboard.addListener(
- Platform.OS === "ios" ? "keyboardWillChangeFrame" : "keyboardDidShow",
- updateKeyboardFrame,
- ),
- ...(Platform.OS === "ios"
- ? [Keyboard.addListener("keyboardDidShow", updateKeyboardFrame)]
- : []),
+ Keyboard.addListener("keyboardWillChangeFrame", updateKeyboardFrame),
+ Keyboard.addListener("keyboardDidShow", updateKeyboardFrame),
];
- const hideSubscription = Keyboard.addListener(
- Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide",
- clearKeyboardFrame,
- );
+ const hideSubscription = Keyboard.addListener("keyboardWillHide", clearKeyboardFrame);
return () => {
for (const subscription of frameSubscriptions) subscription.remove();
hideSubscription.remove();
@@ -1004,145 +648,13 @@ export function SessionScreen({ navigation, route }: SessionProps) {
cancelAnimationFrame(followFrameRef.current);
followFrameRef.current = null;
}
- renameAbortRef.current?.abort();
- removeAbortRef.current?.abort();
};
}, [sessionMutationScope]);
- useEffect(() => {
- if (session) setTitle(session.title ?? "");
- }, [session]);
-
useEffect(() => {
recordTranscriptResidentSet(transcriptPageCount, messages.length);
}, [messages.length, transcriptPageCount]);
- const renameMutation = useMutation({
- mutationFn: async () => {
- const nextTitle = title.trim();
- if (!nextTitle) throw new Error("TITLE_REQUIRED");
- if (!client || connectionId !== routeConnectionId) throw new Error("CONNECTION_NOT_READY");
- const controller = new AbortController();
- renameAbortRef.current?.abort();
- renameAbortRef.current = controller;
- try {
- await renameOpenCodeSession(client, sessionID, nextTitle, { signal: controller.signal });
- return nextTitle;
- } catch (caught) {
- if (controller.signal.aborted) throw new Error("REQUEST_ABORTED");
- throw caught;
- } finally {
- if (renameAbortRef.current === controller) renameAbortRef.current = null;
- }
- },
- onError: (caught) => {
- if (caught instanceof Error && caught.message === "REQUEST_ABORTED") return;
- setError(
- caught instanceof Error && caught.message === "TITLE_REQUIRED"
- ? "Enter a session title."
- : "The session could not be renamed.",
- );
- },
- onSuccess: (nextTitle) => {
- setError(undefined);
- queryClient.setQueryData(
- openCodeQueryKeys.session(connectionId ?? "unselected", location, sessionID),
- (current) => (current ? { ...current, title: nextTitle } : current),
- );
- if (connectionId) {
- void queryClient.invalidateQueries({
- queryKey: openCodeQueryKeys.connection(connectionId),
- });
- }
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(
- () => undefined,
- );
- },
- });
- const removeMutation = useMutation({
- mutationFn: async () => {
- if (!client || connectionId !== routeConnectionId) throw new Error("CONNECTION_NOT_READY");
- const controller = new AbortController();
- removeAbortRef.current?.abort();
- removeAbortRef.current = controller;
- try {
- const sessionIds = await loadOpenCodeSessionTreeIds(
- client,
- location,
- sessionID,
- controller.signal,
- );
- await removeOpenCodeSession(client, sessionID, { signal: controller.signal });
- const cleanupSucceeded = await deleteSessionLocalState(
- db,
- routeConnectionId,
- sessionIds,
- ).then(
- () => true,
- () => false,
- );
- return { cleanupSucceeded, sessionIds };
- } catch (caught) {
- if (controller.signal.aborted) throw new Error("REQUEST_ABORTED");
- throw caught;
- } finally {
- if (removeAbortRef.current === controller) removeAbortRef.current = null;
- }
- },
- onError: (caught) => {
- if (caught instanceof Error && caught.message === "REQUEST_ABORTED") return;
- setError("The session could not be deleted.");
- },
- onSuccess: ({ cleanupSucceeded, sessionIds }) => {
- if (connectionId) {
- for (const deletedSessionID of sessionIds) {
- queryClient.removeQueries({
- queryKey: openCodeQueryKeys.session(connectionId, location, deletedSessionID),
- });
- queryClient.removeQueries({
- queryKey: openCodeQueryKeys.messageRoot(connectionId, location, deletedSessionID),
- });
- queryClient.removeQueries({
- queryKey: openCodeQueryKeys.inbox(connectionId, location, deletedSessionID),
- });
- queryClient.removeQueries({
- queryKey: openCodeQueryKeys.promptAdmissions(connectionId, location, deletedSessionID),
- });
- }
- void queryClient.invalidateQueries({
- queryKey: openCodeQueryKeys.connection(connectionId),
- });
- }
- draft.clearDraft();
- if (!cleanupSucceeded) {
- Alert.alert(
- "Local cleanup incomplete",
- "The server deleted the session, but encrypted local state could not be removed. Removing this connection profile will clear it.",
- );
- }
- void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(
- () => undefined,
- );
- navigation.popTo("Workspace");
- },
- });
-
- function confirmDelete() {
- const host = selectedConnection?.name ?? "selected server";
- const project = session?.projectID ?? "current project";
- const childWarning = session?.parentID
- ? "This is a child session."
- : "Deleting a parent also deletes all child sessions.";
- Alert.alert(
- "Delete session?",
- `Host: ${host}\nProject: ${project}\nLocation: ${location.directory}\n\n${childWarning}`,
- [
- { style: "cancel", text: "Cancel" },
- { onPress: () => removeMutation.mutate(), style: "destructive", text: "Delete" },
- ],
- );
- }
-
function transitionLiveFollow(event: TranscriptLiveFollowEvent) {
const next = resolveTranscriptLiveFollow(liveFollowEnabledRef.current, event);
if (next === liveFollowEnabledRef.current) return;
@@ -1253,15 +765,15 @@ export function SessionScreen({ navigation, route }: SessionProps) {
function measureComposerDock() {
if (
!needsComposerDockMeasurement(
- measuredComposerDockWindowHeightRef.current,
- windowHeight,
+ measuredComposerDockScreenHeightRef.current,
+ screenHeight,
composerKeyboardOffset > 0,
)
) {
return;
}
composerDockRef.current?.measureInWindow((_x, y, _width, height) => {
- measuredComposerDockWindowHeightRef.current = windowHeight;
+ measuredComposerDockScreenHeightRef.current = screenHeight;
const screenBottom = y + height;
setComposerDockScreenBottom((current) =>
Math.abs(current - screenBottom) < 1 ? current : screenBottom,
@@ -1325,11 +837,7 @@ export function SessionScreen({ navigation, route }: SessionProps) {
}
/>
- ) : (
-
- No messages in this session.
-
- )
+ ) : null
}
ListFooterComponent={
@@ -1337,102 +845,14 @@ export function SessionScreen({ navigation, route }: SessionProps) {
{sessionQuery.isError ? (
) : null}
- {session ? (
- <>
- SESSION
-
- {sanitizeTranscriptText(session.title || "Untitled session", 512)}
-
-
- {session.parentID ? : }
- {workspaceSelection.blockedSessionIds.has(session.id) ? (
-
- ) : null}
- {session.time.archived ? : null}
- {runtime.status !== "connected" ? : null}
-
-
- {sanitizeTranscriptText(session.location.directory, 1_024)}
-
-
-
-
- RENAME
-
-
- {error ? : null}
- renameMutation.mutate()}
- />
-
- [styles.deleteButton, pressed && styles.pressed]}
- >
-
- {removeMutation.isPending ? "DELETING" : "DELETE SESSION"}
-
-
- >
+ {canLoadOlder ? (
+ {
+ if (!messagesQuery.isFetchingNextPage) void messagesQuery.fetchNextPage();
+ }}
+ />
) : null}
-
-
-
-
- TRANSCRIPT
-
-
- {messages.length} {messages.length === 1 ? "message" : "messages"} loaded
-
- {runningBackgroundSubagents > 0 ? (
-
- {runningBackgroundSubagents}{" "}
- {runningBackgroundSubagents === 1
- ? "background subagent running"
- : "background subagents running"}
-
- ) : null}
-
-
- {
- if (!messagesQuery.isRefetching) {
- void Promise.all([sessionQuery.refetch(), messagesQuery.refetch()]);
- }
- }}
- />
- {canLoadOlder ? (
- {
- if (!messagesQuery.isFetchingNextPage) void messagesQuery.fetchNextPage();
- }}
- />
- ) : null}
-
-
{!canLoadOlder && messagesQuery.hasNextPage ? (
Older messages are not loaded on this device.
@@ -1523,6 +943,7 @@ export function SessionScreen({ navigation, route }: SessionProps) {
draft={draft.draft}
editable={draft.loaded}
error={execution.error ?? draft.error}
+ focusOnMount={focusComposer}
largeText={largeText}
model={execution.selectedModel}
models={execution.models}
@@ -1718,23 +1139,11 @@ function SessionEmptyState({
);
}
-function Badge({ label, muted }: { label: string; muted?: boolean }) {
- return (
-
-
- {label}
-
-
- );
-}
-
function HeaderAction({
accessibilityHint,
accessibilityLabel,
attention,
+ disabled,
emphasized,
label,
onPress,
@@ -1742,6 +1151,7 @@ function HeaderAction({
accessibilityHint: string;
accessibilityLabel?: string;
attention?: boolean;
+ disabled?: boolean;
emphasized?: boolean;
label: string;
onPress: () => void;
@@ -1751,11 +1161,14 @@ function HeaderAction({
accessibilityHint={accessibilityHint}
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
+ accessibilityState={{ disabled: Boolean(disabled) }}
+ disabled={disabled}
onPress={onPress}
style={({ pressed }) => [
styles.headerAction,
attention && styles.headerActionAttention,
emphasized && styles.headerActionEmphasized,
+ disabled && styles.disabled,
pressed && styles.pressed,
]}
>
@@ -1787,92 +1200,6 @@ function SmallButton({ label, onPress }: { label: string; onPress: () => void })
);
}
-function OptionPicker({
- label,
- loading,
- onSelect,
- options,
- selectedKey,
- selectedLabel,
-}: {
- label: string;
- loading: boolean;
- onSelect: (key: string | undefined) => void;
- options: { description: string; key: string; label: string }[];
- selectedKey: string | undefined;
- selectedLabel: string | undefined;
-}) {
- const [open, setOpen] = useState(false);
- const [search, setSearch] = useState("");
- const normalizedSearch = search.trim().toLocaleLowerCase();
- const visible = (
- normalizedSearch
- ? options.filter((option) =>
- `${option.label}\n${option.description}`.toLocaleLowerCase().includes(normalizedSearch),
- )
- : options
- ).slice(0, 50);
-
- return (
-
-
-
- {label}
- {selectedLabel ?? "Server default"}
-
- setOpen((value) => !value)} />
-
- {open ? (
-
-
- {
- onSelect(undefined);
- setOpen(false);
- }}
- style={styles.pickerRow}
- >
- Server default
-
- {loading ? : null}
- {visible.map((option) => (
- {
- onSelect(option.key);
- setOpen(false);
- }}
- style={[styles.pickerRow, option.key === selectedKey && styles.pickerRowSelected]}
- >
- {option.label}
-
- {option.description}
-
-
- ))}
- {!loading && visible.length === 0 ? (
- No matching choices.
- ) : null}
-
- ) : null}
-
- );
-}
-
function InlineError({ message }: { message: string }) {
return (
@@ -1881,18 +1208,6 @@ function InlineError({ message }: { message: string }) {
);
}
-function projectLabel(project: Project) {
- return project.name?.trim() || basename(project.canonical) || project.id;
-}
-
-function basename(path: string) {
- return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
-}
-
-function modelKey(providerID: string, id: string) {
- return `${providerID}\u0000${id}`;
-}
-
function formatSessionTime(value: number) {
const elapsedMs = Date.now() - value;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return "Now";
@@ -2051,6 +1366,7 @@ const styles = StyleSheet.create({
paddingHorizontal: space.md,
},
deleteLabel: { color: palette.danger, fontSize: 12, fontWeight: "900", letterSpacing: 0.8 },
+ disabled: { opacity: 0.45 },
detailContent: {
alignSelf: "center",
maxWidth: 720,
diff --git a/apps/mobile/src/state/followed-project-inbox.test.ts b/apps/mobile/src/state/followed-project-inbox.test.ts
index 70be260..3fd1cc8 100644
--- a/apps/mobile/src/state/followed-project-inbox.test.ts
+++ b/apps/mobile/src/state/followed-project-inbox.test.ts
@@ -270,6 +270,36 @@ test("keeps existing recent rows stable across competing timestamp updates", ()
).toEqual(["ses_second", "ses_first"]);
});
+test("inserts newly discovered sessions by recency without reordering existing rows", () => {
+ const first = session("ses_first", 3, "project-a");
+ const second = session("ses_second", 2, "project-a");
+ const previous = buildFollowedInboxSections({
+ activeSessionIDs: [],
+ ancestrySessions: {},
+ forms: [],
+ permissions: [],
+ projects: [],
+ rootSessions: [first, second],
+ });
+ const next = buildFollowedInboxSections({
+ activeSessionIDs: [],
+ ancestrySessions: {},
+ forms: [],
+ permissions: [],
+ projects: [],
+ rootSessions: [
+ session("ses_new", 4, "project-a"),
+ first,
+ second,
+ session("ses_older", 1, "project-a"),
+ ],
+ });
+
+ expect(
+ stabilizeFollowedInboxSections(next, previous).recent.map((row) => row.session.id),
+ ).toEqual(["ses_new", "ses_first", "ses_second", "ses_older"]);
+});
+
function session(id: string, updated: number, projectID: string): SessionInfo {
return {
cost: 0,
diff --git a/apps/mobile/src/state/followed-project-inbox.ts b/apps/mobile/src/state/followed-project-inbox.ts
index df9f46f..5ca3838 100644
--- a/apps/mobile/src/state/followed-project-inbox.ts
+++ b/apps/mobile/src/state/followed-project-inbox.ts
@@ -320,5 +320,9 @@ function preserveSectionOrder(next: FollowedInboxRow[], previous: FollowedInboxR
nextByID.delete(row.session.id);
return [current];
});
- return [...stable, ...next.filter((row) => nextByID.has(row.session.id))];
+ for (const row of next.filter((candidate) => nextByID.has(candidate.session.id))) {
+ const index = stable.findIndex((current) => compareSessions(row.session, current.session) < 0);
+ stable.splice(index < 0 ? stable.length : index, 0, row);
+ }
+ return stable;
}
diff --git a/packages/opencode-adapter/src/index.test.ts b/packages/opencode-adapter/src/index.test.ts
index f4b95c6..3f95098 100644
--- a/packages/opencode-adapter/src/index.test.ts
+++ b/packages/opencode-adapter/src/index.test.ts
@@ -12,6 +12,7 @@ import {
createOpenCodeSession,
createRedirectSafeOpenCodeFetch,
getCurrentOpenCodeProject,
+ getDefaultOpenCodeAgent,
getDefaultOpenCodeLocation,
getDefaultOpenCodeModel,
getOpenCodeFormState,
@@ -416,6 +417,43 @@ it("validates and forwards location-scoped agent and model choices", async () =>
);
});
+it("resolves the highest-priority configured default agent", async () => {
+ const api = createFakeOpenCodeApi({
+ configEntries: [
+ { info: { default_agent: "plan" }, path: "/global/opencode.json", type: "document" },
+ { path: "/workspace/.opencode", type: "directory" },
+ { path: "/workspace/.opencode/agents", type: "agents" },
+ { info: {}, path: "/workspace/opencode.json", type: "document" },
+ {
+ info: { default_agent: "review" },
+ path: "/workspace/.opencode/opencode.json",
+ type: "document",
+ },
+ ],
+ });
+ const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch });
+
+ await expect(
+ getDefaultOpenCodeAgent(client, { directory: "/workspace", workspaceID: "wrk_test" }),
+ ).resolves.toBe("review");
+ expect(api.requests.at(-1)).toMatchObject({
+ path: "/api/config",
+ query: {
+ "location[directory]": ["/workspace"],
+ "location[workspace]": ["wrk_test"],
+ },
+ });
+});
+
+it("returns null when no config document defines a default agent", async () => {
+ const api = createFakeOpenCodeApi({
+ configEntries: [{ info: {}, path: "/workspace/opencode.json", type: "document" }],
+ });
+ const client = createOpenCodeClient({ baseUrl: "https://fake.invalid", fetch: api.fetch });
+
+ await expect(getDefaultOpenCodeAgent(client, { directory: "/workspace" })).resolves.toBeNull();
+});
+
it("forwards composer and execution operations and returns generated inbox values", async () => {
const item = {
delivery: "queue" as const,
diff --git a/packages/opencode-adapter/src/index.ts b/packages/opencode-adapter/src/index.ts
index d0274ec..8e9fcff 100644
--- a/packages/opencode-adapter/src/index.ts
+++ b/packages/opencode-adapter/src/index.ts
@@ -191,6 +191,33 @@ export async function listOpenCodeAgents(
return output;
}
+export async function getDefaultOpenCodeAgent(
+ client: OpenCodeClient,
+ location: LocationRef,
+ options?: OpenCodeRequestOptions,
+) {
+ const entries = await client.config.get({ location: locationInput(location) }, options);
+ if (!Array.isArray(entries)) throw new Error("MALFORMED_CONFIG_LIST");
+ let defaultAgent: string | undefined;
+ for (const entry of entries) {
+ if (
+ !isRecord(entry) ||
+ !["agents", "claude", "directory", "document"].includes(String(entry.type))
+ ) {
+ throw new Error("MALFORMED_CONFIG_LIST");
+ }
+ if (entry.type !== "document") continue;
+ if (!isRecord(entry.info)) throw new Error("MALFORMED_CONFIG_LIST");
+ const candidate = entry.info.default_agent;
+ if (candidate === undefined) continue;
+ if (typeof candidate !== "string" || !candidate.trim()) {
+ throw new Error("MALFORMED_CONFIG_LIST");
+ }
+ defaultAgent = candidate;
+ }
+ return defaultAgent ?? null;
+}
+
export async function listOpenCodeModels(
client: OpenCodeClient,
location: LocationRef,
diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts
index 15d34d2..75372c9 100644
--- a/packages/test-fixtures/src/index.ts
+++ b/packages/test-fixtures/src/index.ts
@@ -1,5 +1,6 @@
export type FakeOpenCodeApiOptions = {
agents?: unknown[];
+ configEntries?: unknown[];
eventFrame?: string;
failures?: Record;
forms?: unknown[];
@@ -99,6 +100,9 @@ export function createFakeOpenCodeApi(options: FakeOpenCodeApiOptions = {}) {
if (url.pathname === "/api/agent") {
return json({ location: resolvedLocation(options, url), data: options.agents ?? [] });
}
+ if (url.pathname === "/api/config") {
+ return json(options.configEntries ?? []);
+ }
if (url.pathname === "/api/model") {
return json({ location: resolvedLocation(options, url), data: options.models ?? [] });
}