diff --git a/package-lock.json b/package-lock.json index 1ca62f38..5d08c142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/ui/package.json b/packages/ui/package.json index ec1973dd..8c365390 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index bb2689cf..866a896b 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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) } diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index ac89ed65..98ea2065 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -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): boolean { - let current: SessionV2Info | Session = session +function hasMissingParentChain(session: SDKSession, loaded: Map): boolean { + let current: SDKSession | Session = session const seen = new Set() while (getKnownParentId(current)) { @@ -137,20 +138,17 @@ function hasMissingParentChain(session: SessionV2Info, loaded: Map { +async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise { const client = getRootClient(instanceId) - return requestData(client.v2.session.list(options), "v2.session.list") + const listOptions = buildProjectSessionListOptions(options) + const data = await requestData(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 { 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 { +async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise { const currentSessions = sessions().get(instanceId) ?? new Map() - const loaded = new Map(currentSessions) + const loaded = new Map(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 - - 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()) + const page = await fetchV2Sessions(instanceId, { directory }) + const items = getV2SessionItems(page) + if (items.length === 0) return - for (const apiSession of items) { - const existingSession = instanceSessions.get(apiSession.id) - instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) - loaded.set(apiSession.id, apiSession) - } + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId) ?? new Map()) - next.set(instanceId, instanceSessions) - return next - }) + for (const apiSession of items) { + const existingSession = instanceSessions.get(apiSession.id) + instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) + loaded.set(apiSession.id, apiSession) + } - 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 { +async function fetchSessions(instanceId: string, options?: { reset?: boolean }): Promise { 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: { directory?: string; limit?: number; cursor?: string } = { - limit, - ...(instance.folder ? { directory: instance.folder } : {}), - ...(options?.cursor ? { cursor: options.cursor } : {}), - } + const sessionListOptions = instance.folder ? { directory: instance.folder } : {} - 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 = {} 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 { - 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 { @@ -359,7 +338,6 @@ async function searchSessions(instanceId: string, query: string): Promise 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 } } -function toClientSessionV2(instanceId: string, apiSession: SessionV2Info, existingSession?: Session): Session { +function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingSession?: Session): Session { return { id: apiSession.id, instanceId, diff --git a/packages/ui/src/stores/session-list-options.ts b/packages/ui/src/stores/session-list-options.ts new file mode 100644 index 00000000..91bc27c3 --- /dev/null +++ b/packages/ui/src/stores/session-list-options.ts @@ -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", + } +} diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts index 13b71a7f..60580742 100644 --- a/packages/ui/src/stores/session-pagination.test.ts +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -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") + 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) + }) - const secondPage = applySessionPage(firstPage, ["root-2", "root-3"], false, false, undefined) + 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(secondPage.ids, ["root-1", "root-2", "root-3"]) - assert.equal(secondPage.hasMore, false) - assert.equal(secondPage.nextCursor, undefined) + assert.deepEqual(state.ids, ["root-1", "root-2"]) + assert.equal(state.hasMore, false) + assert.equal(state.nextCursor, undefined) }) - it("resets ids and cursor when a fresh first page is loaded", () => { + 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) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 5bca9e95..eb9cf36a 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -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, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 365fa8c3..a4239318 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -43,7 +43,6 @@ import { setSessionStatus, toggleSessionParentExpanded, clearSessionSearch, - getSessionFetchLimit, getSessionHasMore, isSessionSearchLoading, resetSessionPagination, @@ -161,7 +160,6 @@ export { updateSessionAgent, updateSessionModel, clearSessionSearch, - getSessionFetchLimit, getSessionHasMore, isSessionSearchLoading, resetSessionPagination,