From bc1dbcf0cd1ce0ccf950aab9726bcefe1826a12e Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:08:43 -0700 Subject: [PATCH] feat(sessions): show global recent sessions without picking a directory (#144) session.list() now fetches GET /experimental/session (all sessions across every directory) and falls back to the legacy directory-scoped GET /session only on 404 (older servers). A directory-less /session is directory-scoped and returns [] when the active dir has no sessions, so the Recent Sessions list was empty unless the user first picked a folder. Global shaping (roots filter, title search, sort by time.updated desc, limit) moved to a pure, unit-tested src/lib/session-list.ts (no expo/fetch import) with 10 node --test cases. Co-authored-by: dzianisv --- src/lib/sdk.ts | 36 +++++++--- src/lib/session-list.test.ts | 130 +++++++++++++++++++++++++++++++++++ src/lib/session-list.ts | 70 +++++++++++++++++++ 3 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 src/lib/session-list.test.ts create mode 100644 src/lib/session-list.ts diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 3ef6a5f..89ffb68 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -6,6 +6,7 @@ import { fetch as expoFetch } from "expo/fetch" import { buildRequestHeaders } from "./headers" import { SSEParser } from "./sse" import { apiErrorFor } from "./api-error" +import { loadSessionList } from "./session-list" import type { FileRoot } from "./file-roots" export { ApiAuthError, isAuthError } from "./api-error" @@ -342,14 +343,33 @@ export function createClient(config: ClientConfig) { }, session: { - list: (params?: { roots?: boolean; limit?: number; search?: string }) => { - const query = new URLSearchParams() - if (params?.roots) query.set("roots", "true") - if (params?.limit) query.set("limit", String(params.limit)) - if (params?.search) query.set("search", params.search) - const qs = query.toString() - return request(config, `/session${qs ? `?${qs}` : ""}`) - }, + // Prefer the GLOBAL experimental endpoint (all sessions across every + // directory) so the Recent Sessions list works without the user first + // picking a folder — a directory-less GET /session is directory-scoped + // and returns [] on servers whose active dir has no sessions. Shaping + // (roots filter, search, sort-by-updated, limit) happens client-side in + // loadSessionList; we fetch /experimental/session with no query params + // because the server applies `limit` before we can filter to roots. + // Falls back to the legacy /session path only on 404 (older servers). + list: (params?: { roots?: boolean; limit?: number; search?: string }): Promise => + loadSessionList( + { + getExperimental: async (): Promise => { + const response = await fetchWithTimeout(`${config.baseUrl}/experimental/session`, { + headers: createHeaders(config), + }) + // Older servers lack this route — signal fallback to legacy /session. + if (response.status === 404) return null + if (!response.ok) { + const body = await response.text() + throw apiErrorFor(response.status, `API Error: ${response.status} - ${body}`) + } + return response.json() + }, + getLegacy: (query) => request(config, `/session${query}`), + }, + params, + ), get: (sessionID: string) => request(config, `/session/${sessionID}`), diff --git a/src/lib/session-list.test.ts b/src/lib/session-list.test.ts new file mode 100644 index 0000000..4b3fbb4 --- /dev/null +++ b/src/lib/session-list.test.ts @@ -0,0 +1,130 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { loadSessionList, normalizeSessions, legacySessionQuery, type SessionListTransport } from "./session-list.ts" +import type { Session } from "./sdk.ts" + +// Minimal Session factory — only the fields the list logic reads. +function session(over: Partial & { id: string }): Session { + return { + id: over.id, + slug: over.slug ?? over.id, + projectID: "p", + directory: over.directory ?? "/dir", + title: over.title ?? over.id, + version: "1", + time: over.time ?? { created: 0, updated: 0 }, + ...over, + } as Session +} + +// A fake transport that records which endpoint got hit. getExperimental +// resolves to Session[] (200), null (404 → legacy fallback), or throws (other +// non-2xx), mirroring the real sdk.ts transport contract. +function transport(opts: { + experimental?: Session[] | null + experimentalThrows?: Error + legacy?: Session[] +}): SessionListTransport & { calls: string[] } { + const calls: string[] = [] + return { + calls, + getExperimental: async () => { + calls.push("experimental") + if (opts.experimentalThrows) throw opts.experimentalThrows + return opts.experimental === undefined ? [] : opts.experimental + }, + getLegacy: async (query: string) => { + calls.push(`legacy${query}`) + return opts.legacy ?? [] + }, + } +} + +test("loadSessionList: (a) calls /experimental/session first (global)", async () => { + const t = transport({ experimental: [session({ id: "a" })] }) + await loadSessionList(t, { roots: true, limit: 50 }) + assert.equal(t.calls[0], "experimental") + assert.ok(!t.calls.some((c) => c.startsWith("legacy")), "must not hit legacy /session when experimental works") +}) + +test("loadSessionList: (b) filters to roots (no parentID) when roots:true", async () => { + const t = transport({ + experimental: [ + session({ id: "root1" }), + session({ id: "child1", parentID: "root1" }), + session({ id: "root2" }), + session({ id: "child2", parentID: "root2" }), + ], + }) + const out = await loadSessionList(t, { roots: true }) + assert.deepEqual( + out.map((s) => s.id).sort(), + ["root1", "root2"], + "children (with parentID) must be excluded", + ) +}) + +test("loadSessionList: roots not set keeps children too", async () => { + const t = transport({ experimental: [session({ id: "root1" }), session({ id: "child1", parentID: "root1" })] }) + const out = await loadSessionList(t, {}) + assert.equal(out.length, 2) +}) + +test("loadSessionList: (c) falls back to /session on 404 (older server)", async () => { + const legacy = [session({ id: "legacy-a" })] + const t = transport({ experimental: null, legacy }) + const out = await loadSessionList(t, { roots: true, limit: 50 }) + assert.equal(t.calls[0], "experimental") + assert.equal(t.calls[1], "legacy?roots=true&limit=50", "must call legacy with preserved query params") + assert.deepEqual(out, legacy, "returns the legacy payload unchanged") +}) + +test("loadSessionList: (d) sorts by time.updated descending (most recent first)", async () => { + const t = transport({ + experimental: [ + session({ id: "old", time: { created: 0, updated: 100 } }), + session({ id: "newest", time: { created: 0, updated: 300 } }), + session({ id: "mid", time: { created: 0, updated: 200 } }), + ], + }) + const out = await loadSessionList(t, { roots: true }) + assert.deepEqual(out.map((s) => s.id), ["newest", "mid", "old"]) +}) + +test("loadSessionList: applies limit AFTER root-filter + sort", async () => { + const t = transport({ + experimental: [ + session({ id: "c1", parentID: "r", time: { created: 0, updated: 999 } }), + session({ id: "r1", time: { created: 0, updated: 100 } }), + session({ id: "r2", time: { created: 0, updated: 200 } }), + session({ id: "r3", time: { created: 0, updated: 300 } }), + ], + }) + const out = await loadSessionList(t, { roots: true, limit: 2 }) + // Children excluded first, then sort desc, then take 2 → r3, r2 (not the child). + assert.deepEqual(out.map((s) => s.id), ["r3", "r2"]) +}) + +test("loadSessionList: search matches title case-insensitively", async () => { + const t = transport({ + experimental: [session({ id: "1", title: "Fix Auth Bug" }), session({ id: "2", title: "Add feature" })], + }) + const out = await loadSessionList(t, { search: "AUTH" }) + assert.deepEqual(out.map((s) => s.id), ["1"]) +}) + +test("loadSessionList: non-404 experimental error propagates (no silent fallback)", async () => { + const t = transport({ experimentalThrows: new Error("API Error: 500 - boom") }) + await assert.rejects(() => loadSessionList(t, {}), /500/) + assert.ok(!t.calls.some((c) => c.startsWith("legacy")), "500 must NOT fall back to legacy") +}) + +test("normalizeSessions: tolerates non-array input", () => { + assert.deepEqual(normalizeSessions(undefined as unknown as Session[]), []) +}) + +test("legacySessionQuery: builds the same query the old code sent", () => { + assert.equal(legacySessionQuery({ roots: true, limit: 50 }), "?roots=true&limit=50") + assert.equal(legacySessionQuery({}), "") + assert.equal(legacySessionQuery({ search: "x" }), "?search=x") +}) diff --git a/src/lib/session-list.ts b/src/lib/session-list.ts new file mode 100644 index 0000000..051e2e1 --- /dev/null +++ b/src/lib/session-list.ts @@ -0,0 +1,70 @@ +// Pure, transport-agnostic logic for listing sessions, extracted from sdk.ts so +// it's unit-testable under plain `node --test` without importing expo/fetch +// (sdk.ts is RN-only) — same pattern as api-error.ts / file-roots.ts. +// +// Why this exists: a directory-less `GET /session` is directory-SCOPED and +// returns [] on a server whose active directory has no sessions, so the Recent +// Sessions list showed nothing unless the user first picked a folder. The +// global `GET /experimental/session` returns EVERY session across ALL +// directories. We prefer it, and fall back to the legacy directory path only on +// 404 (older servers without the experimental route). +import type { Session } from "./sdk" + +export interface SessionListParams { + roots?: boolean + limit?: number + search?: string +} + +export interface SessionListTransport { + // GET /experimental/session with NO query params — the server applies `limit` + // BEFORE we can filter to roots, so limiting server-side would truncate the + // pool and yield too few root sessions. We fetch the full global list and + // shape it client-side via normalizeSessions. Resolves to null when the route + // is absent (HTTP 404 on older servers) so we fall back to the legacy path. + // Any other non-2xx is thrown by the transport (parity with request()). + getExperimental: () => Promise + // Legacy directory-scoped GET /session, used only when the experimental + // route is absent. Its behavior is unchanged from before this feature. + getLegacy: (query: string) => Promise +} + +// Shape the global session pool to match the list UI's intent: when roots:true, +// keep only top-level sessions (no parentID); case-insensitive title search; +// most-recently-updated first; then apply limit. Order matters — limit is +// applied LAST so it caps the visible roots, not the raw (root+child) pool. +export function normalizeSessions(all: Session[], params?: SessionListParams): Session[] { + let out = Array.isArray(all) ? all.slice() : [] + if (params?.roots) out = out.filter((s) => !s.parentID) + if (params?.search) { + const q = params.search.toLowerCase() + out = out.filter((s) => (s.title ?? "").toLowerCase().includes(q)) + } + out.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) + if (params?.limit != null) out = out.slice(0, params.limit) + return out +} + +// Build the query string for the legacy directory-scoped /session fallback, +// preserving the exact params the old code sent so old servers behave as before. +export function legacySessionQuery(params?: SessionListParams): string { + const query = new URLSearchParams() + if (params?.roots) query.set("roots", "true") + if (params?.limit) query.set("limit", String(params.limit)) + if (params?.search) query.set("search", params.search) + const qs = query.toString() + return qs ? `?${qs}` : "" +} + +// List sessions globally: prefer /experimental/session (all directories), shape +// client-side, and fall back to the legacy /session path only when the +// experimental route is absent (transport resolves null on 404). Any other +// non-2xx is surfaced by the transport, exactly as before this feature. +export async function loadSessionList( + transport: SessionListTransport, + params?: SessionListParams, +): Promise { + const all = await transport.getExperimental() + if (all === null) return transport.getLegacy(legacySessionQuery(params)) + return normalizeSessions(all, params) +}