mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
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:
parent
fdc0c92641
commit
e29c3a085c
8 changed files with 84 additions and 86 deletions
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
packages/ui/src/stores/session-list-options.ts
Normal file
20
packages/ui/src/stores/session-list-options.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import {
|
|||
setSessionStatus,
|
||||
toggleSessionParentExpanded,
|
||||
clearSessionSearch,
|
||||
getSessionFetchLimit,
|
||||
getSessionHasMore,
|
||||
isSessionSearchLoading,
|
||||
resetSessionPagination,
|
||||
|
|
@ -161,7 +160,6 @@ export {
|
|||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
clearSessionSearch,
|
||||
getSessionFetchLimit,
|
||||
getSessionHasMore,
|
||||
isSessionSearchLoading,
|
||||
resetSessionPagination,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue