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 <engineer@gray-knight-m1.local>
This commit is contained in:
Den
2026-07-23 00:08:43 -07:00
committed by GitHub
parent c9b504711e
commit bc1dbcf0cd
3 changed files with 228 additions and 8 deletions

View File

@@ -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<Session[]>(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<Session[]> =>
loadSessionList(
{
getExperimental: async (): Promise<Session[] | null> => {
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<Session[]>(config, `/session${query}`),
},
params,
),
get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),

View File

@@ -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<Session> & { 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")
})

70
src/lib/session-list.ts Normal file
View File

@@ -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<Session[] | null>
// Legacy directory-scoped GET /session<query>, used only when the experimental
// route is absent. Its behavior is unchanged from before this feature.
getLegacy: (query: string) => Promise<Session[]>
}
// 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<Session[]> {
const all = await transport.getExperimental()
if (all === null) return transport.getLegacy(legacySessionQuery(params))
return normalizeSessions(all, params)
}