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
20 changes: 20 additions & 0 deletions apps/mobile/src/screens/session-composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ComposerHarness onSubmit={onSubmit} />);

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(<ComposerHarness onSubmit={jest.fn()} />);

Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/screens/session-composer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,6 +43,7 @@ export function SessionComposer({
onModelChange: (model: ModelRef) => void;
onSubmit: () => void;
}) {
const inputRef = useRef<TextInput>(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
const [focused, setFocused] = useState(false);
const [modelPickerOpen, setModelPickerOpen] = useState(false);
Expand All @@ -67,9 +68,10 @@ export function SessionComposer({

function submit() {
if (!canSubmit) return;
onSubmit();
inputRef.current?.blur();
setFocused(false);
Keyboard.dismiss();
onSubmit();
}

return (
Expand All @@ -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}
Expand Down
28 changes: 28 additions & 0 deletions apps/mobile/src/state/connection-event-query-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/state/connection-event-query-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -284,6 +284,15 @@ function eventInvalidationRoot(event: OpenCodeEvent): InvalidationRoot | undefin
return "connection";
}

const advisoryLocationEventTypes = new Set<string>([
"filesystem.changed",
"server.connected",
"shell.created",
"shell.deleted",
"shell.exited",
"vcs.branch.updated",
]);

const inboxEventTypes = new Set<string>([
"session.inbox.enqueued",
"session.inbox.delivered",
Expand Down
31 changes: 30 additions & 1 deletion apps/mobile/src/state/connection-transport-coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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[] = [];
Expand Down
37 changes: 37 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.