diff --git a/android/app/build.gradle b/android/app/build.gradle index ceb0f20..dd5c4ab 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -94,8 +94,8 @@ android { applicationId 'cc.agentlabs.opencode' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 38 - versionName "0.4.11" + versionCode 39 + versionName "0.4.12" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/app.json b/app.json index ccd9783..1df38db 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "OpenCode", "slug": "opencode-mobile", - "version": "0.4.11", + "version": "0.4.12", "orientation": "portrait", "scheme": "opencode", "userInterfaceStyle": "automatic", @@ -35,7 +35,7 @@ }, "android": { "package": "cc.agentlabs.opencode", - "versionCode": 38, + "versionCode": 39, "usesCleartextTraffic": true, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", diff --git a/distribution/changelogs/39.txt b/distribution/changelogs/39.txt new file mode 100644 index 0000000..a8baefb --- /dev/null +++ b/distribution/changelogs/39.txt @@ -0,0 +1,5 @@ +v0.4.12 — Global recent sessions + +• Recent Sessions now shows every session across all your projects automatically. +• No need to pick a folder or directory first — the list is populated globally on connect. +• Falls back to per-directory listing on older servers. diff --git a/distribution/whatsnew/whatsnew-en-US b/distribution/whatsnew/whatsnew-en-US index 681efdb..93587af 100644 --- a/distribution/whatsnew/whatsnew-en-US +++ b/distribution/whatsnew/whatsnew-en-US @@ -1,3 +1,3 @@ -v0.4.11 — Release reliability fix +v0.4.12 — Global recent sessions -This maintenance release aligns Android version metadata across every build path so Google Play, direct APK, and F-Droid packages report the same version. No runtime behavior changed. +Recent Sessions now shows every session across all your projects automatically — no need to pick a folder or directory first. The list is populated globally as soon as you connect, and falls back to per-directory listing on older servers. diff --git a/fastlane/metadata/android/en-US/changelogs/39.txt b/fastlane/metadata/android/en-US/changelogs/39.txt new file mode 100644 index 0000000..927a277 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/39.txt @@ -0,0 +1,5 @@ +v0.4.12 — global recent sessions + +- Recent Sessions now shows every session across all your projects automatically. +- No need to pick a folder or directory first — the list is populated globally on connect. +- Falls back to per-directory listing on older servers. diff --git a/package.json b/package.json index bacc176..458e53d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/mobile", - "version": "0.4.11", + "version": "0.4.12", "private": true, "main": "expo-router/entry", "scripts": { diff --git a/src/lib/session-list.integration.test.ts b/src/lib/session-list.integration.test.ts new file mode 100644 index 0000000..2858370 --- /dev/null +++ b/src/lib/session-list.integration.test.ts @@ -0,0 +1,102 @@ +// Integration test for the "global recent sessions" feature. +// +// Unlike session-list.test.ts (pure logic, injected fakes), this stands up the +// real mock opencode server over HTTP and drives loadSessionList through the +// SAME transport shape sdk.ts wires in production (fetch /experimental/session +// first, fall back to /session on 404). It proves the feature works end-to-end +// across the HTTP boundary: the Recent Sessions list is populated globally — +// every session across every directory — WITHOUT the user picking a folder. +// +// Run: node --test src/lib/session-list.integration.test.ts + +import { test, before, after } from "node:test" +import assert from "node:assert/strict" +import { createMockOpencodeServer } from "../../tests/fixtures/mock-opencode-server.ts" +import { loadSessionList, legacySessionQuery, type SessionListTransport } from "./session-list.ts" + +const PORT = 45071 +let mock: ReturnType +let base: string + +before(async () => { + // --seed-sessions pre-populates two sessions in two DIFFERENT directories: + // seed-default -> /mock/project (updated now-120s) + // seed-other -> /mock/project/other-dir (updated now-60s, more recent) + mock = createMockOpencodeServer({ port: PORT, seedSessions: true }) + await mock.listen() + base = mock.url +}) + +after(async () => { + await mock.close() +}) + +// The exact transport sdk.ts builds in production: prefer the global +// experimental endpoint, signal fallback (null) only on 404. +function realTransport(baseUrl: string): SessionListTransport { + return { + getExperimental: async () => { + const r = await fetch(`${baseUrl}/experimental/session`, { headers: { Accept: "application/json" } }) + if (r.status === 404) return null + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }, + getLegacy: async (query) => { + const r = await fetch(`${baseUrl}/session${query}`, { headers: { Accept: "application/json" } }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }, + } +} + +test("global sessions: lists sessions from EVERY directory without picking a folder", async () => { + const sessions = await loadSessionList(realTransport(base), { roots: true, limit: 50 }) + + // Both directories are represented — this is the whole feature. + const ids = sessions.map((s) => s.id) + assert.ok(ids.includes("seed-default"), "session from /mock/project must appear") + assert.ok(ids.includes("seed-other"), "session from /mock/project/other-dir must appear") + assert.equal(sessions.length, 2) + + const dirs = new Set(sessions.map((s) => s.directory)) + assert.equal(dirs.size, 2, "sessions span two distinct directories (global, not directory-scoped)") + + // Most-recently-updated first: seed-other (now-60s) before seed-default (now-120s). + assert.equal(sessions[0].id, "seed-other") + assert.equal(sessions[1].id, "seed-default") +}) + +test("hit the experimental endpoint, not the directory-scoped one", async () => { + // Proves WHY the feature was needed: a plain directory-less GET /session + // (no ?roots, no x-opencode-directory header) is directory-scoped and returns + // ONLY the default directory's session — the old behavior that left the list + // empty/partial until a folder was chosen. + const scoped = await (await fetch(`${base}/session`)).json() + assert.equal(scoped.length, 1, "directory-scoped /session returns only the default dir") + assert.equal(scoped[0].id, "seed-default") + + // The global endpoint returns everything. + const global = await (await fetch(`${base}/experimental/session`)).json() + assert.equal(global.length, 2, "/experimental/session returns all sessions globally") +}) + +test("older servers (404 on /experimental/session) fall back to /session and still list globally", async () => { + // Simulate a server that predates /experimental/session: getExperimental + // resolves null (as it would on a 404), forcing the legacy path. + const legacyTransport: SessionListTransport = { + getExperimental: async () => null, + getLegacy: async (query) => { + const r = await fetch(`${base}/session${query}`, { headers: { Accept: "application/json" } }) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }, + } + + const sessions = await loadSessionList(legacyTransport, { roots: true, limit: 50 }) + // The mock's /session?roots=true returns all sessions, so the fallback still + // produces a global list on old servers. + assert.equal(sessions.length, 2) + assert.deepEqual(new Set(sessions.map((s) => s.id)), new Set(["seed-default", "seed-other"])) + // Sanity: the legacy query we sent is the exact one the old code sent. + assert.equal(legacySessionQuery({ roots: true, limit: 50 }), "?roots=true&limit=50") +}) diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts index d039278..29ab7f2 100644 --- a/tests/fixtures/mock-opencode-server.ts +++ b/tests/fixtures/mock-opencode-server.ts @@ -544,6 +544,16 @@ export function createMockOpencodeServer(opts: MockServerOptions) { messagesBySession.set(id, []) return json(res, 200, session) } + // Global "all sessions across every directory" list, mirroring the real + // opencode server's GET /experimental/session. This is the endpoint the + // client now PREFERS (src/lib/sdk.ts session.list -> loadSessionList): on a + // real server a directory-less GET /session is directory-scoped and returns + // [] when the active dir has no sessions, so the Recent Sessions list was + // empty until the user picked a folder. /experimental/session always + // returns every session, ignoring the x-opencode-directory header. + if (method === "GET" && path === "/experimental/session") { + return json(res, 200, Array.from(sessions.values())) + } if (method === "GET" && path === "/session") { // Directory-less "all sessions across all projects" list: the app's // loadSessions() (src/stores/sessions.ts) uses clientForDirectory(undefined) @@ -551,6 +561,8 @@ export function createMockOpencodeServer(opts: MockServerOptions) { // session.list). Only that combination returns sessions from every // directory; otherwise the list is scoped to the request's directory, so // directory-scoped and directory-less clients are actually distinguishable. + // NOTE: this legacy path remains the 404-fallback for older servers that + // predate /experimental/session. if (url.searchParams.get("roots") === "true") { return json(res, 200, Array.from(sessions.values())) }