From 5c5b42165b8a503f753ad84dee250f646dabce31 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 5 Oct 2026 08:14:30 +0200 Subject: [PATCH 1/2] feat(mobile): add branch details and device-local session archiving Auto-archive idle sessions after 30 days and preserve working sessions and requests in Inbox. Add Archive, Archived, and Restore with a 30-day restoration grace period and connection-scoped storage migrations. Fix pagination visibility for archived-only history with eligible-row lookahead. Move archive guidance to the end of the list after pagination completes. Cover persistence, migration rollback, event invalidation, and list behavior with regression tests. --- .../src/screens/session-archive-model.test.ts | 104 ++++ .../src/screens/session-archive-model.ts | 86 +++ .../workspace-screen.integration.test.tsx | 506 ++++++++++++++++-- apps/mobile/src/screens/workspace-screen.tsx | 355 ++++++++++-- .../connection-event-query-bridge.test.ts | 10 + .../state/connection-event-query-bridge.ts | 3 + .../src/state/followed-projects-context.tsx | 2 + apps/mobile/src/state/open-code-query-keys.ts | 4 + .../src/state/use-session-archives.test.tsx | 68 +++ apps/mobile/src/state/use-session-archives.ts | 68 +++ apps/mobile/src/storage/database.test.ts | 94 +++- apps/mobile/src/storage/database.ts | 33 +- .../prompt-admission-repository.test.ts | 9 +- .../storage/prompt-admission-repository.ts | 5 + .../session-archive-repository.test.ts | 84 +++ .../src/storage/session-archive-repository.ts | 64 +++ 16 files changed, 1399 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/screens/session-archive-model.test.ts create mode 100644 apps/mobile/src/screens/session-archive-model.ts create mode 100644 apps/mobile/src/state/use-session-archives.test.tsx create mode 100644 apps/mobile/src/state/use-session-archives.ts create mode 100644 apps/mobile/src/storage/session-archive-repository.test.ts create mode 100644 apps/mobile/src/storage/session-archive-repository.ts diff --git a/apps/mobile/src/screens/session-archive-model.test.ts b/apps/mobile/src/screens/session-archive-model.test.ts new file mode 100644 index 0000000..0a6b7ee --- /dev/null +++ b/apps/mobile/src/screens/session-archive-model.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from "@jest/globals"; +import type { SessionInfo } from "@opencode2-mobile/opencode-adapter"; +import type { FollowedInboxRow, FollowedInboxSections } from "../state/followed-project-inbox"; +import { + addArchivedSessions, + resolveSessionArchiveIds, + selectSessionArchiveView, + sessionAutoArchiveAgeMs, +} from "./session-archive-model"; + +function row(id: string, active = false, attentionCount = 0): FollowedInboxRow { + const session = { + id, + projectID: "project", + title: id, + location: { directory: "/workspace" }, + time: { created: 1, updated: 1 }, + } as SessionInfo; + return { + session, + active, + attentionCount, + activeChildCount: active ? 1 : 0, + children: [], + projectLabel: "Project", + section: "recent", + targetLocation: session.location, + targetSessionID: id, + }; +} +const idle = row("ses_idle"); +const active = row("ses_active", true); +const blocked = row("ses_blocked", false, 1); +const other = row("ses_other"); +const inbox: FollowedInboxSections = { + needsYou: [blocked], + working: [active], + recent: [idle, other], + unmatchedSessionIDs: [], +}; + +test("archives only idle sessions strictly older than 30 days since their last update", () => { + const now = sessionAutoArchiveAgeMs * 2; + const boundary = row("ses_boundary"); + boundary.session.time.updated = now - sessionAutoArchiveAgeMs; + const childActive = { ...row("ses_child_active"), activeChildCount: 1 }; + expect( + resolveSessionArchiveIds({ ...inbox, recent: [idle, boundary, childActive] }, [], {}, now), + ).toEqual(["ses_idle"]); + expect(resolveSessionArchiveIds(inbox, ["ses_manual"], {}, now)).toEqual([ + "ses_manual", + "ses_idle", + "ses_other", + ]); + expect(resolveSessionArchiveIds(inbox, [], { ses_idle: now, ses_other: now }, now)).toEqual([]); + expect( + resolveSessionArchiveIds( + inbox, + [], + { ses_idle: now, ses_other: now }, + now + sessionAutoArchiveAgeMs + 1, + ), + ).toEqual(["ses_idle", "ses_other"]); +}); + +test("restoring an old session moves it to the top of Recent without changing server timestamps", () => { + const restored = row("ses_restored"); + expect( + selectSessionArchiveView({ ...inbox, recent: [other, restored] }, [], false, { + ses_restored: 100, + }).recent.map((item) => item.session.id), + ).toEqual(["ses_restored", "ses_other"]); + expect(restored.session.time.updated).toBe(1); +}); + +test("hides idle archives but never hides active tasks or attention", () => { + const ids = [idle.session.id, active.session.id, blocked.session.id]; + expect(selectSessionArchiveView(inbox, ids, false)).toEqual({ ...inbox, recent: [other] }); + expect(selectSessionArchiveView(inbox, ids, true)).toEqual({ ...inbox, recent: [idle] }); + expect(selectSessionArchiveView(inbox, [], false)).toEqual(inbox); +}); + +test("restores archived sessions outside the loaded pages without copying server content", () => { + const older = row("ses_older").session; + const extra = addArchivedSessions( + inbox, + [ + idle.session, + older, + { ...older, id: "ses_unfollowed", projectID: "unfollowed" }, + { ...older, id: "ses_child", parentID: "ses_idle" }, + ], + new Map([["project", "Project"]]), + "", + ); + expect(extra.recent.map((item) => item.session.id)).toEqual([ + "ses_idle", + "ses_other", + "ses_older", + ]); + expect( + addArchivedSessions(inbox, [older], new Map([["project", "Project"]]), "non-matching").recent, + ).toEqual(inbox.recent); +}); diff --git a/apps/mobile/src/screens/session-archive-model.ts b/apps/mobile/src/screens/session-archive-model.ts new file mode 100644 index 0000000..97668d1 --- /dev/null +++ b/apps/mobile/src/screens/session-archive-model.ts @@ -0,0 +1,86 @@ +import type { SessionInfo } from "@opencode2-mobile/opencode-adapter"; +import type { FollowedInboxSections } from "../state/followed-project-inbox"; + +export const sessionAutoArchiveAgeMs = 30 * 24 * 60 * 60 * 1_000; + +export function resolveSessionArchiveIds( + inbox: FollowedInboxSections, + manualIds: readonly string[], + restoredAt: Readonly>, + now: number, +) { + const archived = new Set(manualIds); + for (const row of [...inbox.needsYou, ...inbox.working, ...inbox.recent]) { + if (row.active || row.activeChildCount > 0 || row.attentionCount > 0 || row.session.parentID) + continue; + const lastActivity = Math.max(row.session.time.updated, restoredAt[row.session.id] ?? 0); + if ( + Number.isFinite(lastActivity) && + lastActivity > 0 && + lastActivity < now - sessionAutoArchiveAgeMs + ) + archived.add(row.session.id); + } + return [...archived]; +} + +export function selectSessionArchiveView( + inbox: FollowedInboxSections, + archivedIds: readonly string[], + archivedView: boolean, + restoredAt: Readonly> = {}, +) { + const archived = new Set(archivedIds); + const visible = (row: FollowedInboxSections["recent"][number]) => + archivedView + ? archived.has(row.session.id) + : !archived.has(row.session.id) || row.active || row.attentionCount > 0; + return { + ...inbox, + needsYou: inbox.needsYou.filter(visible), + working: inbox.working.filter(visible), + recent: inbox.recent + .filter(visible) + .sort( + (a, b) => + Math.max(b.session.time.updated, restoredAt[b.session.id] ?? 0) - + Math.max(a.session.time.updated, restoredAt[a.session.id] ?? 0), + ), + }; +} + +export function addArchivedSessions( + inbox: FollowedInboxSections, + sessions: readonly SessionInfo[], + projectLabels: Map, + search: string, +): FollowedInboxSections { + const known = new Set( + [...inbox.needsYou, ...inbox.working, ...inbox.recent].map((row) => row.session.id), + ); + const extra = sessions + .filter( + (session) => + !known.has(session.id) && + !session.parentID && + projectLabels.has(session.projectID) && + (!search || (session.title ?? "").toLocaleLowerCase().includes(search.toLocaleLowerCase())), + ) + .map((session) => ({ + active: false, + activeChildCount: 0, + attentionCount: 0, + children: [], + projectLabel: projectLabels.get(session.projectID) ?? "Project", + section: "recent" as const, + session, + targetLocation: session.location, + targetSessionID: session.id, + })); + return { + ...inbox, + recent: [...inbox.recent, ...extra].sort( + (a, b) => b.session.time.updated - a.session.time.updated, + ), + }; +} diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 02f0c8e..4443852 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -1,3 +1,4 @@ +import Feather from "@expo/vector-icons/Feather"; import { expect, jest, test } from "@jest/globals"; import { getOpenCodeLocation, @@ -10,9 +11,9 @@ import { type SessionMessagesResponse, } from "@opencode2-mobile/opencode-adapter"; import { type InfiniteData, QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react-native"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react-native"; import type { ReactNode } from "react"; -import { Dimensions, FlatList, Platform, RefreshControl } from "react-native"; +import { Alert, Dimensions, FlatList, Platform, RefreshControl } from "react-native"; import { ConnectionEventQueryBridge } from "../state/connection-event-query-bridge"; import { openCodeQueryKeys } from "../state/open-code-query-keys"; import { WorkspaceSelectionProvider } from "../state/workspace-selection-context"; @@ -50,10 +51,71 @@ const mockReplyPermission = jest.fn(); const mockSetLocation = jest.fn(); const mockWorkspaceRefetch = jest.fn<() => Promise>(async () => undefined); let mockWorkspacePermissions: PermissionRequest[] = []; +let mockWorkspaceActive = false; +let mockArchivedIds: string[] = []; +let mockArchiveWriteFailure = false; +const mockSessionNow = Date.now(); +let mockOldSession = false; +let mockRestoredAt: Record = {}; +let mockHasNextSessionPage = false; +let mockAllSessionsOld = false; +let mockSessionsFetchingNextPage = false; +let mockSessionsError = false; +const mockFetchNextSessionPage = jest.fn(async () => undefined); +function mockWorkspaceRow(index: number) { + const session = { + cost: 0, + id: `ses_${index}`, + location: { directory: "/workspace" }, + projectID: "project-1", + time: { + created: mockSessionNow - index, + updated: + mockAllSessionsOld || (mockOldSession && index === 0) + ? mockSessionNow - 31 * 24 * 60 * 60 * 1_000 + : mockSessionNow - index, + }, + title: `Session ${index}`, + tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, + }; + return { + active: mockWorkspaceActive, + activeChildCount: 0, + attentionCount: 0, + children: [], + projectLabel: "Workspace", + section: mockWorkspaceActive ? "working" : "recent", + session, + targetLocation: session.location, + targetSessionID: session.id, + }; +} const mockDraftDb = { - getAllAsync: jest.fn(async () => []), + getAllAsync: jest.fn(async (sql: string) => + sql.includes("session_archives") + ? [ + ...mockArchivedIds.map((session_id) => ({ session_id, restored_at_ms: null })), + ...Object.entries(mockRestoredAt).map(([session_id, restored_at_ms]) => ({ + session_id, + restored_at_ms, + })), + ] + : [], + ), getFirstAsync: jest.fn(async () => undefined), - runAsync: jest.fn(async () => undefined), + runAsync: jest.fn(async (sql: string, ...args: unknown[]) => { + if (sql.includes("session_archives")) { + if (mockArchiveWriteFailure) throw new Error("disk"); + const id = args[1] as string; + if (args[2] == null) { + mockArchivedIds = [...new Set([...mockArchivedIds, id])]; + delete mockRestoredAt[id]; + } else { + mockArchivedIds = mockArchivedIds.filter((value) => value !== id); + mockRestoredAt[id] = args[2] as number; + } + } + }), withExclusiveTransactionAsync: jest.fn(async (task: (txn: unknown) => Promise) => task(mockDraftDb), ), @@ -90,7 +152,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ interruptOpenCodeSession: jest.fn(), listActiveOpenCodeSessions: jest.fn(async () => ({})), listOpenCodeAgents: jest.fn(async () => ({ data: [], location })), - listOpenCodeFormRequests: mockListForms, + listOpenCodeFormRequests: () => mockListForms(), listOpenCodeMessages: jest.fn( async (_client: unknown, _sessionID: string, input: { cursor?: string }) => input.cursor @@ -139,7 +201,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ }, ), listOpenCodeModels: jest.fn(async () => ({ data: [], location })), - listOpenCodePermissionRequests: mockListPermissions, + listOpenCodePermissionRequests: () => mockListPermissions(), listOpenCodeProjects: jest.fn(async () => [ { canonical: "/workspace", @@ -156,7 +218,7 @@ jest.mock("@opencode2-mobile/opencode-adapter", () => ({ location: { directory: "/workspace" }, projectID: "project-1", outcome: "succeeded", - time: { created: 120 - index, updated: 120 - index }, + time: { created: mockSessionNow - index, updated: mockSessionNow - index }, title: `Session ${index}`, tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, })), @@ -194,6 +256,7 @@ jest.mock("../state/workspace-selection-context", () => ({ useWorkspaceSelection: () => ({ attentionCoverage: { completeness: "complete", + freshness: "current", failedLocationCount: 0, knownLocationCount: 1, reasons: [], @@ -201,37 +264,21 @@ jest.mock("../state/workspace-selection-context", () => ({ revision: 1, }, blockedSessionIds: new Set(mockWorkspacePermissions.map((request) => request.sessionID)), - fetchNextPage: jest.fn(async () => undefined), + fetchNextPage: mockFetchNextSessionPage, followedProjectIds: ["project-1"], formLocations: new Map(), forms: [], - hasNextPage: false, + hasNextPage: mockHasNextSessionPage, + sessionsFetchingNextPage: mockSessionsFetchingNextPage, inbox: { needsYou: [], - recent: Array.from({ length: 120 }, (_, index) => { - const session = { - cost: 0, - id: `ses_${index}`, - location: { directory: "/workspace" }, - projectID: "project-1", - time: { created: 120 - index, updated: 120 - index }, - title: `Session ${index}`, - tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, - }; - return { - active: false, - activeChildCount: 0, - attentionCount: 0, - children: [], - projectLabel: "Workspace", - section: "recent", - session, - targetLocation: session.location, - targetSessionID: session.id, - }; - }), + recent: mockWorkspaceActive + ? [] + : Array.from({ length: 120 }, (_, index) => mockWorkspaceRow(index)), unmatchedSessionIDs: [], - working: [], + working: mockWorkspaceActive + ? Array.from({ length: 120 }, (_, index) => mockWorkspaceRow(index)) + : [], }, interactionsError: false, interactionsLoading: false, @@ -246,7 +293,7 @@ jest.mock("../state/workspace-selection-context", () => ({ refetch: mockWorkspaceRefetch, replyPermission: mockReplyPermission, search: "", - sessionsError: false, + sessionsError: mockSessionsError, sessionsLoading: false, setFollowedProjectIds: jest.fn(async () => undefined), setLocation: mockSetLocation, @@ -469,6 +516,392 @@ test("shows a permission blocking the open session and can reply", async () => { } }); +test.each([false, true])( + "shows the branch instead of the directory when working is %s", + async (active) => { + mockWorkspaceActive = active; + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + const row = within(screen.getByRole("button", { name: /^Open session Session 0\./ })); + expect(await row.findByText("docs/mobile-workflow-screenshots")).toBeOnTheScreen(); + expect(row.UNSAFE_getByType(Feather).props).toMatchObject({ + accessibilityElementsHidden: true, + importantForAccessibility: "no-hide-descendants", + name: "git-branch", + }); + expect(row.queryByText("/workspace")).not.toBeOnTheScreen(); + if (active) { + expect( + screen.getByRole("button", { name: /^Open session Session 0\. Workspace\. Working/ }), + ).toBeOnTheScreen(); + } + view.unmount(); + queryClient.clear(); + mockWorkspaceActive = false; + }, +); + +test("Load older sessions reveals already loaded inbox rows", async () => { + mockHasNextSessionPage = true; + mockFetchNextSessionPage.mockClear(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + await screen.findByText("Session 0"); + expect(screen.queryByText("Session 20")).not.toBeOnTheScreen(); + expect(screen.queryByTestId("session-archive-footer")).not.toBeOnTheScreen(); + expect( + screen.queryByText("Idle sessions archive automatically after 30 days without updates."), + ).not.toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Load older sessions" })); + expect(view.UNSAFE_getByType(FlatList).props.data).toEqual( + expect.arrayContaining([expect.objectContaining({ key: "ses_20", type: "session" })]), + ); + expect(mockFetchNextSessionPage).not.toHaveBeenCalled(); + for (let index = 0; index < 4; index++) + fireEvent.press(screen.getByRole("button", { name: "Load older sessions" })); + expect( + view + .UNSAFE_getByType(FlatList) + .props.data.filter((item: { type: string }) => item.type === "session"), + ).toHaveLength(120); + await waitFor(() => expect(mockFetchNextSessionPage).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("button", { name: "Load older sessions" })).not.toBeOnTheScreen(); + expect(screen.queryByTestId("session-archive-footer")).not.toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); + mockHasNextSessionPage = false; +}); + +test("does not offer older Inbox sessions when remaining history is archived", async () => { + mockAllSessionsOld = true; + mockHasNextSessionPage = true; + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + expect(screen.queryByRole("button", { name: "Load older sessions" })).not.toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); + mockAllSessionsOld = false; + mockHasNextSessionPage = false; +}); + +test("Inbox lookahead waits for pending pages, reveals eligible rows, and stops on errors", async () => { + mockAllSessionsOld = true; + mockHasNextSessionPage = true; + mockSessionsFetchingNextPage = true; + mockFetchNextSessionPage.mockClear(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const content = () => ( + + + + ); + const view = render(content()); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + expect(mockFetchNextSessionPage).not.toHaveBeenCalled(); + expect(screen.queryByTestId("session-archive-footer")).not.toBeOnTheScreen(); + mockSessionsFetchingNextPage = false; + mockSessionsError = true; + view.rerender(content()); + expect(mockFetchNextSessionPage).not.toHaveBeenCalled(); + mockSessionsError = false; + view.rerender(content()); + await waitFor(() => expect(mockFetchNextSessionPage).toHaveBeenCalledTimes(1)); + mockAllSessionsOld = false; + mockHasNextSessionPage = false; + view.rerender(content()); + expect(screen.getByRole("button", { name: "Load older sessions" })).toBeOnTheScreen(); + expect(mockFetchNextSessionPage).toHaveBeenCalledTimes(1); + view.unmount(); + queryClient.clear(); +}); + +test("hides Load older sessions once all rows are visible and the server is exhausted", async () => { + mockHasNextSessionPage = false; + mockFetchNextSessionPage.mockClear(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + await screen.findByText("Session 0"); + expect(screen.queryByTestId("session-archive-footer")).not.toBeOnTheScreen(); + for (let index = 0; index < 5; index++) + fireEvent.press(screen.getByRole("button", { name: "Load older sessions" })); + expect(screen.queryByRole("button", { name: "Load older sessions" })).not.toBeOnTheScreen(); + expect(mockFetchNextSessionPage).not.toHaveBeenCalled(); + const footer = await screen.findByTestId("session-archive-footer"); + expect( + within(footer).getByText("Idle sessions archive automatically after 30 days without updates."), + ).toBeOnTheScreen(); + expect(view.UNSAFE_getByType(FlatList).props.ListFooterComponent.props.testID).toBe( + "session-archive-footer", + ); + fireEvent.press(screen.getByRole("button", { name: "Archived" })); + expect(screen.queryByRole("button", { name: "Load older sessions" })).not.toBeOnTheScreen(); + expect( + within(screen.getByTestId("session-archive-footer")).getByText( + /Archived on this device only\. Restore/, + ), + ).toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Inbox" })); + expect(screen.queryByRole("button", { name: "Load older sessions" })).not.toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); +}); + +test("auto archives sessions after 30 days and Restore survives a remount", async () => { + mockOldSession = true; + mockRestoredAt = {}; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity }, + }, + }); + const content = () => ( + + + + ); + let view = render(content()); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + expect(screen.queryByRole("button", { name: "Keep latest 10" })).not.toBeOnTheScreen(); + fireEvent.press(screen.getByRole("button", { name: "Archived" })); + const oldSession = await screen.findByRole("button", { name: /^Open session Session 0\./ }); + fireEvent(oldSession, "accessibilityAction", { nativeEvent: { actionName: "archive" } }); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + fireEvent.press(screen.getByRole("button", { name: "Inbox" })); + expect(await screen.findByText("Session 0")).toBeOnTheScreen(); + expect(mockRestoredAt.ses_0).toBeGreaterThan(0); + view.unmount(); + queryClient.clear(); + view = render(content()); + await screen.findByText("Session 0"); + await waitFor(() => + expect( + screen.getByRole("button", { name: /^Open session Session 0\./ }).props.accessibilityActions, + ).toEqual(expect.arrayContaining([expect.objectContaining({ name: "archive" })])), + ); + expect(screen.getByText("Session 0")).toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); + mockOldSession = false; + mockRestoredAt = {}; +}); + +test("old working sessions remain visible and auto archive when they become idle", async () => { + mockOldSession = true; + mockWorkspaceActive = true; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity }, + }, + }); + const content = () => ( + + + + ); + const view = render(content()); + await screen.findByText("Session 0"); + mockWorkspaceActive = false; + view.rerender(content()); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + fireEvent.press(screen.getByRole("button", { name: "Archived" })); + expect(await screen.findByText("Session 0")).toBeOnTheScreen(); + view.unmount(); + queryClient.clear(); + mockOldSession = false; +}); + +test("a recently restored session outside the loaded pages returns to Inbox", async () => { + mockRestoredAt = { ses_restored_older: Date.now() }; + jest.mocked(getOpenCodeSession).mockResolvedValueOnce({ + cost: 0, + id: "ses_restored_older", + location: { directory: "/workspace" }, + projectID: "project-1", + title: "Restored older session", + time: { created: 1, updated: Date.now() - 31 * 24 * 60 * 60 * 1_000 }, + tokens: { cache: { read: 0, write: 0 }, input: 0, output: 0, reasoning: 0 }, + }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const view = render( + + + , + ); + expect(await screen.findByText("Restored older session")).toBeOnTheScreen(); + expect( + screen.getByRole("button", { name: /^Open session Restored older session\./ }).props + .accessibilityLabel, + ).not.toContain("Archived"); + view.unmount(); + queryClient.clear(); + mockRestoredAt = {}; +}); + +test("archives from the list, restores from Archived, and survives a remount", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity }, + }, + }); + const renderWorkspace = () => + render( + + + , + ); + let view = renderWorkspace(); + const session = screen.getByRole("button", { name: /^Open session Session 0\./ }); + await waitFor(() => + expect(session.props.accessibilityActions).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "archive" })]), + ), + ); + fireEvent(session, "accessibilityAction", { nativeEvent: { actionName: "archive" } }); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + expect(mockArchivedIds).toContain("ses_0"); + view.unmount(); + queryClient.clear(); + view = renderWorkspace(); + await screen.findByText("Session 1"); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + fireEvent.press(screen.getByRole("button", { name: "Archived" })); + const archived = await screen.findByRole("button", { name: /^Open session Session 0\./ }); + fireEvent(archived, "accessibilityAction", { nativeEvent: { actionName: "archive" } }); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + fireEvent.press(screen.getByRole("button", { name: "Inbox" })); + expect(await screen.findByText("Session 0")).toBeOnTheScreen(); + expect(mockArchivedIds).not.toContain("ses_0"); + view.unmount(); + queryClient.clear(); +}); + +test("failed archive writes keep the session in the inbox", async () => { + const alert = jest.spyOn(Alert, "alert").mockImplementation(() => undefined); + mockArchiveWriteFailure = true; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity }, + }, + }); + const view = render( + + + , + ); + const session = screen.getByRole("button", { name: /^Open session Session 0\./ }); + await waitFor(() => + expect(session.props.accessibilityActions).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "archive" })]), + ), + ); + fireEvent(session, "accessibilityAction", { nativeEvent: { actionName: "archive" } }); + await waitFor(() => + expect(alert).toHaveBeenCalledWith("Archive preference not saved", expect.any(String)), + ); + expect(screen.getByText("Session 0")).toBeOnTheScreen(); + mockArchiveWriteFailure = false; + alert.mockRestore(); + view.unmount(); + queryClient.clear(); +}); + +test("an archived working session stays in the inbox until it becomes idle", async () => { + mockArchivedIds = ["ses_0"]; + mockWorkspaceActive = true; + const queryClient = new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); + const content = ( + + + + ); + const view = render(content); + expect(await screen.findByText("Archived on this device")).toBeOnTheScreen(); + expect( + screen.getByRole("button", { name: /^Open session Session 0\. Workspace\. Working/ }), + ).toBeOnTheScreen(); + mockWorkspaceActive = false; + view.rerender( + + + , + ); + await waitFor(() => expect(screen.queryByText("Session 0")).not.toBeOnTheScreen()); + mockArchivedIds = []; + view.unmount(); + queryClient.clear(); +}); + test("publishes an unchanged resolved location only once", async () => { const errors: unknown[][] = []; const consoleError = jest.spyOn(console, "error").mockImplementation((...args: unknown[]) => { @@ -614,7 +1047,10 @@ test("keeps connection management and new-session controls out of the phone list expect(screen.queryByRole("header", { name: "Sessions" })).toBeNull(); expect(screen.queryByRole("button", { name: "Filter" })).toBeNull(); expect(screen.queryByLabelText(/Change new session location/)).toBeNull(); - expect(screen.queryByText("Inbox")).toBeNull(); + expect(screen.getByRole("button", { name: "Inbox" }).props.accessibilityState).toEqual({ + selected: true, + }); + expect(screen.getByRole("button", { name: "Archived" })).toBeOnTheScreen(); expect(screen.getByText("Recent")).toBeOnTheScreen(); const list = screen.UNSAFE_getByType(FlatList); expect(list.props.data.filter((item: { type: string }) => item.type === "session")).toHaveLength( @@ -622,7 +1058,7 @@ test("keeps connection management and new-session controls out of the phone list ); expect(screen.getByText("20")).toBeOnTheScreen(); expect(screen.queryByRole("button", { name: "Load older" })).toBeNull(); - expect(screen.getByRole("button", { name: "Search older sessions" })).toBeOnTheScreen(); + expect(screen.getByRole("button", { name: "Load older sessions" })).toBeOnTheScreen(); fireEvent.changeText(screen.getByLabelText("Search sessions"), "Session"); await waitFor(() => expect(screen.getByText("Search results")).toBeOnTheScreen()); expect(list.props.data.filter((item: { type: string }) => item.type === "session")).toHaveLength( diff --git a/apps/mobile/src/screens/workspace-screen.tsx b/apps/mobile/src/screens/workspace-screen.tsx index 4ce0d64..35f78b0 100644 --- a/apps/mobile/src/screens/workspace-screen.tsx +++ b/apps/mobile/src/screens/workspace-screen.tsx @@ -13,13 +13,20 @@ import { type SessionMessagesResponse, } from "@opencode2-mobile/opencode-adapter"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQueries, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import * as Haptics from "expo-haptics"; import { useSQLiteContext } from "expo-sqlite"; import { useCallback, useDeferredValue, useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, + AppState, Dimensions, FlatList, Keyboard, @@ -52,6 +59,7 @@ import { } from "../state/transcript-performance"; import { useTranscriptPreferences } from "../state/transcript-preferences"; import { useModelFavorites } from "../state/use-model-favorites"; +import { useSessionArchives } from "../state/use-session-archives"; import { useWorkspaceSelection } from "../state/workspace-selection-context"; import { deleteSessionLocalState } from "../storage/prompt-admission-repository"; import { @@ -66,6 +74,12 @@ import { import { ActionButton, isTabletShell, ShellFrame } from "./app-shell"; import { SessionChanges } from "./diff-screen"; import { FormRequestList } from "./form-request-list"; +import { + addArchivedSessions, + resolveSessionArchiveIds, + selectSessionArchiveView, + sessionAutoArchiveAgeMs, +} from "./session-archive-model"; import { SessionComposer } from "./session-composer"; import { loadOpenCodeSessionTreeIds } from "./session-deletion"; import { SessionExecutionPanel } from "./session-execution-panel"; @@ -117,6 +131,25 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { const [selectedProjectId, setSelectedProjectId] = useState(); const [selectedDirectory, setSelectedDirectory] = useState(); const [sessionSearch, setSessionSearch] = useState(""); + const [archivedView, setArchivedView] = useState(false); + const [visibleRecentLimit, setVisibleRecentLimit] = useState(recentSessionLimit); + useEffect(() => { + void connectionId; + void runtime.connectionUpdatedAtMs; + setVisibleRecentLimit(recentSessionLimit); + }, [connectionId, runtime.connectionUpdatedAtMs]); + const archives = useSessionArchives(connectionId, runtime.connectionUpdatedAtMs); + const [archiveNow, setArchiveNow] = useState(Date.now); + useEffect(() => { + const timer = setInterval(() => setArchiveNow(Date.now()), 60_000); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") setArchiveNow(Date.now()); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, []); const searchInputRef = useRef(null); const [refreshing, setRefreshing] = useState(false); const removeAbortRef = useRef(null); @@ -128,6 +161,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { setSelectedProjectId(undefined); setSelectedDirectory(undefined); setSessionSearch(""); + setArchivedView(false); refreshGenerationRef.current += 1; setRefreshing(false); }, [connectionId]); @@ -209,12 +243,75 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { workspaceSelection.setLocation(location); }, [location, workspaceSelection.setLocation]); - const inboxItems = workspaceInboxItems(workspaceSelection.inbox, Boolean(deferredSessionSearch)); - const ambiguousProjectIDs = ambiguousInboxProjectIDs(workspaceSelection.inbox); + const loadedSessionIds = new Set( + [ + ...workspaceSelection.inbox.needsYou, + ...workspaceSelection.inbox.working, + ...workspaceSelection.inbox.recent, + ].map((row) => row.session.id), + ); + const archivedSessionQueries = useQueries({ + queries: [...new Set([...archives.ids, ...Object.keys(archives.restoredAt)])].map( + (sessionID) => ({ + enabled: Boolean( + (archivedView || + (archives.restoredAt[sessionID] ?? 0) >= archiveNow - sessionAutoArchiveAgeMs) && + client && + connectionId && + !loadedSessionIds.has(sessionID), + ), + queryKey: openCodeQueryKeys.archivedSession(connectionId ?? "unselected", sessionID), + queryFn: ({ signal }: { signal: AbortSignal }) => { + if (!client) throw new Error("CONNECTION_NOT_READY"); + return getOpenCodeSession(client, sessionID, { signal }); + }, + }), + ), + }); + const archiveInbox = addArchivedSessions( + workspaceSelection.inbox, + archivedSessionQueries.flatMap((query) => (query.data ? [query.data] : [])), + new Map( + projects + .filter((project) => workspaceSelection.followedProjectIds.includes(project.id)) + .map((project) => [ + project.id, + project.name ?? project.canonical.split("/").filter(Boolean).at(-1) ?? "Project", + ]), + ), + deferredSessionSearch, + ); + const archivedIds = + !archives.loaded || archives.error + ? archives.ids + : resolveSessionArchiveIds(archiveInbox, archives.ids, archives.restoredAt, archiveNow); + const visibleInbox = selectSessionArchiveView( + archiveInbox, + archivedIds, + archivedView, + archives.restoredAt, + ); + const needsInboxLookahead = + !archivedView && + !deferredSessionSearch && + archives.loaded && + !archives.error && + !workspaceSelection.sessionsLoading && + !workspaceSelection.sessionsError && + workspaceSelection.hasNextPage && + !workspaceSelection.sessionsFetchingNextPage && + visibleInbox.recent.length <= visibleRecentLimit; + useEffect(() => { + if (needsInboxLookahead) void workspaceSelection.fetchNextPage().catch(() => undefined); + }, [needsInboxLookahead, workspaceSelection.fetchNextPage]); + const inboxItems = workspaceInboxItems( + visibleInbox, + archivedView || Boolean(deferredSessionSearch), + archives.restoredAt, + visibleRecentLimit, + ); const sessionCount = - workspaceSelection.inbox.needsYou.length + - workspaceSelection.inbox.working.length + - workspaceSelection.inbox.recent.length; + visibleInbox.needsYou.length + visibleInbox.working.length + visibleInbox.recent.length; const selectedConnection = connections.profiles.find( (profile) => profile.id === connections.selectedProfileId, ); @@ -272,6 +369,7 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { void queryClient.invalidateQueries({ queryKey: openCodeQueryKeys.connection(connectionId), }); + void queryClient.invalidateQueries({ queryKey: ["device-session-archives", connectionId] }); if (!cleanupSucceeded) { Alert.alert( "Local cleanup incomplete", @@ -291,6 +389,8 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { setRefreshing(true); try { await Promise.all([ + archives.refetch(), + ...(archivedView ? archivedSessionQueries.map((query) => query.refetch()) : []), workspaceSelection.refetch(), ...(requestedLocation ? [locationQuery.refetch()] : []), ]); @@ -323,6 +423,14 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { ); } + async function toggleArchive(session: SessionInfo) { + try { + await archives.setArchived(session.id, !archivedIds.includes(session.id)); + } catch { + Alert.alert("Archive preference not saved", "The list has not changed. Try again."); + } + } + const header = ( {tablet ? ( @@ -389,6 +497,39 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { ) : null} + + setArchivedView(false)} + style={styles.archiveFilter} + > + + Inbox + + + setArchivedView(true)} + style={styles.archiveFilter} + > + + Archived + + + + {archives.error ? ( + + Archive preferences could not be loaded. Pull to refresh. + + ) : null} + {archivedView && archivedSessionQueries.some((query) => query.isError) ? ( + + Some archived sessions are unavailable. Pull to refresh to retry. + + ) : null} + {deferredSessionSearch ? ( Search results @@ -409,29 +550,73 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { 0} - loading={workspaceSelection.sessionsLoading} + loading={ + workspaceSelection.sessionsLoading || + (!archivedView && + !deferredSessionSearch && + workspaceSelection.sessionsFetchingNextPage) || + archives.busy || + (archivedView && + archivedSessionQueries.some( + (query) => query.isPending && query.fetchStatus === "fetching", + )) + } search={deferredSessionSearch} + archivedView={archivedView} + hasMore={ + workspaceSelection.hasNextPage && Boolean(deferredSessionSearch) && !archivedView + } /> } ListFooterComponent={ - deferredSessionSearch && workspaceSelection.hasNextPage ? ( + (archivedView || deferredSessionSearch) && workspaceSelection.hasNextPage ? ( void workspaceSelection.fetchNextPage()} secondary /> - ) : !deferredSessionSearch && - (workspaceSelection.inbox.recent.length >= recentSessionLimit || - workspaceSelection.hasNextPage) ? ( + ) : !archivedView && + !deferredSessionSearch && + visibleInbox.recent.length > visibleRecentLimit ? ( searchInputRef.current?.focus()} + label="Load older sessions" + onPress={() => { + setVisibleRecentLimit((limit) => limit + recentSessionLimit); + }} secondary /> + ) : !archivedView && + !deferredSessionSearch && + workspaceSelection.sessionsFetchingNextPage ? ( + + + + ) : archives.loaded && + !archives.busy && + !archives.error && + !workspaceSelection.sessionsLoading && + !workspaceSelection.sessionsError && + !workspaceSelection.hasNextPage && + !workspaceSelection.sessionsFetchingNextPage && + !archivedSessionQueries.some((query) => query.fetchStatus === "fetching") ? ( + + + Idle sessions archive automatically after 30 days without updates. + + {archivedView ? ( + + Archived on this device only. Restore keeps a session in Inbox for another 30 + days. Working sessions and requests remain visible. + + ) : null} + ) : null } ListHeaderComponent={header} @@ -459,6 +644,9 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { ) : ( void toggleArchive(item.row.session)} onDelete={() => confirmDelete(item.row.session)} onOpenChild={(child) => { if (!connectionId) return; @@ -470,7 +658,6 @@ export function WorkspaceScreen({ navigation }: WorkspaceProps) { }} onPress={() => openSession(item.row)} row={item.row} - showLocation={ambiguousProjectIDs.has(item.row.session.projectID)} /> ) } @@ -1184,21 +1371,43 @@ export function SessionScreen({ navigation, route }: SessionProps) { } function SessionRow({ + archived, + archiveDisabled, + onArchive, largeText, onDelete, onOpenChild, onPress, row, - showLocation, }: { + archived: boolean; + archiveDisabled: boolean; + onArchive: () => void; largeText: boolean; onDelete: () => void; onOpenChild: (child: SessionInfo) => void; onPress: () => void; row: FollowedInboxRow; - showLocation: boolean; }) { const { session } = row; + const runtime = useConnectionRuntime(); + const vcsQuery = useQuery({ + enabled: Boolean(runtime.restClient && runtime.connectionId), + queryFn: ({ signal }) => { + if (!runtime.restClient) throw new Error("CONNECTION_NOT_READY"); + return getOpenCodeVcs(runtime.restClient, session.location, { signal }); + }, + queryKey: openCodeQueryKeys.vcs(runtime.connectionId ?? "unselected", session.location), + staleTime: 30_000, + }); + const branchName = vcsQuery.data?.data.branch.current; + const branchLabel = branchName + ? branchName + : vcsQuery.isError || runtime.status !== "connected" + ? "Branch unavailable" + : vcsQuery.isPending + ? "Checking branch" + : "No branch"; const { fontScale } = useWindowDimensions(); const active = row.active; const blocked = row.attentionCount > 0; @@ -1211,6 +1420,7 @@ function SessionRow({ blocked || row.activeChildCount > 0 || row.attentionCount > 1 || + archived || Boolean(session.time.archived) || showOutcome; return ( @@ -1223,6 +1433,26 @@ function SessionRow({ overshootRight={false} renderRightActions={(_progress, _translation, swipeable) => ( + { + swipeable.close(); + onArchive(); + }} + style={[styles.swipeArchive, archiveDisabled && styles.archiveDisabled]} + > + + {archived ? "Restore" : "Archive"} + { if (nativeEvent.actionName === "activate") onPress(); if (nativeEvent.actionName === "delete") onDelete(); + if (nativeEvent.actionName === "archive" && !archiveDisabled) onArchive(); }} onPress={onPress} style={({ pressed }) => [ @@ -1291,7 +1528,9 @@ function SessionRow({ {row.attentionCount > 1 ? ( {row.attentionCount} requests ) : null} - {session.time.archived ? ( + {archived ? ( + Archived on this device + ) : session.time.archived ? ( Archived ) : null} {showOutcome && session.outcome ? ( @@ -1299,11 +1538,18 @@ function SessionRow({ ) : null} ) : null} - {showLocation ? ( - - {session.location.directory} + + + + {branchLabel} - ) : null} + {row.children.map((child) => ( @@ -1332,11 +1578,15 @@ function SessionRow({ } function SessionEmptyState({ + archivedView, + hasMore, error, hasLocation, loading, search, }: { + archivedView: boolean; + hasMore: boolean; error: boolean; hasLocation: boolean; loading: boolean; @@ -1363,7 +1613,11 @@ function SessionEmptyState({ ? "Follow a project to build this inbox." : search ? "No sessions match this search." - : "No root sessions in followed projects."} + : archivedView + ? "No archived sessions." + : hasMore + ? "No inbox sessions on this page. Load older sessions below." + : "Inbox clear."} ); @@ -1482,12 +1736,18 @@ type WorkspaceInboxItem = function workspaceInboxItems( inbox: ReturnType["inbox"], flattenSections: boolean, + restoredAt: Readonly> = {}, + recentLimit = recentSessionLimit, ) { const items: WorkspaceInboxItem[] = []; const recentIDs = new Set( [...inbox.recent] - .sort((first, second) => second.session.time.updated - first.session.time.updated) - .slice(0, recentSessionLimit) + .sort( + (first, second) => + Math.max(second.session.time.updated, restoredAt[second.session.id] ?? 0) - + Math.max(first.session.time.updated, restoredAt[first.session.id] ?? 0), + ) + .slice(0, recentLimit) .map((row) => row.session.id), ); for (const [key, label, rows] of [ @@ -1510,20 +1770,6 @@ function workspaceInboxItems( return items; } -function ambiguousInboxProjectIDs(inbox: ReturnType["inbox"]) { - const locationsByProject = new Map>(); - for (const row of [...inbox.needsYou, ...inbox.working, ...inbox.recent]) { - const locations = locationsByProject.get(row.session.projectID) ?? new Set(); - locations.add(`${row.session.location.directory}\u0000`); - locationsByProject.set(row.session.projectID, locations); - } - return new Set( - [...locationsByProject.entries()] - .filter(([, locations]) => locations.size > 1) - .map(([projectID]) => projectID), - ); -} - function sentenceCase(value: string) { return value ? `${value[0]?.toLocaleUpperCase()}${value.slice(1).toLocaleLowerCase()}` : value; } @@ -1728,11 +1974,18 @@ const styles = StyleSheet.create({ selectionMark: { color: palette.signal, fontSize: 12, fontWeight: "600", marginLeft: space.sm }, sessionMain: { flex: 1, minWidth: 0 }, sessionGroup: { backgroundColor: palette.card }, - sessionLocation: { + sessionBranchRow: { + alignItems: "center", + flexDirection: "row", + gap: space.xs, + marginTop: 4, + }, + sessionBranchName: { color: palette.dim, + flexShrink: 1, fontFamily: Platform.select({ android: "monospace", ios: "Menlo" }), fontSize: 11, - marginTop: 4, + minWidth: 0, }, sessionMetadata: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, marginTop: 4 }, sessionMetaLabel: { color: palette.dim, fontSize: 12 }, @@ -1797,6 +2050,20 @@ const styles = StyleSheet.create({ }, smallButtonLabel: { ...typography.control, color: palette.signal }, staleRoute: { alignSelf: "center", maxWidth: 640, padding: space.lg, width: "100%" }, + archiveFilters: { flexDirection: "row", gap: space.sm }, + archiveFilter: { minHeight: 44, justifyContent: "center", paddingHorizontal: space.sm }, + archiveFilterLabel: { color: palette.dim, fontSize: 14 }, + archiveFilterSelected: { color: palette.ink, fontSize: 14, fontWeight: "600" }, + archiveDisabled: { opacity: 0.4 }, + swipeArchive: { + minWidth: 88, + alignItems: "center", + justifyContent: "center", + gap: space.xs, + backgroundColor: palette.card, + paddingHorizontal: space.sm, + }, + swipeArchiveLabel: { color: palette.ink, fontSize: 13, fontWeight: "600" }, swipeActions: { flexDirection: "row" }, swipeContainer: { backgroundColor: palette.background }, swipeDelete: { 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 160b3b2..a3f523a 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.test.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.test.ts @@ -56,6 +56,10 @@ test("coalesces session metadata invalidations", () => { ); queryClient.setQueryData(messageKey, []); queryClient.setQueryData(followedKey, []); + const archiveKey = openCodeQueryKeys.archivedSession("connection-1", "session-1"); + const otherArchiveKey = openCodeQueryKeys.archivedSession("connection-2", "session-1"); + queryClient.setQueryData(archiveKey, {}); + queryClient.setQueryData(otherArchiveKey, {}); bridge.apply(sessionRenamedEvent("event-1")); bridge.apply(sessionRenamedEvent("event-2")); @@ -68,6 +72,12 @@ test("coalesces session metadata invalidations", () => { const followedQuery = queryClient.getQueryCache().find({ queryKey: followedKey }); expect(messageQuery && invalidate.mock.calls[0]?.[0]?.predicate?.(messageQuery)).toBe(false); expect(followedQuery && invalidate.mock.calls[0]?.[0]?.predicate?.(followedQuery)).toBe(true); + const archiveQuery = queryClient.getQueryCache().find({ queryKey: archiveKey }); + const otherArchiveQuery = queryClient.getQueryCache().find({ queryKey: otherArchiveKey }); + expect(archiveQuery && invalidate.mock.calls[0]?.[0]?.predicate?.(archiveQuery)).toBe(true); + expect(otherArchiveQuery && invalidate.mock.calls[0]?.[0]?.predicate?.(otherArchiveQuery)).toBe( + false, + ); queryClient.clear(); }); diff --git a/apps/mobile/src/state/connection-event-query-bridge.ts b/apps/mobile/src/state/connection-event-query-bridge.ts index f286612..8d0f070 100644 --- a/apps/mobile/src/state/connection-event-query-bridge.ts +++ b/apps/mobile/src/state/connection-event-query-bridge.ts @@ -389,6 +389,9 @@ function matchesRoot( ) { const key: QueryKey = query.queryKey; if (key[0] !== "opencode" || key[1] !== connectionId) return false; + if (root === "sessions" && key[2] === "archived-session") { + return sessionId === undefined || key[3] === sessionId; + } if (root === "sessions" && key[2] === "project") return key[4] === "sessions"; if (root === "sessions" && key[2] === "followed-project-sessions") return true; if ( diff --git a/apps/mobile/src/state/followed-projects-context.tsx b/apps/mobile/src/state/followed-projects-context.tsx index a317d81..e995916 100644 --- a/apps/mobile/src/state/followed-projects-context.tsx +++ b/apps/mobile/src/state/followed-projects-context.tsx @@ -82,6 +82,7 @@ type FollowedProjectsContextValue = { formLocations: ReadonlyMap; forms: FormInfo[]; hasNextPage: boolean; + sessionsFetchingNextPage: boolean; inbox: FollowedInboxSections; interactionsError: boolean; interactionsLoading: boolean; @@ -882,6 +883,7 @@ export function FollowedProjectsProvider({ children }: { children: ReactNode }) formLocations, forms, hasNextPage: Boolean(visibleSessionsQuery.hasNextPage), + sessionsFetchingNextPage: visibleSessionsQuery.isFetchingNextPage, inbox, interactionsError: failedLocationCount > 0, interactionsLoading: reconciling, diff --git a/apps/mobile/src/state/open-code-query-keys.ts b/apps/mobile/src/state/open-code-query-keys.ts index b692ae3..42d121b 100644 --- a/apps/mobile/src/state/open-code-query-keys.ts +++ b/apps/mobile/src/state/open-code-query-keys.ts @@ -13,6 +13,10 @@ export type MessageListKeyParameters = { }; export const openCodeQueryKeys = { + archivedSession(connectionId: string, sessionId: string) { + // Identity-only lookup, like ancestry snapshots. No saved location is required. + return [...connectionKey(connectionId), "archived-session", sessionId] as const; + }, activeSessions(connectionId: string) { return [...connectionKey(connectionId), "sessions-active"] as const; }, diff --git a/apps/mobile/src/state/use-session-archives.test.tsx b/apps/mobile/src/state/use-session-archives.test.tsx new file mode 100644 index 0000000..4a072ec --- /dev/null +++ b/apps/mobile/src/state/use-session-archives.test.tsx @@ -0,0 +1,68 @@ +import { expect, jest, test } from "@jest/globals"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react-native"; +import type { ReactNode } from "react"; +import { + readSessionArchivePreferences, + setSessionArchived, +} from "../storage/session-archive-repository"; +import { useSessionArchives } from "./use-session-archives"; + +jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => ({}) })); +jest.mock("../storage/session-archive-repository", () => ({ + readSessionArchivePreferences: jest.fn(async () => ({ ids: [], restoredAt: {} })), + setSessionArchived: jest.fn(async () => undefined), +})); + +test("a pending archive cannot change the next connection's inbox and duplicate taps are ignored", async () => { + let resolveWrite: (() => void) | undefined; + jest.mocked(setSessionArchived).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveWrite = resolve; + }), + ); + jest.mocked(readSessionArchivePreferences).mockImplementation(async (_db, connectionId) => ({ + ids: connectionId === "a" && resolveWrite ? ["ses_a"] : [], + restoredAt: {}, + })); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false }, + mutations: { gcTime: Infinity }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const hook = renderHook(({ id }: { id: string }) => useSessionArchives(id, 7), { + initialProps: { id: "a" }, + wrapper, + }); + await waitFor(() => expect(hook.result.current.busy).toBe(false)); + let write: Promise | undefined; + act(() => { + write = hook.result.current.setArchived("ses_a", true); + }); + await act(async () => { + await hook.result.current.setArchived("ses_a", true); + }); + expect(setSessionArchived).toHaveBeenCalledTimes(1); + hook.rerender({ id: "b" }); + await waitFor(() => expect(hook.result.current.ids).toEqual([])); + await act(async () => { + resolveWrite?.(); + await write; + }); + expect(hook.result.current.ids).toEqual([]); + expect(queryClient.getQueryData(["device-session-archives", "a", 7])).toEqual({ + ids: ["ses_a"], + restoredAt: {}, + }); + expect(queryClient.getQueryData(["device-session-archives", "b", 7])).toEqual({ + ids: [], + restoredAt: {}, + }); + hook.unmount(); + queryClient.clear(); +}); diff --git a/apps/mobile/src/state/use-session-archives.ts b/apps/mobile/src/state/use-session-archives.ts new file mode 100644 index 0000000..81e9fef --- /dev/null +++ b/apps/mobile/src/state/use-session-archives.ts @@ -0,0 +1,68 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSQLiteContext } from "expo-sqlite"; +import { useRef } from "react"; +import { + readSessionArchivePreferences, + setSessionArchived, +} from "../storage/session-archive-repository"; + +export function useSessionArchives(connectionId?: string, connectionUpdatedAtMs?: number) { + const db = useSQLiteContext(); + const queryClient = useQueryClient(); + const writing = useRef(false); + const queryKey = ["device-session-archives", connectionId, connectionUpdatedAtMs] as const; + const query = useQuery({ + enabled: Boolean(connectionId), + queryKey, + queryFn: () => { + if (!connectionId) throw new Error("CONNECTION_NOT_READY"); + return readSessionArchivePreferences(db, connectionId); + }, + }); + const mutation = useMutation({ + mutationFn: async (input: { + sessionId: string; + archived: boolean; + connectionId: string; + connectionUpdatedAtMs: number | undefined; + queryKey: typeof queryKey; + }) => { + await setSessionArchived( + db, + input.connectionId, + input.sessionId, + input.archived, + input.connectionUpdatedAtMs, + ); + return readSessionArchivePreferences(db, input.connectionId); + }, + onSuccess: (ids, input) => queryClient.setQueryData(input.queryKey, ids), + }); + return { + ids: query.data?.ids ?? [], + restoredAt: query.data?.restoredAt ?? {}, + loaded: query.isSuccess, + busy: query.isPending || mutation.isPending, + error: query.isError, + refetch: query.refetch, + async setArchived(sessionId: string, archived: boolean) { + return updateArchives(sessionId, archived); + }, + }; + + async function updateArchives(sessionId: string, archived: boolean) { + if (writing.current || query.isPending || query.isError || !connectionId) return; + writing.current = true; + try { + await mutation.mutateAsync({ + sessionId, + archived, + connectionId, + connectionUpdatedAtMs, + queryKey, + }); + } finally { + writing.current = false; + } + } +} diff --git a/apps/mobile/src/storage/database.test.ts b/apps/mobile/src/storage/database.test.ts index 01766f6..532e2cb 100644 --- a/apps/mobile/src/storage/database.test.ts +++ b/apps/mobile/src/storage/database.test.ts @@ -3,6 +3,70 @@ import type { SQLiteDatabase } from "expo-sqlite"; import { migrateMobileDatabase } from "./database"; +test("version 14 preserves manual archives and adds restoration timestamps", async () => { + const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); + await migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 14 }), + } as unknown as SQLiteDatabase); + expect(execAsync).toHaveBeenCalledTimes(2); + const sql = execAsync.mock.calls[1]?.[0]; + expect(sql).toContain("ALTER TABLE session_archives"); + expect(sql).toContain("restored_at_ms INTEGER"); + expect(sql).toContain("PRAGMA user_version = 15"); + expect(sql).toContain("BEGIN IMMEDIATE"); + expect(sql).toContain("COMMIT"); + expect(sql).not.toContain("DELETE"); +}); + +test("restoration migration rolls back on failure", async () => { + const execAsync = jest + .fn<(source: string) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("disk")) + .mockResolvedValueOnce(undefined); + await expect( + migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 14 }), + } as unknown as SQLiteDatabase), + ).rejects.toThrow("disk"); + expect(execAsync).toHaveBeenLastCalledWith("ROLLBACK;"); +}); + +test("version 13 adds identity-only device archives scoped to connections", async () => { + const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); + await migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 13 }), + } as unknown as SQLiteDatabase); + expect(execAsync).toHaveBeenCalledTimes(3); + const sql = execAsync.mock.calls[1]?.[0]; + expect(sql).toContain("CREATE TABLE session_archives"); + expect(sql).toContain("PRIMARY KEY (connection_id, session_id)"); + expect(sql).toContain("ON DELETE CASCADE"); + expect(sql).toContain("PRAGMA user_version = 14"); + expect(sql).toContain("BEGIN IMMEDIATE"); + expect(sql).toContain("COMMIT"); + expect(sql).not.toContain("title"); + expect(sql).not.toContain("directory"); +}); + +test("rolls back a failed archive migration", async () => { + const execAsync = jest + .fn<(source: string) => Promise>() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("disk")) + .mockResolvedValueOnce(undefined); + await expect( + migrateMobileDatabase({ + execAsync, + getFirstAsync: async () => ({ user_version: 13 }), + } as unknown as SQLiteDatabase), + ).rejects.toThrow("disk"); + expect(execAsync).toHaveBeenLastCalledWith("ROLLBACK;"); +}); + test("version 12 upgrades default delivery to steer without rewriting existing preferences", async () => { const execAsync = jest.fn<(source: string) => Promise>(async () => undefined); const db = { @@ -10,7 +74,7 @@ test("version 12 upgrades default delivery to steer without rewriting existing p getFirstAsync: jest.fn(async () => ({ user_version: 12 })), } as unknown as SQLiteDatabase; await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(2); + expect(execAsync).toHaveBeenCalledTimes(4); const migration = execAsync.mock.calls[1]?.[0]; expect(migration).toContain("ALTER TABLE transcript_preferences"); expect(migration).toContain("DEFAULT 'steer'"); @@ -43,7 +107,9 @@ test("creates the current mobile database schema", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(14); + expect(execAsync).toHaveBeenCalledTimes(16); + expect(execAsync.mock.calls[15]?.[0]).toContain("restored_at_ms"); + expect(execAsync.mock.calls[14]?.[0]).toContain("CREATE TABLE session_archives"); expect(execAsync.mock.calls[13]?.[0]).toContain("DEFAULT 'steer'"); expect(execAsync.mock.calls[12]?.[0]).toContain("CREATE TABLE model_favorites"); expect(execAsync.mock.calls[11]?.[0]).toContain("CREATE TABLE transcript_preferences"); @@ -80,7 +146,7 @@ test("migrates an existing profile database to app-lock preferences", async () = await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(13); + expect(execAsync).toHaveBeenCalledTimes(15); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE IF NOT EXISTS app_preferences"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("connection_profiles"); expect(execAsync.mock.calls[2]?.[0]).toContain("CREATE TABLE IF NOT EXISTS session_drafts"); @@ -99,7 +165,7 @@ test("migrates app-lock databases to encrypted draft storage", async () => { await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(12); + expect(execAsync).toHaveBeenCalledTimes(14); expect(execAsync.mock.calls[1]?.[0]).toContain("ciphertext BLOB NOT NULL"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).not.toContain("app_preferences"); @@ -118,7 +184,7 @@ test("migrates encrypted draft databases to unresolved admission storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(11); + expect(execAsync).toHaveBeenCalledTimes(13); expect(execAsync.mock.calls[1]?.[0]).toContain("status IN ('submitting', 'unknown-delivery')"); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN revision"); expect(execAsync.mock.calls[2]?.[0]).toContain("followed_projects"); @@ -134,7 +200,7 @@ test("migrates admission databases to followed project preferences", async () => await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(10); + expect(execAsync).toHaveBeenCalledTimes(12); expect(execAsync.mock.calls[1]?.[0]).toContain("followed_project_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRIMARY KEY (connection_id, project_id)"); expect(execAsync.mock.calls[1]?.[0]).toContain("UNIQUE (connection_id, position)"); @@ -150,7 +216,7 @@ test("migrates followed project databases to notification pairing storage", asyn await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(9); + expect(execAsync).toHaveBeenCalledTimes(11); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_secret_deletions"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -166,7 +232,7 @@ test("migrates notification pairings to handled event replay storage", async () await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(8); + expect(execAsync).toHaveBeenCalledTimes(10); expect(execAsync.mock.calls[1]?.[0]).toContain("handled_notification_events"); expect(execAsync.mock.calls[1]?.[0]).toContain("ON DELETE CASCADE"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -182,7 +248,7 @@ test("migrates handled events to pending notification revocation storage", async await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(7); + expect(execAsync).toHaveBeenCalledTimes(9); expect(execAsync.mock.calls[1]?.[0]).toContain("pending_notification_revocations"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 8"); expect(execAsync.mock.calls[1]?.[0]).toContain("COMMIT"); @@ -197,7 +263,7 @@ test("migrates legacy encrypted drafts to explicit payload versioning", async () await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(6); + expect(execAsync).toHaveBeenCalledTimes(8); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN payload_version"); expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 1"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); @@ -214,7 +280,7 @@ test("migrates admission recovery metadata to distinguish commands", async () => await migrateMobileDatabase(db); - expect(execAsync).toHaveBeenCalledTimes(5); + expect(execAsync).toHaveBeenCalledTimes(7); expect(execAsync.mock.calls[1]?.[0]).toContain("ADD COLUMN submission_kind"); expect(execAsync.mock.calls[1]?.[0]).toContain("DEFAULT 'prompt'"); expect(execAsync.mock.calls[1]?.[0]).toContain("BEGIN IMMEDIATE"); @@ -225,7 +291,7 @@ test("migrates admission recovery metadata to distinguish commands", async () => test("rejects a database created by a newer app", async () => { const db = { execAsync: jest.fn(async () => undefined), - getFirstAsync: jest.fn(async () => ({ user_version: 14 })), + getFirstAsync: jest.fn(async () => ({ user_version: 16 })), } as unknown as SQLiteDatabase; await expect(migrateMobileDatabase(db)).rejects.toThrow("DATABASE_VERSION_TOO_NEW"); @@ -237,7 +303,7 @@ test("upgrades version 10 with compact transcript defaults", async () => { execAsync, getFirstAsync: async () => ({ user_version: 10 }), } as unknown as SQLiteDatabase); - expect(execAsync).toHaveBeenCalledTimes(4); + expect(execAsync).toHaveBeenCalledTimes(6); expect(execAsync.mock.calls[1]?.[0]).toContain("CREATE TABLE transcript_preferences"); expect(execAsync.mock.calls[1]?.[0]).toContain("reasoning INTEGER NOT NULL DEFAULT 0"); expect(execAsync.mock.calls[1]?.[0]).toContain("PRAGMA user_version = 11"); @@ -249,7 +315,7 @@ test("upgrades version 11 with identity-only connection-scoped model favorites", execAsync, getFirstAsync: async () => ({ user_version: 11 }), } as unknown as SQLiteDatabase); - expect(execAsync).toHaveBeenCalledTimes(3); + expect(execAsync).toHaveBeenCalledTimes(5); const sql = execAsync.mock.calls[1]?.[0]; expect(sql).toContain("PRIMARY KEY (connection_id, provider_id, model_id)"); expect(sql).toContain("ON DELETE CASCADE"); diff --git a/apps/mobile/src/storage/database.ts b/apps/mobile/src/storage/database.ts index b79737c..da8fcc8 100644 --- a/apps/mobile/src/storage/database.ts +++ b/apps/mobile/src/storage/database.ts @@ -1,7 +1,7 @@ import type { SQLiteDatabase } from "expo-sqlite"; export const mobileDatabaseName = "opencode-mobile.db"; -export const mobileDatabaseSchemaVersion = 13; +export const mobileDatabaseSchemaVersion = 15; const maxDraftCiphertextBytes = 256 * 1024 + 16; @@ -280,4 +280,35 @@ export async function migrateMobileDatabase(db: SQLiteDatabase) { throw caught; } } + if (version < 14) { + try { + await db.execAsync(` + BEGIN IMMEDIATE; + CREATE TABLE session_archives ( + connection_id TEXT NOT NULL REFERENCES connection_profiles(id) ON DELETE CASCADE, + session_id TEXT NOT NULL CHECK (length(session_id) > 0), + PRIMARY KEY (connection_id, session_id) + ); + PRAGMA user_version = 14; + COMMIT; + `); + } catch (caught) { + await db.execAsync("ROLLBACK;").catch(() => undefined); + throw caught; + } + } + if (version < 15) { + try { + await db.execAsync(` + BEGIN IMMEDIATE; + ALTER TABLE session_archives + ADD COLUMN restored_at_ms INTEGER CHECK (restored_at_ms IS NULL OR restored_at_ms >= 0); + PRAGMA user_version = 15; + COMMIT; + `); + } catch (caught) { + await db.execAsync("ROLLBACK;").catch(() => undefined); + throw caught; + } + } } diff --git a/apps/mobile/src/storage/prompt-admission-repository.test.ts b/apps/mobile/src/storage/prompt-admission-repository.test.ts index 82d9586..6dfb1a6 100644 --- a/apps/mobile/src/storage/prompt-admission-repository.test.ts +++ b/apps/mobile/src/storage/prompt-admission-repository.test.ts @@ -39,7 +39,7 @@ test("stores only content-free unresolved admission metadata", async () => { expect(String(runAsync.mock.calls[1]?.[0])).toContain("LIMIT 20"); }); -test("deletes drafts and admissions for a complete server session tree", async () => { +test("deletes drafts, archives and admissions for a complete server session tree", async () => { const runAsync = jest.fn(async (..._args: unknown[]) => undefined); const db = { runAsync, @@ -48,7 +48,12 @@ test("deletes drafts and admissions for a complete server session tree", async ( await deleteSessionLocalState(db, "connection-1", ["ses_parent", "ses_child"]); - expect(runAsync).toHaveBeenCalledTimes(4); + expect(runAsync).toHaveBeenCalledTimes(6); + expect(runAsync).toHaveBeenCalledWith( + expect.stringContaining("session_archives"), + "connection-1", + "ses_parent", + ); expect(runAsync).toHaveBeenCalledWith( expect.stringContaining("session_drafts"), "connection-1", diff --git a/apps/mobile/src/storage/prompt-admission-repository.ts b/apps/mobile/src/storage/prompt-admission-repository.ts index a9d3a8c..34e55fc 100644 --- a/apps/mobile/src/storage/prompt-admission-repository.ts +++ b/apps/mobile/src/storage/prompt-admission-repository.ts @@ -109,6 +109,11 @@ export async function deleteSessionLocalState( ) { await db.withExclusiveTransactionAsync(async (txn) => { for (const sessionId of sessionIds) { + await txn.runAsync( + "DELETE FROM session_archives WHERE connection_id = ? AND session_id = ?", + connectionId, + sessionId, + ); await txn.runAsync( "DELETE FROM session_drafts WHERE connection_id = ? AND session_id = ?", connectionId, diff --git a/apps/mobile/src/storage/session-archive-repository.test.ts b/apps/mobile/src/storage/session-archive-repository.test.ts new file mode 100644 index 0000000..283e645 --- /dev/null +++ b/apps/mobile/src/storage/session-archive-repository.test.ts @@ -0,0 +1,84 @@ +import { expect, jest, test } from "@jest/globals"; +import type { SQLiteDatabase } from "expo-sqlite"; +import { + readArchivedSessionIds, + readSessionArchivePreferences, + setSessionArchived, +} from "./session-archive-repository"; + +test("reads and writes identity-only archives for the exact connection", async () => { + const transaction = { + getFirstAsync: jest.fn(async () => ({ updated_at_ms: 7 })), + runAsync: jest.fn(async (..._args: unknown[]) => undefined), + }; + const getAllAsync = jest.fn(async (..._args: unknown[]) => [{ session_id: "ses_a" }]); + const db = { + getAllAsync, + withExclusiveTransactionAsync: async (fn: (tx: typeof transaction) => Promise) => + fn(transaction), + } as unknown as SQLiteDatabase; + expect(await readArchivedSessionIds(db, "connection-a")).toEqual(["ses_a"]); + expect(getAllAsync).toHaveBeenCalledWith( + expect.stringContaining("WHERE connection_id = ?"), + "connection-a", + ); + await setSessionArchived(db, "connection-b", "ses_a", true, 7); + expect(transaction.runAsync).toHaveBeenLastCalledWith( + expect.stringContaining("ON CONFLICT"), + "connection-b", + "ses_a", + null, + ); + await setSessionArchived(db, "connection-b", "ses_a", false, 7); + expect(transaction.runAsync).toHaveBeenLastCalledWith( + expect.stringContaining("restored_at_ms"), + "connection-b", + "ses_a", + expect.any(Number), + ); +}); + +test("rejects writes after a profile changes and propagates disk failure", async () => { + const runAsync = jest.fn(async () => { + throw new Error("disk"); + }); + const db = { + withExclusiveTransactionAsync: async (fn: (tx: unknown) => Promise) => + fn({ getFirstAsync: async () => ({ updated_at_ms: 8 }), runAsync }), + } as unknown as SQLiteDatabase; + await expect(setSessionArchived(db, "connection", "ses_a", true, 7)).rejects.toThrow( + "CONNECTION_PROFILE_CHANGED", + ); + expect(runAsync).not.toHaveBeenCalled(); + await expect(setSessionArchived(db, "connection", "ses_a", true, 8)).rejects.toThrow("disk"); +}); + +test("rejects malformed persisted identifiers", async () => { + const db = { getAllAsync: async () => [{ session_id: "" }] } as unknown as SQLiteDatabase; + await expect(readArchivedSessionIds(db, "connection")).rejects.toThrow( + "INVALID_ARCHIVE_IDENTIFIER", + ); + await expect(setSessionArchived(db, "", "ses_a", true)).rejects.toThrow( + "INVALID_ARCHIVE_IDENTIFIER", + ); +}); + +test("reads manual archives separately from device-owned restoration timestamps", async () => { + const db = { + getAllAsync: async () => [ + { session_id: "ses_manual", restored_at_ms: null }, + { session_id: "ses_restored", restored_at_ms: 123 }, + ], + } as unknown as SQLiteDatabase; + expect(await readSessionArchivePreferences(db, "connection")).toEqual({ + ids: ["ses_manual"], + restoredAt: { ses_restored: 123 }, + }); + expect(await readArchivedSessionIds(db, "connection")).toEqual(["ses_manual"]); + const invalid = { + getAllAsync: async () => [{ session_id: "ses_restored", restored_at_ms: -1 }], + } as unknown as SQLiteDatabase; + await expect(readSessionArchivePreferences(invalid, "connection")).rejects.toThrow( + "INVALID_ARCHIVE_PREFERENCE", + ); +}); diff --git a/apps/mobile/src/storage/session-archive-repository.ts b/apps/mobile/src/storage/session-archive-repository.ts new file mode 100644 index 0000000..6328d85 --- /dev/null +++ b/apps/mobile/src/storage/session-archive-repository.ts @@ -0,0 +1,64 @@ +import type { SQLiteDatabase } from "expo-sqlite"; + +export async function readArchivedSessionIds(db: SQLiteDatabase, connectionId: string) { + return (await readSessionArchivePreferences(db, connectionId)).ids; +} + +export async function readSessionArchivePreferences(db: SQLiteDatabase, connectionId: string) { + assertIdentifier(connectionId); + const rows = await db.getAllAsync<{ session_id: string; restored_at_ms?: number | null }>( + "SELECT session_id, restored_at_ms FROM session_archives WHERE connection_id = ? ORDER BY session_id", + connectionId, + ); + for (const row of rows) { + assertIdentifier(row.session_id); + if ( + row.restored_at_ms != null && + (!Number.isFinite(row.restored_at_ms) || row.restored_at_ms < 0) + ) + throw new Error("INVALID_ARCHIVE_PREFERENCE"); + } + return { + ids: rows.filter((row) => row.restored_at_ms == null).map((row) => row.session_id), + restoredAt: Object.fromEntries( + rows.flatMap((row) => + row.restored_at_ms == null ? [] : [[row.session_id, row.restored_at_ms]], + ), + ), + }; +} + +export async function setSessionArchived( + db: SQLiteDatabase, + connectionId: string, + sessionId: string, + archived: boolean, + expectedConnectionUpdatedAtMs?: number, +) { + assertIdentifier(connectionId); + assertIdentifier(sessionId); + await db.withExclusiveTransactionAsync(async (transaction) => { + if (expectedConnectionUpdatedAtMs !== undefined) { + const profile = await transaction.getFirstAsync<{ updated_at_ms: number }>( + "SELECT updated_at_ms FROM connection_profiles WHERE id = ?", + connectionId, + ); + if (profile?.updated_at_ms !== expectedConnectionUpdatedAtMs) { + throw new Error("CONNECTION_PROFILE_CHANGED"); + } + } + await transaction.runAsync( + `INSERT INTO session_archives(connection_id, session_id, restored_at_ms) VALUES (?, ?, ?) + ON CONFLICT(connection_id, session_id) DO UPDATE SET restored_at_ms = excluded.restored_at_ms`, + connectionId, + sessionId, + archived ? null : Date.now(), + ); + }); +} + +function assertIdentifier(value: string) { + if (typeof value !== "string" || !value.trim() || value.includes("\u0000")) { + throw new Error("INVALID_ARCHIVE_IDENTIFIER"); + } +} From ffbf878db3c3efcc5ef7b6507fcdc58c8abe4521 Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Mon, 5 Oct 2026 08:22:36 +0200 Subject: [PATCH 2/2] test(mobile): await branch query notifications and isolate workspace mocks CI timed out waiting for the working row's branch while it still showed Checking branch, then leaked the working flag into later tests. Await the VCS request and observer notification timer inside act before capturing the row, clean up its query client in finally, and reset shared workspace mock state after every test. --- .../workspace-screen.integration.test.tsx | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/screens/workspace-screen.integration.test.tsx b/apps/mobile/src/screens/workspace-screen.integration.test.tsx index 4443852..193fda2 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -1,5 +1,5 @@ import Feather from "@expo/vector-icons/Feather"; -import { expect, jest, test } from "@jest/globals"; +import { afterEach, expect, jest, test } from "@jest/globals"; import { getOpenCodeLocation, getOpenCodeSession, @@ -62,6 +62,18 @@ let mockAllSessionsOld = false; let mockSessionsFetchingNextPage = false; let mockSessionsError = false; const mockFetchNextSessionPage = jest.fn(async () => undefined); +afterEach(() => { + mockWorkspaceActive = false; + mockWorkspacePermissions = []; + mockArchivedIds = []; + mockArchiveWriteFailure = false; + mockOldSession = false; + mockRestoredAt = {}; + mockHasNextSessionPage = false; + mockAllSessionsOld = false; + mockSessionsFetchingNextPage = false; + mockSessionsError = false; +}); function mockWorkspaceRow(index: number) { const session = { cost: 0, @@ -531,22 +543,32 @@ test.each([false, true])( /> , ); - const row = within(screen.getByRole("button", { name: /^Open session Session 0\./ })); - expect(await row.findByText("docs/mobile-workflow-screenshots")).toBeOnTheScreen(); - expect(row.UNSAFE_getByType(Feather).props).toMatchObject({ - accessibilityElementsHidden: true, - importantForAccessibility: "no-hide-descendants", - name: "git-branch", - }); - expect(row.queryByText("/workspace")).not.toBeOnTheScreen(); - if (active) { - expect( - screen.getByRole("button", { name: /^Open session Session 0\. Workspace\. Working/ }), - ).toBeOnTheScreen(); + try { + await act(async () => { + await queryClient.refetchQueries({ + queryKey: openCodeQueryKeys.vcs("connection-1", location), + }); + // Query notifications run on a zero-delay timer. Flush them inside act + // before capturing a row, including on slower CI runners. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const row = within(screen.getByRole("button", { name: /^Open session Session 0\./ })); + expect(row.getByText("docs/mobile-workflow-screenshots")).toBeOnTheScreen(); + expect(row.UNSAFE_getByType(Feather).props).toMatchObject({ + accessibilityElementsHidden: true, + importantForAccessibility: "no-hide-descendants", + name: "git-branch", + }); + expect(row.queryByText("/workspace")).not.toBeOnTheScreen(); + if (active) { + expect( + screen.getByRole("button", { name: /^Open session Session 0\. Workspace\. Working/ }), + ).toBeOnTheScreen(); + } + } finally { + view.unmount(); + queryClient.clear(); } - view.unmount(); - queryClient.clear(); - mockWorkspaceActive = false; }, );