fix(ui): scope project session list requests (#565)

## Summary
- update @opencode-ai/sdk to 1.17.8
- load session lists through client.session.list with scope=project
- use a single high-limit project request because this endpoint does not
support cursor pagination

## Validation
- npm run typecheck --workspace packages/ui

## Notes
- Existing untracked .opencode/package-lock.json was left untouched.
This commit is contained in:
Shantur Rathore 2026-06-19 17:42:12 +01:00 committed by GitHub
parent fdc0c92641
commit e29c3a085c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 84 additions and 86 deletions

8
package-lock.json generated
View file

@ -3266,9 +3266,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.16.0.tgz",
"integrity": "sha512-S4H2e9j4rdHs5BQOCjmVEdqdXmKwPFKjXPbPUaWiRJpAjBcZ/uIBpoZkmV+x9BLzc+vrE6WAffMZieQgukt4DA==",
"version": "1.17.8",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.8.tgz",
"integrity": "sha512-6MKmsj2ujZyL44jy+12dpwWYDYKPS9fUr+0wVQxaIlPYQ/eAt8T8T3QrybplJ5ZtHfZUX+esXZ02x2UYYm7oEw==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
@ -13503,7 +13503,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.16.0",
"@opencode-ai/sdk": "^1.17.8",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -13,7 +13,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.16.0",
"@opencode-ai/sdk": "^1.17.8",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View file

@ -47,7 +47,6 @@ import {
clearActiveParentSession,
createSession,
fetchSessions,
getSessionFetchLimit,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
@ -419,7 +418,7 @@ const App: Component = () => {
clearActiveParentSession(instanceId)
try {
await fetchSessions(instanceId, { reset: true, limit: getSessionFetchLimit(instanceId) })
await fetchSessions(instanceId, { reset: true })
} catch (error) {
log.error("Failed to refresh sessions after closing", error)
}

View file

@ -7,7 +7,7 @@ import {
type SessionStatus,
} from "../types/session"
import type { Message } from "../types/message"
import type { SessionV2Info, V2SessionsResponse } from "@opencode-ai/sdk/v2/client"
import type { Session as SDKSession, SessionListResponse } from "@opencode-ai/sdk/v2/client"
import { instances } from "./instances"
import { preferences, setAgentModelPreference } from "./preferences"
@ -36,8 +36,6 @@ import {
cleanupBlankSessions,
syncInstanceSessionIndicator,
updateThreadTotalsForParent,
SESSION_PAGE_SIZE,
getSessionNextCursor,
setSessionPage,
prependSessionListId,
removeSessionListId,
@ -58,6 +56,7 @@ import { getRootClient } from "./opencode-client"
import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees"
import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces"
import { hydrateSessionMetadataWithClient } from "./session-metadata"
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options"
const log = getLogger("api")
@ -111,17 +110,19 @@ interface SessionForkResponse {
type V2SessionListOptions = {
directory?: string
limit?: number
search?: string
cursor?: string
}
function getKnownParentId(session: SessionV2Info | Session): string | null | undefined {
type ProjectSessionListResponse = {
data: SDKSession[]
}
function getKnownParentId(session: SDKSession | Session): string | null | undefined {
return (session as any).parentID ?? (session as Session).parentId
}
function hasMissingParentChain(session: SessionV2Info, loaded: Map<string, SessionV2Info | Session>): boolean {
let current: SessionV2Info | Session = session
function hasMissingParentChain(session: SDKSession, loaded: Map<string, SDKSession | Session>): boolean {
let current: SDKSession | Session = session
const seen = new Set<string>()
while (getKnownParentId(current)) {
@ -137,20 +138,17 @@ function hasMissingParentChain(session: SessionV2Info, loaded: Map<string, Sessi
return false
}
async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise<V2SessionsResponse> {
async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise<ProjectSessionListResponse> {
const client = getRootClient(instanceId)
return requestData<V2SessionsResponse>(client.v2.session.list(options), "v2.session.list")
const listOptions = buildProjectSessionListOptions(options)
const data = await requestData<SessionListResponse>(client.session.list(listOptions), "session.list")
return { data }
}
function getV2SessionItems(response: V2SessionsResponse): SessionV2Info[] {
function getV2SessionItems(response: ProjectSessionListResponse): SDKSession[] {
return response.data
}
function getV2NextCursor(response: V2SessionsResponse): string | undefined {
const next = (response as any)?.cursor?.next
return typeof next === "string" && next.length > 0 ? next : undefined
}
async function hydrateMissingSessionMetadata(instanceId: string, sessionIds: string[]): Promise<void> {
const uniqueIds = Array.from(new Set(sessionIds)).filter(Boolean)
if (uniqueIds.length === 0) return
@ -167,43 +165,33 @@ async function hydrateMissingSessionMetadata(instanceId: string, sessionIds: str
}
}
async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SessionV2Info[], directory?: string): Promise<void> {
async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise<void> {
const currentSessions = sessions().get(instanceId) ?? new Map<string, Session>()
const loaded = new Map<string, SessionV2Info | Session>(currentSessions)
const loaded = new Map<string, SDKSession | Session>(currentSessions)
for (const session of apiSessions) loaded.set(session.id, session)
if (!apiSessions.some((session) => hasMissingParentChain(session, loaded))) return
const limit = SESSION_PAGE_SIZE
let cursor: string | undefined
let remainingPages = 25
const page = await fetchV2Sessions(instanceId, { directory })
const items = getV2SessionItems(page)
if (items.length === 0) return
while (apiSessions.some((session) => hasMissingParentChain(session, loaded)) && remainingPages > 0) {
const page = await fetchV2Sessions(instanceId, { directory, limit, ...(cursor ? { cursor } : {}) })
const items = getV2SessionItems(page)
if (items.length === 0) break
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
setSessions((prev) => {
const next = new Map(prev)
const instanceSessions = new Map(next.get(instanceId) ?? new Map())
for (const apiSession of items) {
const existingSession = instanceSessions.get(apiSession.id)
instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession))
loaded.set(apiSession.id, apiSession)
}
for (const apiSession of items) {
const existingSession = instanceSessions.get(apiSession.id)
instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession))
loaded.set(apiSession.id, apiSession)
}
next.set(instanceId, instanceSessions)
return next
})
cursor = getV2NextCursor(page)
if (!cursor) break
remainingPages -= 1
}
next.set(instanceId, instanceSessions)
return next
})
}
async function fetchSessions(instanceId: string, options?: { limit?: number; reset?: boolean; cursor?: string }): Promise<void> {
async function fetchSessions(instanceId: string, options?: { reset?: boolean }): Promise<void> {
const instance = instances().get(instanceId)
if (!instance || !instance.client) {
throw new Error("Instance not ready")
@ -218,17 +206,10 @@ async function fetchSessions(instanceId: string, options?: { limit?: number; res
})
try {
const limit = Math.min(options?.limit ?? SESSION_PAGE_SIZE, 200)
const sessionListOptions = instance.folder ? { directory: instance.folder } : {}
const sessionListOptions: { directory?: string; limit?: number; cursor?: string } = {
limit,
...(instance.folder ? { directory: instance.folder } : {}),
...(options?.cursor ? { cursor: options.cursor } : {}),
}
log.info("v2.session.list", { instanceId, limit, directory: sessionListOptions.directory, cursor: sessionListOptions.cursor })
log.info("session.list", { instanceId, limit: PROJECT_SESSION_LIST_LIMIT, directory: sessionListOptions.directory, scope: "project" })
const response = await fetchV2Sessions(instanceId, sessionListOptions)
const nextCursor = getV2NextCursor(response)
let statusById: Record<string, any> = {}
try {
@ -298,7 +279,7 @@ async function fetchSessions(instanceId: string, options?: { limit?: number; res
})
}
setSessionPage(instanceId, rootIds, Boolean(nextCursor), options?.reset ?? true, nextCursor)
setSessionPage(instanceId, rootIds, false, options?.reset ?? true)
syncInstanceSessionIndicator(instanceId)
@ -339,9 +320,7 @@ async function fetchSessions(instanceId: string, options?: { limit?: number; res
}
async function loadMoreSessions(instanceId: string): Promise<void> {
const cursor = getSessionNextCursor(instanceId)
if (!cursor) return
await fetchSessions(instanceId, { limit: SESSION_PAGE_SIZE, reset: false, cursor })
return
}
async function searchSessions(instanceId: string, query: string): Promise<void> {
@ -359,7 +338,6 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
log.info("v2.session.search", { instanceId, query: trimmedQuery, directory: instance.folder })
const response = await fetchV2Sessions(instanceId, {
search: trimmedQuery,
limit: SESSION_PAGE_SIZE,
directory: instance.folder,
})
if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return
@ -410,7 +388,7 @@ async function searchSessions(instanceId: string, query: string): Promise<void>
}
}
function toClientSessionV2(instanceId: string, apiSession: SessionV2Info, existingSession?: Session): Session {
function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingSession?: Session): Session {
return {
id: apiSession.id,
instanceId,

View file

@ -0,0 +1,20 @@
export const PROJECT_SESSION_LIST_LIMIT = 1000
type ProjectSessionListInput = {
directory?: string
search?: string
}
export type ProjectSessionListOptions = ProjectSessionListInput & {
limit: typeof PROJECT_SESSION_LIST_LIMIT
scope: "project"
}
export function buildProjectSessionListOptions(options: ProjectSessionListInput): ProjectSessionListOptions {
return {
...(options.directory ? { directory: options.directory } : {}),
...(options.search ? { search: options.search } : {}),
limit: PROJECT_SESSION_LIST_LIMIT,
scope: "project",
}
}

View file

@ -2,25 +2,33 @@ import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts"
import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions } from "./session-list-options.ts"
describe("session pagination cursor state", () => {
it("stores the v2 next cursor and appends loaded pages", () => {
const firstPage = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], true, true, "cursor-page-2")
describe("project session list loading", () => {
it("builds a one-shot project-scoped request without pagination params", () => {
const options = buildProjectSessionListOptions({ directory: "/tmp/project", search: "worktree" })
assert.deepEqual(firstPage.ids, ["root-1", "root-2"])
assert.equal(firstPage.hasMore, true)
assert.equal(firstPage.nextCursor, "cursor-page-2")
const secondPage = applySessionPage(firstPage, ["root-2", "root-3"], false, false, undefined)
assert.deepEqual(secondPage.ids, ["root-1", "root-2", "root-3"])
assert.equal(secondPage.hasMore, false)
assert.equal(secondPage.nextCursor, undefined)
assert.deepEqual(options, {
directory: "/tmp/project",
search: "worktree",
limit: PROJECT_SESSION_LIST_LIMIT,
scope: "project",
})
assert.equal("start" in options, false)
assert.equal("cursor" in options, false)
})
it("resets ids and cursor when a fresh first page is loaded", () => {
it("marks the loaded session list complete because the API does not paginate", () => {
const state = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], false, true)
assert.deepEqual(state.ids, ["root-1", "root-2"])
assert.equal(state.hasMore, false)
assert.equal(state.nextCursor, undefined)
})
it("resets stale cursor state when the one-shot list refreshes", () => {
const previous = applySessionPage(getDefaultSessionPaginationState(), ["old-root"], true, true, "old-cursor")
const next = applySessionPage(previous, ["new-root"], false, true, undefined)
const next = applySessionPage(previous, ["new-root"], false, true)
assert.deepEqual(next.ids, ["new-root"])
assert.equal(next.hasMore, false)

View file

@ -85,10 +85,6 @@ function getSessionListIds(instanceId: string): string[] {
return getSessionPaginationState(instanceId).ids
}
function getSessionFetchLimit(instanceId: string): number {
return Math.max(getSessionPaginationState(instanceId).ids.length, SESSION_PAGE_SIZE)
}
function getSessionNextCursor(instanceId: string): string | undefined {
return getSessionPaginationState(instanceId).nextCursor
}
@ -1070,7 +1066,6 @@ export {
sessionPagination,
sessionSearch,
getSessionListIds,
getSessionFetchLimit,
getSessionNextCursor,
setSessionPage,
getSessionHasMore,

View file

@ -43,7 +43,6 @@ import {
setSessionStatus,
toggleSessionParentExpanded,
clearSessionSearch,
getSessionFetchLimit,
getSessionHasMore,
isSessionSearchLoading,
resetSessionPagination,
@ -161,7 +160,6 @@ export {
updateSessionAgent,
updateSessionModel,
clearSessionSearch,
getSessionFetchLimit,
getSessionHasMore,
isSessionSearchLoading,
resetSessionPagination,