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:
@@ -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}`),
|
||||
|
||||
|
||||
130
src/lib/session-list.test.ts
Normal file
130
src/lib/session-list.test.ts
Normal 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
70
src/lib/session-list.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user