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..193fda2 100644 --- a/apps/mobile/src/screens/workspace-screen.integration.test.tsx +++ b/apps/mobile/src/screens/workspace-screen.integration.test.tsx @@ -1,4 +1,5 @@ -import { expect, jest, test } from "@jest/globals"; +import Feather from "@expo/vector-icons/Feather"; +import { afterEach, expect, jest, test } from "@jest/globals"; import { getOpenCodeLocation, getOpenCodeSession, @@ -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,83 @@ 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); +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, + 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 +164,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 +213,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 +230,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 +268,7 @@ jest.mock("../state/workspace-selection-context", () => ({ useWorkspaceSelection: () => ({ attentionCoverage: { completeness: "complete", + freshness: "current", failedLocationCount: 0, knownLocationCount: 1, reasons: [], @@ -201,37 +276,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 +305,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 +528,402 @@ 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( + + + , + ); + 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(); + } + }, +); + +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 +1069,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 +1080,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"); + } +}