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
128 changes: 128 additions & 0 deletions apps/mobile/src/screens/workspace-screen.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { expect, jest, test } from "@jest/globals";
import {
getOpenCodeLocation,
getOpenCodeSession,
listOpenCodeAgents,
listOpenCodeMessages,
type PermissionRequest,
type SessionInfo,
type SessionMessageInfo,
type SessionMessagesResponse,
} from "@opencode2-mobile/opencode-adapter";
Expand Down Expand Up @@ -235,6 +237,7 @@ jest.mock("react-native-keyboard-controller", () => {

const mockGetSession = jest.mocked(getOpenCodeSession);
const mockGetLocation = jest.mocked(getOpenCodeLocation);
const mockListAgents = jest.mocked(listOpenCodeAgents);
const mockListMessages = jest.mocked(listOpenCodeMessages);

test("moves only the Android composer dock with the keyboard", async () => {
Expand Down Expand Up @@ -614,6 +617,131 @@ test("renders short thoughts inline and keeps detailed thoughts collapsed", asyn
scrollToOffset.mockRestore();
});

test("waits for and adopts a moved session's authoritative location", async () => {
const routeLocation = { directory: "/workspace" };
const movedLocation = { directory: "/workspace/moved" };
const movedSession = {
cost: 0,
id: "ses_moved",
location: movedLocation,
projectID: "project-1",
time: { created: 1, updated: 3 },
title: "Moved session",
tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 },
} satisfies SessionInfo;
let resolveSession: ((session: SessionInfo) => void) | undefined;
const pendingSession = new Promise<SessionInfo>((resolve) => {
resolveSession = resolve;
});
const previousGetSession = mockGetSession.getMockImplementation();
const previousListMessages = mockListMessages.getMockImplementation();
mockGetSession.mockImplementation(() => pendingSession);
mockListMessages.mockImplementation(async () => ({
cursor: {},
data: [
{
agent: "build",
content: [
{
id: "tool-patch",
name: "patch",
state: {
content: [{ text: "Applied", type: "text" }],
input: { patchText: "*** Begin Patch\n*** End Patch" },
status: "completed",
},
time: { completed: 2, created: 1 },
type: "tool",
},
],
id: "msg_edit",
model: { id: "model-1", providerID: "provider" },
time: { created: 1 },
type: "assistant",
},
],
}));
mockListMessages.mockClear();
mockListAgents.mockClear();
mockSetLocation.mockClear();
const push = jest.fn();
const queryClient = new QueryClient({
defaultOptions: {
mutations: { networkMode: "always" },
queries: { gcTime: Infinity, retry: false },
},
});
const view = render(
<QueryClientProvider client={queryClient}>
<SessionScreen
navigation={{ goBack: jest.fn(), navigate: jest.fn(), push } as never}
route={{
key: "session-moved",
name: "Session",
params: {
connectionId: "connection-1",
location: routeLocation,
sessionID: "ses_moved",
},
}}
/>
</QueryClientProvider>,
);

try {
await waitFor(() => expect(mockGetSession).toHaveBeenCalled());
expect(screen.getByLabelText("Prompt").props.editable).toBe(false);
expect(mockListMessages).not.toHaveBeenCalled();
expect(mockListAgents).not.toHaveBeenCalled();
expect(mockSetLocation).not.toHaveBeenCalled();

await act(async () => resolveSession?.(movedSession));

await waitFor(() => expect(mockListMessages).toHaveBeenCalled());
await waitFor(() => expect(mockListAgents).toHaveBeenCalled());
expect(mockListAgents).toHaveBeenLastCalledWith(
expect.anything(),
movedLocation,
expect.objectContaining({ signal: expect.anything() }),
);
expect(mockSetLocation).toHaveBeenLastCalledWith(movedLocation);
expect(screen.getByLabelText("Prompt").props.editable).toBe(true);
expect(
queryClient.getQueryData(
openCodeQueryKeys.session("connection-1", movedLocation, "ses_moved"),
),
).toEqual(movedSession);
expect(
queryClient.getQueryData(
openCodeQueryKeys.messages("connection-1", routeLocation, "ses_moved", {
limit: 40,
order: "desc",
}),
),
).toBeUndefined();
expect(
queryClient.getQueryData(
openCodeQueryKeys.messages("connection-1", movedLocation, "ses_moved", {
limit: 40,
order: "desc",
}),
),
).toBeDefined();

fireEvent.press(await screen.findByRole("button", { name: "Review current changes" }));
expect(push).toHaveBeenCalledWith("Diff", {
connectionId: "connection-1",
location: movedLocation,
mode: "working",
});
} finally {
view.unmount();
queryClient.clear();
if (previousGetSession) mockGetSession.mockImplementation(previousGetSession);
if (previousListMessages) mockListMessages.mockImplementation(previousListMessages);
}
});

test("shows running background subagents and opens their child sessions", async () => {
mockListMessages.mockImplementationOnce(async () => ({
cursor: {},
Expand Down
69 changes: 53 additions & 16 deletions apps/mobile/src/screens/workspace-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,12 +508,22 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) {
export function SessionScreen({ navigation, route }: SessionProps) {
const runtime = useConnectionRuntime();
const workspaceSelection = useWorkspaceSelection();
const queryClient = useQueryClient();
const { fontScale } = useWindowDimensions();
const screenHeight = Dimensions.get("screen").height;
const largeText = usesLargeTextLayout(fontScale);
const { connectionId: routeConnectionId, focusComposer, location, sessionID } = route.params;
const connectionId = runtime.connectionId;
const client = runtime.restClient;
const routeSessionScope = `${routeConnectionId}\u0000${sessionID}\u0000${location.directory}\u0000${location.workspaceID ?? ""}`;
const [sessionQueryScope, setSessionQueryScope] = useState({
location,
routeSessionScope,
});
const sessionQueryLocation =
sessionQueryScope.routeSessionScope === routeSessionScope
? sessionQueryScope.location
: location;
const [liveFollowEnabled, setLiveFollowEnabled] = useState(true);
const [latestJumpPending, setLatestJumpPending] = useState(false);
const [composerDockHeight, setComposerDockHeight] = useState(66);
Expand All @@ -536,20 +546,27 @@ export function SessionScreen({ navigation, route }: SessionProps) {
if (!client) throw new Error("CONNECTION_NOT_READY");
return getOpenCodeSession(client, sessionID, { signal });
},
queryKey: openCodeQueryKeys.session(connectionId ?? "unselected", location, sessionID),
queryKey: openCodeQueryKeys.session(
connectionId ?? "unselected",
sessionQueryLocation,
sessionID,
),
});
const session = sessionQuery.data;
const sessionLocation = session?.location ?? location;
const sessionLocation = session?.location ?? sessionQueryLocation;
const sessionLocationReady = Boolean(
sessionQuery.isSuccess && session && locationsEqual(session.location, sessionQueryLocation),
);
const vcsQuery = useQuery({
enabled: Boolean(client && connectionId === routeConnectionId),
enabled: Boolean(client && connectionId === routeConnectionId && sessionLocationReady),
queryFn: ({ signal }) => {
if (!client) throw new Error("CONNECTION_NOT_READY");
return getOpenCodeVcs(client, sessionLocation, { signal });
},
queryKey: openCodeQueryKeys.vcs(connectionId ?? "unselected", sessionLocation),
});
const messagesQuery = useInfiniteQuery({
enabled: Boolean(client && connectionId === routeConnectionId),
enabled: Boolean(client && connectionId === routeConnectionId && sessionLocationReady),
getNextPageParam: (lastPage: SessionMessagesResponse) => lastPage.cursor.next ?? undefined,
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }): Promise<SessionMessagesResponse> => {
Expand All @@ -563,14 +580,17 @@ export function SessionScreen({ navigation, route }: SessionProps) {
{ signal },
);
},
queryKey: openCodeQueryKeys.messages(connectionId ?? "unselected", location, sessionID, {
queryKey: openCodeQueryKeys.messages(connectionId ?? "unselected", sessionLocation, sessionID, {
limit: messagePageSize,
order: "desc",
}),
});
const mentionFilesQuery = useQuery({
enabled: Boolean(
client && connectionId === routeConnectionId && deferredMentionSearch !== undefined,
client &&
connectionId === routeConnectionId &&
sessionLocationReady &&
deferredMentionSearch !== undefined,
),
queryFn: ({ signal }) => {
if (!client || deferredMentionSearch === undefined) throw new Error("CONNECTION_NOT_READY");
Expand Down Expand Up @@ -601,7 +621,7 @@ export function SessionScreen({ navigation, route }: SessionProps) {
const messages = flattenTranscriptPages(messagesQuery.data?.pages);
const draft = useSessionDraft(routeConnectionId, sessionID);
const execution = useSessionExecution({
client,
client: sessionLocationReady ? client : undefined,
connectionId,
draftReady: draft.loaded,
draftRevision: draft.revision,
Expand All @@ -627,23 +647,33 @@ export function SessionScreen({ navigation, route }: SessionProps) {
(childSessionID: string) => {
navigation.push("Session", {
connectionId: routeConnectionId,
location,
location: sessionLocation,
sessionID: childSessionID,
});
},
[location, navigation, routeConnectionId],
[navigation, routeConnectionId, sessionLocation],
);
const openDiff = useCallback(() => {
navigation.push("Diff", {
connectionId: routeConnectionId,
location,
location: sessionLocation,
mode: "working",
});
}, [location, navigation, routeConnectionId]);
}, [navigation, routeConnectionId, sessionLocation]);

useEffect(() => {
workspaceSelection.setLocation(location);
}, [location, workspaceSelection.setLocation]);
if (!session || locationsEqual(session.location, sessionQueryLocation)) return;
queryClient.setQueryData(
openCodeQueryKeys.session(routeConnectionId, session.location, sessionID),
session,
);
setSessionQueryScope({ location: session.location, routeSessionScope });
}, [queryClient, routeConnectionId, routeSessionScope, session, sessionID, sessionQueryLocation]);

useEffect(() => {
if (!sessionLocationReady) return;
workspaceSelection.setLocation(sessionLocation);
}, [sessionLocation, sessionLocationReady, workspaceSelection.setLocation]);

useEffect(() => {
if (Platform.OS !== "ios") return;
Expand Down Expand Up @@ -867,7 +897,7 @@ export function SessionScreen({ navigation, route }: SessionProps) {
connectionId={connectionId}
formLocations={workspaceSelection.formLocations}
forms={sessionForms}
location={location}
location={sessionLocation}
/>
) : undefined
}
Expand All @@ -892,9 +922,9 @@ export function SessionScreen({ navigation, route }: SessionProps) {
completionLoading={execution.completionLoading}
completionUnavailable={execution.completionUnavailable}
delivery={execution.delivery}
disabled={execution.submitDisabled || !draft.loaded}
disabled={execution.submitDisabled || !draft.loaded || !sessionLocationReady}
draft={draft.draft}
editable={draft.loaded}
editable={draft.loaded && sessionLocationReady}
error={execution.error ?? draft.error}
focusOnMount={focusComposer}
largeText={largeText}
Expand Down Expand Up @@ -1309,6 +1339,13 @@ function formatSessionTime(value: number) {
}
}

function locationsEqual(first: LocationRef, second: LocationRef) {
return (
first.directory === second.directory &&
(first.workspaceID ?? null) === (second.workspaceID ?? null)
);
}

function sessionAccessibilityLabel(row: FollowedInboxRow) {
const { session } = row;
const states = [
Expand Down
2 changes: 1 addition & 1 deletion docs/COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ later entries supersede them.
| Keep a lost command response behind an explicit duplicate-risk retry guard | Pass |
| Route beta 18387 step-streamed and message-content events to exact-session reconciliation | Pass |
| Decode the beta 18387 interrupt response | Pass in the deterministic fake API |
| Run all 292 mobile tests | Pass |
| Run all 293 mobile tests | Pass |
| Export iOS and Android Hermes bundles | Pass |
| Run Expo Doctor | Pass, 18/18 checks |

Expand Down