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.