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 { buildRequestHeaders } from "./headers"
|
||||||
import { SSEParser } from "./sse"
|
import { SSEParser } from "./sse"
|
||||||
import { apiErrorFor } from "./api-error"
|
import { apiErrorFor } from "./api-error"
|
||||||
|
import { loadSessionList } from "./session-list"
|
||||||
import type { FileRoot } from "./file-roots"
|
import type { FileRoot } from "./file-roots"
|
||||||
|
|
||||||
export { ApiAuthError, isAuthError } from "./api-error"
|
export { ApiAuthError, isAuthError } from "./api-error"
|
||||||
@@ -342,14 +343,33 @@ export function createClient(config: ClientConfig) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
session: {
|
session: {
|
||||||
list: (params?: { roots?: boolean; limit?: number; search?: string }) => {
|
// Prefer the GLOBAL experimental endpoint (all sessions across every
|
||||||
const query = new URLSearchParams()
|
// directory) so the Recent Sessions list works without the user first
|
||||||
if (params?.roots) query.set("roots", "true")
|
// picking a folder — a directory-less GET /session is directory-scoped
|
||||||
if (params?.limit) query.set("limit", String(params.limit))
|
// and returns [] on servers whose active dir has no sessions. Shaping
|
||||||
if (params?.search) query.set("search", params.search)
|
// (roots filter, search, sort-by-updated, limit) happens client-side in
|
||||||
const qs = query.toString()
|
// loadSessionList; we fetch /experimental/session with no query params
|
||||||
return request<Session[]>(config, `/session${qs ? `?${qs}` : ""}`)
|
// 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}`),
|
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