From 9af07ca49cc8903ba2d11549930c87d158a0b5ff Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Wed, 26 Aug 2026 13:25:19 +0200 Subject: [PATCH 1/3] Stabilize mobile command updates and composer submission --- .../src/screens/session-composer.test.tsx | 20 ++++++++++ apps/mobile/src/screens/session-composer.tsx | 7 +++- .../connection-event-query-bridge.test.ts | 28 ++++++++++++++ .../state/connection-event-query-bridge.ts | 13 ++++++- .../connection-transport-coordinator.test.ts | 31 +++++++++++++++- docs/COMPATIBILITY.md | 37 +++++++++++++++++++ 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/screens/session-composer.test.tsx b/apps/mobile/src/screens/session-composer.test.tsx index ec9c4e5..aa2e3e7 100644 --- a/apps/mobile/src/screens/session-composer.test.tsx +++ b/apps/mobile/src/screens/session-composer.test.tsx @@ -48,6 +48,26 @@ test("dismisses the keyboard and collapses the composer after sending", () => { dismissKeyboard.mockRestore(); }); +test("closes before publishing an immediate active-session transition", () => { + let composerClosed = false; + const dismissKeyboard = jest.spyOn(Keyboard, "dismiss").mockImplementation(() => { + composerClosed = true; + }); + const onSubmit = jest.fn(() => { + expect(composerClosed).toBe(true); + }); + render(); + + const input = screen.getByLabelText("Prompt"); + fireEvent.changeText(input, "Ship it"); + fireEvent(input, "focus"); + fireEvent.press(screen.getByRole("button", { name: "Send" })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText("Prompt").props.numberOfLines).toBe(1); + dismissKeyboard.mockRestore(); +}); + test("keeps controls collapsed until the editor is focused", () => { render(); diff --git a/apps/mobile/src/screens/session-composer.tsx b/apps/mobile/src/screens/session-composer.tsx index 18d1c42..5a9a99c 100644 --- a/apps/mobile/src/screens/session-composer.tsx +++ b/apps/mobile/src/screens/session-composer.tsx @@ -1,5 +1,5 @@ import type { AgentInfo, ModelInfo, ModelRef } from "@opencode2-mobile/opencode-adapter"; -import { useDeferredValue, useState } from "react"; +import { useDeferredValue, useRef, useState } from "react"; import { Keyboard, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; import { ModalSheet } from "../components/modal-sheet"; @@ -43,6 +43,7 @@ export function SessionComposer({ onModelChange: (model: ModelRef) => void; onSubmit: () => void; }) { + const inputRef = useRef(null); const [agentPickerOpen, setAgentPickerOpen] = useState(false); const [focused, setFocused] = useState(false); const [modelPickerOpen, setModelPickerOpen] = useState(false); @@ -67,9 +68,10 @@ export function SessionComposer({ function submit() { if (!canSubmit) return; - onSubmit(); + inputRef.current?.blur(); setFocused(false); Keyboard.dismiss(); + onSubmit(); } return ( @@ -91,6 +93,7 @@ export function SessionComposer({ onFocus={() => setFocused(true)} placeholder={active ? "Add a follow-up" : "Ask OpenCode"} placeholderTextColor={palette.dim} + ref={inputRef} returnKeyType="default" scrollEnabled={expanded} selectionColor={palette.signal} diff --git a/apps/mobile/src/state/connection-event-query-bridge.test.ts b/apps/mobile/src/state/connection-event-query-bridge.test.ts index 80b11b5..cf78078 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.test.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts @@ -174,6 +174,34 @@ test("does not refetch connection queries for file-change hints", () => { queryClient.clear(); }); +test("does not refetch connection queries for shell and VCS advisory events", () => { + const queryClient = new QueryClient(); + const invalidate = jest.spyOn(queryClient, "invalidateQueries"); + const scheduled: Array<() => void> = []; + const bridge = new ConnectionEventQueryBridge(queryClient, "connection-1", (callback) => { + scheduled.push(callback); + }); + + for (const [index, type] of [ + "shell.created", + "shell.exited", + "shell.deleted", + "vcs.branch.updated", + ].entries()) { + bridge.apply({ + created: index + 1, + data: {}, + id: `event-shell-${index}`, + location: { directory: "/workspace" }, + type, + } as unknown as OpenCodeEvent); + } + + expect(scheduled).toHaveLength(0); + expect(invalidate).not.toHaveBeenCalled(); + queryClient.clear(); +}); + test("falls back to connection reconciliation for an unknown session event", () => { const queryClient = new QueryClient(); const invalidate = jest.spyOn(queryClient, "invalidateQueries"); diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index 1eac594..6bccd4d 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -259,11 +259,11 @@ export function reduceActiveSessions( } export function eventRequiresConnectionSnapshot(event: OpenCodeEvent) { - return event.type.startsWith("installation."); + return event.type === "installation.updated"; } function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefined { - if (event.type === "server.connected" || event.type === "filesystem.changed") return undefined; + if (advisoryLocationEventTypes.has(event.type)) return undefined; if (inboxEventTypes.has(event.type)) return "inbox"; if (event.type === "session.status" || event.type === "session.execution.started") { return undefined; @@ -284,6 +284,15 @@ function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefin return "connection"; } +const advisoryLocationEventTypes = new Set([ + "filesystem.changed", + "server.connected", + "shell.created", + "shell.deleted", + "shell.exited", + "vcs.branch.updated", +]); + const inboxEventTypes = new Set([ "session.inbox.enqueued", "session.inbox.delivered", diff --git a/apps/mobile/src/state/connection-transport-coordinator.test.ts b/apps/mobile/src/state/connection-transport-coordinator.test.ts index e7aee04..468d767 100644 --- a/apps/mobile/src/state/connection-transport-coordinator.test.ts +++ b/apps/mobile/src/state/connection-transport-coordinator.test.ts @@ -1,7 +1,7 @@ import { expect, jest, test } from "@jest/globals"; import type { OpenCodeClient, OpenCodeEvent } from "@opencode2-mobile/opencode-adapter"; - +import { eventRequiresConnectionSnapshot } from "./connection-event-query-bridge"; import { ConnectionTransportCoordinator, type ConnectionTransportCoordinatorOptions, @@ -185,6 +185,35 @@ test("reconciles coordinator-owned roots for an uncertain event type", async () expect(onSnapshot).toHaveBeenCalledTimes(2); }); +test("keeps a healthy generation live for installation advisory events", async () => { + const stream = createEventStream(); + const onSnapshot = jest.fn(); + const statuses: ConnectionTransportStatus[] = []; + const coordinator = createCoordinator({ + eventClient: { event: { subscribe: stream.subscribe } } as never, + onSnapshot, + onStatus: (status) => statuses.push(status), + restClient: createSnapshotClient(true).client, + shouldReconcileEvent: eventRequiresConnectionSnapshot, + }); + coordinator.start(); + await flush(); + const settledStatuses = [...statuses]; + + stream.push({ + data: {}, + id: "event-installation", + type: "installation.update-available", + } as unknown as OpenCodeEvent); + await flush(); + + expect(statuses).toEqual(settledStatuses); + expect(statuses.at(-1)).toBe("connected"); + expect(stream.generations).toBe(1); + expect(onSnapshot).toHaveBeenCalledTimes(1); + coordinator.stop(); +}); + test("rejects malformed authoritative snapshots", async () => { const stream = createEventStream(); const statuses: ConnectionTransportStatus[] = []; diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index a9416a3..1eaef75 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -543,3 +543,40 @@ slot component. The probe recorded no address, credential, token, pairing code, identifier, prompt, path, form response, or server content. + +## 2026-08-26: physical iPhone command-event stability + +### Stack + +- Mobile runtime: signed EAS preview build with an iOS EAS Update using Hermes +- Expo SDK: 54.0.37 +- React Native: 0.81.5 +- OpenCode server: beta 18286 +- Mobile client contract: beta 18050 + +### Results + +| Probe | Result | +| --- | --- | +| Capture payload-free event types during shell-backed TUI work | Pass | +| Keep the server event stream open throughout each command burst | Pass | +| Avoid connection-wide invalidation for `shell.created`, `shell.exited`, and `shell.deleted` | Pass | +| Avoid connection-wide invalidation for `vcs.branch.updated` | Pass | +| Keep `installation.update-available` on the current healthy stream generation | Pass | +| Keep the Live indicator stable during read-only Git work | Pass | +| Keep the session list stable during file creation, patching, reading, hashing, deletion, and Git work | Pass | +| Remove the temporary probe file without changing repository files | Pass | + +The beta 18286 event trace showed a shell lifecycle burst for every shell-backed +TUI tool call. The beta 18050 mobile classifier did not know these event types, +so it fell back to connection-wide invalidation and repeatedly refetched the +session list. The mobile bridge now treats the shell lifecycle and branch-change +events as advisory for the current foundation UI. An available-update advisory +also no longer replaces a healthy stream; `installation.updated`, real stream +failure, durable sequence uncertainty, foreground recovery, and network recovery +retain their reconciliation behavior. + +The signed iPhone applied the preview update and showed no Live or session-list +flicker during the controlled command checks. The trace and report retained no +command text, address, credential, path, prompt, identifier, event payload, file +content, or server content. From fff34424a6d00a6b788a3fb8cb92ff8ea4a52483 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Wed, 26 Aug 2026 15:09:59 +0200 Subject: [PATCH 2/3] Fix mobile session creation and transcript behavior --- apps/mobile/app.config.ts | 5 +- apps/mobile/src/components/modal-sheet.tsx | 21 +- .../src/navigation/root-navigation.test.tsx | 13 +- .../mobile/src/navigation/root-navigation.tsx | 14 +- .../src/screens/new-session-screen.test.tsx | 194 +++++ .../mobile/src/screens/new-session-screen.tsx | 637 +++++++++++++++ .../src/screens/session-composer.test.tsx | 3 + apps/mobile/src/screens/session-composer.tsx | 184 +++-- .../session-transcript-live-follow.test.ts | 7 + .../screens/session-transcript-live-follow.ts | 2 +- .../screens/use-session-execution.test.tsx | 10 +- .../src/screens/use-session-execution.ts | 31 +- .../src/screens/workspace-screen-model.ts | 6 +- .../workspace-screen.integration.test.tsx | 46 +- .../src/screens/workspace-screen.test.ts | 2 +- apps/mobile/src/screens/workspace-screen.tsx | 754 +----------------- .../src/state/followed-project-inbox.test.ts | 30 + .../src/state/followed-project-inbox.ts | 6 +- packages/opencode-adapter/src/index.test.ts | 38 + packages/opencode-adapter/src/index.ts | 27 + packages/test-fixtures/src/index.ts | 4 + 21 files changed, 1214 insertions(+), 820 deletions(-) create mode 100644 apps/mobile/src/screens/new-session-screen.test.tsx create mode 100644 apps/mobile/src/screens/new-session-screen.tsx diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 40e31b3..96e056f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -54,7 +54,7 @@ const config: ExpoConfig = { : { enabled: false }, ios: { bundleIdentifier: iosBundleIdentifier, - buildNumber: "5", + buildNumber: "6", supportsTablet: true, config: { usesNonExemptEncryption: false, @@ -70,9 +70,10 @@ const config: ExpoConfig = { android: { package: androidPackage, ...(googleServicesFile ? { googleServicesFile } : {}), - versionCode: 3, + versionCode: 4, allowBackup: false, predictiveBackGestureEnabled: false, + softwareKeyboardLayoutMode: "resize", adaptiveIcon: { foregroundImage: "./assets/adaptive-icon.png", monochromeImage: "./assets/adaptive-icon-monochrome.png", 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 ?? [] }); } From 2e15dc8f18635486933305592cb6946f43c61c11 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Wed, 26 Aug 2026 15:14:43 +0200 Subject: [PATCH 3/3] Bump mobile preview version to 0.1.3 --- apps/mobile/app.config.ts | 6 +++--- apps/mobile/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 96e056f..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: "6", + buildNumber: "7", supportsTablet: true, config: { usesNonExemptEncryption: false, @@ -70,7 +70,7 @@ const config: ExpoConfig = { android: { package: androidPackage, ...(googleServicesFile ? { googleServicesFile } : {}), - versionCode: 4, + versionCode: 5, allowBackup: false, predictiveBackGestureEnabled: false, softwareKeyboardLayoutMode: "resize", 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": {