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
2 changes: 2 additions & 0 deletions apps/mobile/src/state/connection-runtime-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ test("forwards app state and network changes and drops stale cache metadata", as
?.value as NativeEventSubscription;
const removeAppState = jest.spyOn(appStateSubscription, "remove");

act(() => appStateListener("inactive"));
expect(setFocused).toHaveBeenLastCalledWith(true);
act(() => appStateListener("background"));
expect(setFocused).toHaveBeenLastCalledWith(false);
act(() => mockNetworkListener?.({ isConnected: false }));
Expand Down
8 changes: 6 additions & 2 deletions apps/mobile/src/state/connection-runtime-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
type ConnectionCacheMetadata,
readConnectionCacheMetadata,
} from "./connection-cache-metadata";
import { appendEventLocation, startConnectionTransport } from "./connection-runtime-transport";
import {
appendEventLocation,
isTransportForeground,
startConnectionTransport,
} from "./connection-runtime-transport";
import type {
ConnectionTransportCoordinator,
ConnectionTransportStatus,
Expand Down Expand Up @@ -196,7 +200,7 @@ function useConnectionTransport(
});

const appStateSubscription = AppState.addEventListener("change", (nextState) => {
const foreground = nextState === "active";
const foreground = isTransportForeground(nextState);
focusManager.setFocused(foreground);
coordinator?.setForeground(foreground);
});
Expand Down
13 changes: 13 additions & 0 deletions apps/mobile/src/state/connection-runtime-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
appendEventLocation,
type ConnectionRuntimeTransportSink,
connectionTransportCallbacks,
isTransportForeground,
startConnectionTransport,
} from "./connection-runtime-transport";
import type { ConnectionSnapshot } from "./connection-transport-coordinator";
Expand Down Expand Up @@ -147,6 +148,18 @@ test("omits authorization without a credential and starts in the background", as
expect(mockCoordinators[0]?.start).toHaveBeenCalledTimes(1);
});

test("keeps the transport in the foreground while iOS reports inactive", () => {
Object.defineProperty(AppState, "currentState", { configurable: true, value: "inactive" });
mockGetNetworkState.mockResolvedValue({});

startConnectionTransport(profile, undefined, createQueryClient(), createSink());

expect(mockCoordinators[0]?.setForeground).toHaveBeenCalledWith(true);
expect(isTransportForeground("active")).toBe(true);
expect(isTransportForeground("inactive")).toBe(true);
expect(isTransportForeground("background")).toBe(false);
});

test("encodes basic credentials", () => {
mockGetNetworkState.mockResolvedValue({ isConnected: true });

Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/src/state/connection-runtime-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { focusManager, onlineManager, type QueryClient } from "@tanstack/react-query";
import * as Network from "expo-network";
import type { Dispatch, SetStateAction } from "react";
import { AppState } from "react-native";
import { AppState, type AppStateStatus } from "react-native";

import { connectionAuthorizationHeader } from "../connections/connection-authorization";
import type { ConnectionCredential, ConnectionProfile } from "../connections/connection-profile";
Expand Down Expand Up @@ -77,7 +77,7 @@ export function startConnectionTransport(
shouldReconcileEvent: eventRequiresConnectionSnapshot,
});

const foreground = AppState.currentState === "active";
const foreground = isTransportForeground(AppState.currentState);
coordinator.setForeground(foreground);
focusManager.setFocused(foreground);
void Network.getNetworkStateAsync()
Expand All @@ -92,6 +92,14 @@ export function startConnectionTransport(
return coordinator;
}

// iOS reports "inactive" while the app switcher, Control Center, Notification Center,
// or a system prompt covers the app. The app is still on screen and not suspended,
// so only "background" closes the event stream. Closing it on "inactive" marked the
// connection stale every time the user swiped up to switch apps.
export function isTransportForeground(state: AppStateStatus) {
return state !== "background";
}

export function connectionTransportCallbacks(
profile: ConnectionTransportProfile,
queryClient: QueryClient,
Expand Down
19 changes: 19 additions & 0 deletions docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ the fail-closed database backup-exclusion startup guard. Statements marked
pending in older dated entries describe the status at the time of that probe;
later entries supersede them.

## 2026-11-14: event stream stays open in the iOS app switcher, pending device verification

On an iPhone, swiping up to the app switcher showed "Request status is stale"
and "Showing saved data until the server reconnects" on the workspace card. iOS
reports `inactive` while the app switcher, Control Center, Notification Center,
or a system prompt covers the app. The transport treated every state other than
`active` as background, so it closed the event stream and marked the connection
stale. Only `background` now closes the stream and pauses query focus; `inactive`
keeps both. Leaving for another app still reaches `background`, closes the
stream, and reconnects on return. Android does not report `inactive`.

The full lint, typecheck, test, and build sequence passed, including 1533 mobile
tests and both Hermes exports. Expo Doctor passed 18 of 18 checks. Both native
fingerprints and runtime matched the installed 0.1.6 baseline under the preview
EAS environment. Local EAS Update publication then succeeded for iOS and Android
on preview. Publication does not establish installation or device behavior.
Still to verify on the iPhone: no notice while hovering in the app switcher, and
a clean reconnect after switching to another app and back.

## 2026-11-10: transcript image tiles and swipeable viewer, pending device verification

Transcript images now form justified rows of tiles that keep each image's shape.
Expand Down
Loading