From 8f836c384999ec44cc3115b9f494dbb47e69f0b4 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Thu, 27 Aug 2026 23:51:59 +0200 Subject: [PATCH] Scope moved sessions to resolved locations --- .../workspace-screen.integration.test.tsx | 128 ++++++++++++++++++ apps/mobile/src/screens/workspace-screen.tsx | 69 +++++++--- docs/COMPATIBILITY.md | 2 +- 3 files changed, 182 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 8a42908..a7fb6b3 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -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"; @@ -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 () => { @@ -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((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( + + + , + ); + + 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: {}, diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index cfd303e..16de745 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -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); @@ -536,12 +546,19 @@ 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 }); @@ -549,7 +566,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { 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 => { @@ -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"); @@ -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, @@ -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; @@ -867,7 +897,7 @@ export function SessionScreen({ navigation, route }: SessionProps) { connectionId={connectionId} formLocations={workspaceSelection.formLocations} forms={sessionForms} - location={location} + location={sessionLocation} /> ) : undefined } @@ -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} @@ -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 = [ diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index 947093a..af88f9b 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -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 |