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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ if (projectId) {
const config: ExpoConfig = {
name: appName,
slug,
version: "0.1.3",
version: "0.1.4",
newArchEnabled: true,
platforms: ["ios", "android"],
icon: "./assets/icon.png",
Expand Down Expand Up @@ -70,7 +70,7 @@ const config: ExpoConfig = {
android: {
package: androidPackage,
...(googleServicesFile ? { googleServicesFile } : {}),
versionCode: 5,
versionCode: 6,
allowBackup: false,
predictiveBackGestureEnabled: false,
softwareKeyboardLayoutMode: "resize",
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencode2-mobile/mobile",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"main": "index.ts",
"scripts": {
Expand Down Expand Up @@ -45,6 +45,7 @@
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "1.18.5",
"react-native-reanimated": "4.1.7",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
Expand Down
56 changes: 36 additions & 20 deletions apps/mobile/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { NavigationContainer, type Theme } from "@react-navigation/native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SQLiteProvider } from "expo-sqlite";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Pressable, Share, StyleSheet, Text, View } from "react-native";
import { Platform, Pressable, Share, StyleSheet, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";

import { applicationName } from "./application-name";
Expand Down Expand Up @@ -48,28 +49,43 @@ const navigationTheme: Theme = {
};

export default function App() {
const application = (
<SafeAreaProvider>
<SQLiteProvider databaseName={mobileDatabaseName} onInit={migrateMobileDatabase}>
<AppLockProvider>
<QueryClientProvider client={queryClient}>
<ConnectionsProvider>
<ConnectionRuntimeProvider>
<FollowedProjectsProvider>
<NotificationRoutingProvider>
<NavigationContainer ref={rootNavigationRef} theme={navigationTheme}>
<RootNavigation />
</NavigationContainer>
</NotificationRoutingProvider>
</FollowedProjectsProvider>
</ConnectionRuntimeProvider>
</ConnectionsProvider>
</QueryClientProvider>
</AppLockProvider>
</SQLiteProvider>
</SafeAreaProvider>
);

return (
<RootErrorBoundary>
<GestureHandlerRootView style={styles.appRoot}>
<SafeAreaProvider>
<SQLiteProvider databaseName={mobileDatabaseName} onInit={migrateMobileDatabase}>
<AppLockProvider>
<QueryClientProvider client={queryClient}>
<ConnectionsProvider>
<ConnectionRuntimeProvider>
<FollowedProjectsProvider>
<NotificationRoutingProvider>
<NavigationContainer ref={rootNavigationRef} theme={navigationTheme}>
<RootNavigation />
</NavigationContainer>
</NotificationRoutingProvider>
</FollowedProjectsProvider>
</ConnectionRuntimeProvider>
</ConnectionsProvider>
</QueryClientProvider>
</AppLockProvider>
</SQLiteProvider>
</SafeAreaProvider>
{Platform.OS === "android" ? (
<KeyboardProvider
navigationBarTranslucent
preload={false}
preserveEdgeToEdge
statusBarTranslucent
>
{application}
</KeyboardProvider>
) : (
application
)}
</GestureHandlerRootView>
</RootErrorBoundary>
);
Expand Down
47 changes: 46 additions & 1 deletion apps/mobile/src/screens/workspace-screen.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { type InfiniteData, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native";
import type { ReactNode } from "react";
import { Dimensions, FlatList, RefreshControl } from "react-native";
import { Dimensions, FlatList, Platform, RefreshControl } from "react-native";
import { openCodeQueryKeys } from "../state/open-code-query-keys";
import { WorkspaceSelectionProvider } from "../state/workspace-selection-context";
import { SessionScreen, WorkspaceScreen } from "./workspace-screen";
Expand Down Expand Up @@ -220,11 +220,56 @@ jest.mock("react-native-gesture-handler/ReanimatedSwipeable", () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => children,
}));
jest.mock("react-native-keyboard-controller", () => {
const React = jest.requireActual<typeof import("react")>("react");
const { View } = jest.requireActual<typeof import("react-native")>("react-native");
return {
KeyboardStickyView: ({ children, ...props }: { children: ReactNode }) =>
React.createElement(View, { ...props, testID: "keyboard-sticky-view" }, children),
};
});

const mockGetSession = jest.mocked(getOpenCodeSession);
const mockGetLocation = jest.mocked(getOpenCodeLocation);
const mockListMessages = jest.mocked(listOpenCodeMessages);

test("moves only the Android composer dock with the keyboard", async () => {
const platformOS = Platform.OS;
Object.defineProperty(Platform, "OS", { configurable: true, value: "android" });
const queryClient = new QueryClient({
defaultOptions: {
mutations: { networkMode: "always" },
queries: { gcTime: Infinity, retry: false },
},
});
const view = render(
<QueryClientProvider client={queryClient}>
<SessionScreen
navigation={{ goBack: jest.fn(), navigate: jest.fn() } as never}
route={{
key: "session-android-keyboard",
name: "Session",
params: {
connectionId: "connection-1",
location: { directory: "/workspace" },
sessionID: "ses_transcript",
},
}}
/>
</QueryClientProvider>,
);

try {
await screen.findByLabelText("Keyboard composer dock");
expect(screen.getByTestId("keyboard-sticky-view")).toBeOnTheScreen();
expect(screen.getByLabelText("Keyboard-aware session")).toBeOnTheScreen();
} finally {
view.unmount();
queryClient.clear();
Object.defineProperty(Platform, "OS", { configurable: true, value: platformOS });
}
});

test("shows a permission blocking the open session and can reply", async () => {
mockWorkspacePermissions = [
{
Expand Down
124 changes: 69 additions & 55 deletions apps/mobile/src/screens/workspace-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
View,
} from "react-native";
import ReanimatedSwipeable from "react-native-gesture-handler/ReanimatedSwipeable";
import { KeyboardStickyView } from "react-native-keyboard-controller";
import { useConnections } from "../connections/connections-context";
import type { RootStackParamList } from "../navigation/root-navigation";
import { useConnectionRuntime } from "../state/connection-runtime-context";
Expand Down Expand Up @@ -802,6 +803,58 @@ export function SessionScreen({ navigation, route }: SessionProps) {
);
}

const composerDockContent = (
<View onLayout={measureComposerContent}>
<SessionExecutionPanel
active={execution.active}
admissions={execution.admissions}
busyAction={execution.busyAction}
formRequests={
sessionForms.length > 0 ? (
<FormRequestList
client={client}
connectionId={connectionId}
formLocations={workspaceSelection.formLocations}
forms={sessionForms}
location={location}
/>
) : undefined
}
inbox={execution.inbox}
onAllowRetry={execution.allowRetry}
onCancelInbox={execution.cancelInbox}
onCheckAdmission={execution.reconcileAdmission}
onInterrupt={execution.interrupt}
onQueueInbox={execution.queueInbox}
onReplyPermission={workspaceSelection.replyPermission}
onSteerInbox={execution.steerInbox}
permissionReplyError={workspaceSelection.permissionReplyError}
permissions={sessionPermissions}
projectedMessageIds={execution.projectedMessageIds}
replyingPermissionId={workspaceSelection.replyingPermissionId}
/>
<SessionComposer
active={execution.active}
agent={execution.selectedAgent}
agents={execution.agents}
delivery={execution.delivery}
disabled={execution.submitDisabled || !draft.loaded}
draft={draft.draft}
editable={draft.loaded}
error={execution.error ?? draft.error}
focusOnMount={focusComposer}
largeText={largeText}
model={execution.selectedModel}
models={execution.models}
onAgentChange={execution.switchAgent}
onDeliveryChange={execution.setDelivery}
onDraftChange={draft.setDraft}
onModelChange={execution.switchModel}
onSubmit={() => execution.submit(draft.draft)}
/>
</View>
);

return (
<ShellFrame
active="Workspace"
Expand Down Expand Up @@ -899,62 +952,23 @@ export function SessionScreen({ navigation, route }: SessionProps) {
</Pressable>
) : null}
<View pointerEvents="none" style={{ height: composerDockHeight }} />
<View
accessibilityLabel="Keyboard composer dock"
onLayout={measureComposerDock}
ref={composerDockRef}
style={[styles.composerDock, { bottom: composerKeyboardOffset }]}
>
<View onLayout={measureComposerContent}>
<SessionExecutionPanel
active={execution.active}
admissions={execution.admissions}
busyAction={execution.busyAction}
formRequests={
sessionForms.length > 0 ? (
<FormRequestList
client={client}
connectionId={connectionId}
formLocations={workspaceSelection.formLocations}
forms={sessionForms}
location={location}
/>
) : undefined
}
inbox={execution.inbox}
onAllowRetry={execution.allowRetry}
onCancelInbox={execution.cancelInbox}
onCheckAdmission={execution.reconcileAdmission}
onInterrupt={execution.interrupt}
onQueueInbox={execution.queueInbox}
onReplyPermission={workspaceSelection.replyPermission}
onSteerInbox={execution.steerInbox}
permissionReplyError={workspaceSelection.permissionReplyError}
permissions={sessionPermissions}
projectedMessageIds={execution.projectedMessageIds}
replyingPermissionId={workspaceSelection.replyingPermissionId}
/>
<SessionComposer
active={execution.active}
agent={execution.selectedAgent}
agents={execution.agents}
delivery={execution.delivery}
disabled={execution.submitDisabled || !draft.loaded}
draft={draft.draft}
editable={draft.loaded}
error={execution.error ?? draft.error}
focusOnMount={focusComposer}
largeText={largeText}
model={execution.selectedModel}
models={execution.models}
onAgentChange={execution.switchAgent}
onDeliveryChange={execution.setDelivery}
onDraftChange={draft.setDraft}
onModelChange={execution.switchModel}
onSubmit={() => execution.submit(draft.draft)}
/>
{Platform.OS === "android" ? (
<KeyboardStickyView
accessibilityLabel="Keyboard composer dock"
style={styles.composerDock}
>
{composerDockContent}
</KeyboardStickyView>
) : (
<View
accessibilityLabel="Keyboard composer dock"
onLayout={measureComposerDock}
ref={composerDockRef}
style={[styles.composerDock, { bottom: composerKeyboardOffset }]}
>
{composerDockContent}
</View>
</View>
)}
</View>
</ShellFrame>
);
Expand Down
24 changes: 24 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,27 @@ 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.

## 2026-08-27: physical Android 17 composer probe

### Stack

- Mobile runtime: signed EAS preview build using Hermes
- Device: Pixel 8 Pro running Android 17
- Expo SDK: 54.0.37
- React Native: 0.81.5
- Keyboard controller: 1.18.5

### Results

| Probe | Result |
| --- | --- |
| Install and launch preview version 0.1.4, build 6 | Pass |
| Focus the session composer and show the software keyboard | Pass |
| Keep the composer visible directly above the keyboard | Pass |

The prior build relied on activity resize and hid the composer behind the
keyboard on this device. The corrected build keeps the transcript container
fixed and moves only the composer dock from native keyboard inset animation
frames. The probe recorded no address, credential, identifier, path, prompt, or
server content.
17 changes: 17 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.