diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 045fa43f..430ceb63 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -56,6 +56,7 @@ User-visible session ordering is also not owned by the global cache array order. Global refresh rules: +- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page. - Per-directory refresh is bounded to two requests across callers and prioritizes the current directory. - Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally. - Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions. diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 118a94b4..1319568d 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -96,6 +96,164 @@ describe('listGlobalSessionPages', () => { expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2']) }) + test('returns only archived sessions when archived pages are requested', async () => { + const apiClient = { + experimental: { + session: { + list: async () => ({ + // The server treats `archived: true` as "include archived", so the + // response mixes active and archived records. + data: [ + { id: 'ses_active', time: { created: 1, updated: 20 } }, + { id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 500 }) + + expect(sessions.map((session) => session.id)).toEqual(['ses_archived']) + }) + + test('keeps every record when active pages are requested', async () => { + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [ + { id: 'ses_active_1', time: { updated: 20 } }, + { id: 'ses_active_2', time: { updated: 10 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 }) + + expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2']) + }) + + test('keeps paginating archived pages that are full of non-archived records', async () => { + const calls: Array> = [] + const apiClient = { + experimental: { + session: { + list: async (options: Record) => { + calls.push(options) + if (options.cursor === undefined) { + return { + data: [ + { id: 'ses_active_1', time: { updated: 30 } }, + { id: 'ses_active_2', time: { updated: 20 } }, + ], + response: { headers: new Headers({ 'x-next-cursor': '20' }) }, + } + } + return { + data: [ + { id: 'ses_archived', time: { updated: 10, archived: 12 } }, + ], + response: { headers: new Headers() }, + } + }, + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 }) + + // A page that is full upstream but fully filtered out here must not be + // mistaken for the last page: pagination progress is measured on the raw + // response, not on the accepted records. + expect(calls).toHaveLength(2) + expect(sessions.map((session) => session.id)).toEqual(['ses_archived']) + }) + + test('reports only accepted records to onPage for archived pages', async () => { + const pages: string[][] = [] + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [ + { id: 'ses_active', time: { updated: 20 } }, + { id: 'ses_archived', time: { updated: 10, archived: 12 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + await listGlobalSessionPages(apiClient, { + archived: true, + pageSize: 500, + onPage: (sessions) => pages.push(sessions.map((session) => session.id)), + }) + + expect(pages).toEqual([['ses_archived']]) + }) + + test('does not notify onPage for an archived page with no archived records', async () => { + const pages: string[][] = [] + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [{ id: 'ses_active', time: { updated: 20 } }], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { + archived: true, + pageSize: 500, + onPage: (page) => pages.push(page.map((session) => session.id)), + }) + + expect(sessions).toEqual([]) + expect(pages).toEqual([]) + }) + + test('dedupes archived records by id and stops when a page repeats known ids', async () => { + const calls: Array> = [] + const page = [ + { id: 'ses_archived_1', time: { updated: 30, archived: 31 } }, + { id: 'ses_archived_2', time: { updated: 20, archived: 21 } }, + ] + const apiClient = { + experimental: { + session: { + list: async (options: Record) => { + calls.push(options) + return { + data: page, + response: { + headers: new Headers({ + 'x-next-cursor': options.cursor === undefined ? '20' : '10', + }), + }, + } + }, + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 }) + + // The second page repeats ids already seen, so the dedupe guard stops the + // loop and no record is returned twice. + expect(calls).toHaveLength(2) + expect(sessions.map((session) => session.id)).toEqual(['ses_archived_1', 'ses_archived_2']) + }) + test('retries SDK error responses before treating the load as failed', async () => { let calls = 0 const apiClient = {