From 9af07ca49cc8903ba2d11549930c87d158a0b5ff Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Wed, 26 Aug 2026 13:25:19 +0200 Subject: [PATCH] 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.