Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c4dc6eb
test(mobile): reproduce keyboard state lost after Android IME inset r…
juliusmarminge Oct 27, 2026
fe3591b
fix(mobile): keyboard state follows Android's nonanimated IME show re…
juliusmarminge Oct 27, 2026
f14e75b
Merge remote-tracking branch 'origin/main' into fix/issue-22484
juliusmarminge Oct 27, 2026
62f461d
Merge remote-tracking branch 'origin/main' into fix/issue-22484
juliusmarminge Oct 27, 2026
df3a160
test(mobile): takeover tests for keyboard didShow-only state
juliusmarminge Oct 27, 2026
29d68e8
fix(mobile): keyboard didShow patch matches upstream; terminal skips …
juliusmarminge Oct 27, 2026
03110f3
fix(mobile): terminal accessory returns on Android's didShow-only IME…
juliusmarminge Oct 27, 2026
818a20c
Merge remote-tracking branch 'origin/main' into fix/issue-22484
juliusmarminge Oct 27, 2026
996ef6e
Merge remote-tracking branch 'origin/main' into fix/issue-22484
juliusmarminge Oct 28, 2026
98d0423
fix(mobile): terminal accessory also clears on Android's didHide-only…
juliusmarminge Oct 28, 2026
967196d
fix(mobile): terminal accessory survives Android's did-only IME repair
juliusmarminge Oct 28, 2026
4ba4deb
Merge remote-tracking branch 'origin/main' into pr-25847
juliusmarminge Oct 28, 2026
0e39d71
fix(mobile): shorten terminal keyboard store comment for lint
juliusmarminge Oct 28, 2026
8d3d149
fix(mobile): every terminal screen hears a keyboard show, not just th…
juliusmarminge Oct 28, 2026
f82221d
Merge remote-tracking branch 'origin/main' into pr-25847
juliusmarminge Oct 28, 2026
bd524c0
fix(mobile): terminal keyboard store listens once instead of per subs…
juliusmarminge Oct 28, 2026
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
84 changes: 84 additions & 0 deletions apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { KeyboardEventData } from "react-native-keyboard-controller";
import { expect, it, vi } from "vite-plus/test";

const listeners = vi.hoisted(() => new Map<string, Set<(event: KeyboardEventData) => void>>());

// Exercises the patched library source with only its native bindings replaced.
vi.mock("react-native-keyboard-controller/src/bindings", () => ({
KeyboardEvents: {
addListener: (name: string, listener: (event: KeyboardEventData) => void) => {
const named = listeners.get(name) ?? new Set();
named.add(listener);
listeners.set(name, named);
return { remove: () => named.delete(listener) };
},
},
KeyboardControllerNative: { dismiss: () => {} },
}));

import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState";
import { KeyboardController } from "react-native-keyboard-controller/src/module";

function emit(name: string, height: number) {
const event: KeyboardEventData = {
height,
duration: 0,
timestamp: 1,
target: 42,
type: "default",
appearance: "light",
};
act(() => {
for (const listener of listeners.get(name) ?? []) listener(event);
});
}

function KeyboardState() {
const { isVisible, height } = useKeyboardState();
return <output>{JSON.stringify({ isVisible, height })}</output>;
}

// The library keeps keyboard state at module scope; every test starts and ends hidden.
function setup() {
emit("keyboardDidHide", 0);
return { [Symbol.dispose]: () => emit("keyboardDidHide", 0) };
}

function renderKeyboardState() {
const container = document.createElement("div");
const root = createRoot(container);
act(() => root.render(<KeyboardState />));
return { container, [Symbol.dispose]: () => act(() => root.unmount()) };
}

// Android's syncKeyboardPosition repairs a nonanimated IME inset change with
// keyboardDidHide then keyboardDidShow, and no keyboardWillShow.
it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => {
using _keyboard = setup();
using screen = renderKeyboardState();

emit("keyboardDidShow", 376);

expect({
isVisible: KeyboardController.isVisible(),
height: KeyboardController.state().height,
hook: JSON.parse(screen.container.textContent ?? ""),
}).toEqual({ isVisible: true, height: 376, hook: { isVisible: true, height: 376 } });
});

it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => {
using _keyboard = setup();
emit("keyboardDidShow", 376);

let dismissed = false;
const dismissal = KeyboardController.dismiss().then(() => {
dismissed = true;
});
await act(async () => {});

expect(dismissed).toBe(false);
emit("keyboardDidHide", 0);
await dismissal;
});
50 changes: 13 additions & 37 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import * as Schema from "effect/Schema";
import { AsyncResult } from "effect/reactivity";
import {
KeyboardController,
KeyboardEvents,
KeyboardStickyView,
useKeyboardState,
} from "react-native-keyboard-controller";
Expand Down Expand Up @@ -90,6 +89,7 @@ import {
} from "./terminalInput";
import { createTerminalPasteSession } from "./terminalPaste";
import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState";
import { getKeyboardShowEpoch, subscribeKeyboardShowing } from "./terminalKeyboardShowing";

function TerminalHeader(props: {
readonly subtitle: string;
Expand Down Expand Up @@ -353,26 +353,6 @@ function readLiveTerminalSessions(
}));
}

// `useKeyboardState` only flips on willShow and didHide; the accessory must
// leave as soon as the keyboard starts hiding, so it follows the will-events.
let keyboardShowing = KeyboardController.isVisible();
function subscribeKeyboardShowing(onChange: () => void) {
keyboardShowing = KeyboardController.isVisible();
const willShow = KeyboardEvents.addListener("keyboardWillShow", () => {
keyboardShowing = true;
onChange();
});
const willHide = KeyboardEvents.addListener("keyboardWillHide", () => {
keyboardShowing = false;
onChange();
});
return () => {
willShow.remove();
willHide.remove();
};
}
const getKeyboardShowing = () => keyboardShowing;

function ThreadTerminal(props: {
readonly params: ThreadTerminalRouteScreenProps["route"]["params"];
readonly routeEnvironmentId: EnvironmentId | null;
Expand Down Expand Up @@ -586,28 +566,25 @@ function ThreadTerminal(props: {
{ kind: "send", key: "dash", label: "-", data: "-" },
];
}, [hostPlatform]);
const keyboardState = useKeyboardState((state) => ({
height: state.height,
isVisible: state.isVisible,
}));
const keyboardShowing = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing);
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
const keyboardHeight = useKeyboardState((state) => state.height);
const keyboardShowEpoch = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowEpoch);
// The accessory follows the keyboard, except that the dismiss and show
// buttons flip it ahead of the keyboard animation.
const [accessory, setAccessory] = useState({ keyboardShowing, dismissed: false });
if (accessory.keyboardShowing !== keyboardShowing) {
setAccessory({ keyboardShowing, dismissed: !keyboardShowing });
const [accessory, setAccessory] = useState({ keyboardShowEpoch, dismissed: false });
if (accessory.keyboardShowEpoch !== keyboardShowEpoch) {
setAccessory({ keyboardShowEpoch, dismissed: keyboardShowEpoch === 0 });
}
const isAccessoryDismissed = accessory.dismissed;
const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed;
const isAccessoryVisible = canOperateTerminal && isKeyboardVisible && !isAccessoryDismissed;
// Android's terminal owns an EditText; turning off autoFocus also clears its native focus.
const terminalAutoFocus =
Platform.OS === "android"
? !isAccessoryDismissed &&
(!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible)
(!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || isKeyboardVisible)
: !SHOWCASE_ENABLED;
const terminalBottomInset =
(keyboardState.isVisible ? keyboardState.height : 0) +
(isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);
(isKeyboardVisible ? keyboardHeight : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0);

const terminalMenuSessions: ReadonlyArray<TerminalMenuSession> = buildTerminalMenuSessions({
knownSessions: knownSessions ?? [],
Expand Down Expand Up @@ -861,8 +838,7 @@ function ThreadTerminal(props: {
// Measured top reservation is zero when UIKit already inset the body or moved the bar.
paddingTop:
Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0,
paddingBottom:
Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0,
paddingBottom: Platform.OS === "android" && !isKeyboardVisible ? insets.bottom : 0,
}}
>
{!isEnvironmentReady ? (
Expand Down Expand Up @@ -957,7 +933,7 @@ function ThreadTerminal(props: {
/>
</View>

{Platform.OS === "android" && !keyboardState.isVisible ? (
{Platform.OS === "android" && !isKeyboardVisible ? (
<View className="min-h-14 flex-row items-center gap-2 bg-card-alt px-2">
{hasNativeTerminalSurface() ? (
<Button label="Attach output" tone="text" onPress={requestCapture} />
Expand Down Expand Up @@ -1032,7 +1008,7 @@ function ThreadTerminal(props: {
</ComposerToolbarRow>
</View>
</KeyboardStickyView>
) : canOperateTerminal && !keyboardState.isVisible && Platform.OS !== "android" ? (
) : canOperateTerminal && !isKeyboardVisible && Platform.OS !== "android" ? (
<Pressable
accessibilityLabel="Show keyboard"
accessibilityRole="button"
Expand Down
66 changes: 66 additions & 0 deletions apps/mobile/src/features/terminal/terminalKeyboardShowing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, it, vi } from "vite-plus/test";

const listeners = vi.hoisted(() => new Map<string, Set<() => void>>());

vi.mock("react-native-keyboard-controller", () => ({
KeyboardController: { isVisible: () => false },
KeyboardEvents: {
addListener: (name: string, listener: () => void) => {
const named = listeners.get(name) ?? new Set();
named.add(listener);
listeners.set(name, named);
return { remove: () => named.delete(listener) };
},
},
}));

import { getKeyboardShowEpoch, subscribeKeyboardShowing } from "./terminalKeyboardShowing";

function emit(name: string) {
for (const listener of listeners.get(name) ?? []) listener();
}

// Android's syncKeyboardPosition repairs a nonanimated IME inset change with
// keyboardDidHide then keyboardDidShow, and no will-events.
it("keeps the accessory through Android's did-only repair and starts a new show after it", () => {
// Two mounted terminal screens, each reading the snapshot when notified.
const seen = [0, 0];
const unsubscribes = seen.map((_, index) => {
seen[index] = getKeyboardShowEpoch();
return subscribeKeyboardShowing(() => {
seen[index] = getKeyboardShowEpoch();
});
});
const trace = [
"keyboardWillShow",
"keyboardDidShow",
"keyboardDidHide",
"keyboardDidShow",
"keyboardWillHide",
"keyboardWillShow",
].map((name) => {
emit(name);
return {
epoch: getKeyboardShowEpoch(),
inSync: seen.every((s) => s === getKeyboardShowEpoch()),
};
});

const [shown, settled, repairHidden, repairShown, hidden, reshown] = trace.map((t) => t.epoch);
expect({
neverHiddenByRepair: [shown, settled, repairHidden, repairShown].every((epoch) => epoch !== 0),
sameShow: settled === shown && repairHidden === shown,
newShow: repairShown !== shown,
hidden,
newShowAfterHide: reshown !== 0 && reshown !== repairShown,
subscribersInSync: trace.every((t) => t.inSync),
}).toEqual({
neverHiddenByRepair: true,
sameShow: true,
newShow: true,
hidden: 0,
newShowAfterHide: true,
subscribersInSync: true,
});
for (const unsubscribe of unsubscribes) unsubscribe();
});
40 changes: 40 additions & 0 deletions apps/mobile/src/features/terminal/terminalKeyboardShowing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller";

// The accessory leaves on willHide, before `useKeyboardState` sees didHide. Android
// repairs a nonanimated IME change with didHide then didShow alone: that didHide must
// not hide the accessory (it would clear the terminal's focus), but the didShow after
// it starts a new show so a dismissed accessory comes back.
const subscribers = new Set<() => void>();
let showEpoch = 1;
let keyboardShowing = KeyboardController.isVisible();
let hiddenSinceShow = !keyboardShowing;

const notify = () => {
for (const subscriber of subscribers) subscriber();
};
const show = () => {
if (hiddenSinceShow) showEpoch += 1;
hiddenSinceShow = false;
keyboardShowing = true;
notify();
};
KeyboardEvents.addListener("keyboardWillShow", show);
KeyboardEvents.addListener("keyboardDidShow", show);
KeyboardEvents.addListener("keyboardWillHide", () => {
keyboardShowing = false;
hiddenSinceShow = true;
notify();
});
KeyboardEvents.addListener("keyboardDidHide", () => {
hiddenSinceShow = true;
});

export function subscribeKeyboardShowing(onChange: () => void) {
subscribers.add(onChange);
return () => {
subscribers.delete(onChange);
};
}

// 0 while hidden; otherwise a number that changes with every new show.
export const getKeyboardShowEpoch = () => (keyboardShowing ? showEpoch : 0);
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const reactTests = [
"src/features/threads/use-thread-list-now.test.tsx",
"src/features/home/home-list-options.test.tsx",
"src/features/connection/ConnectionsNewRouteScreen.test.tsx",
"src/features/keyboard/keyboardControllerState.test.tsx",
];

const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`;
Expand Down
Loading
Loading