Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ import {
clearActiveParentSession,
createSession,
fetchSessions,
getSessionFetchLimit,
updateSessionAgent,
updateSessionModel,
} from "./stores/sessions"
Expand Down Expand Up @@ -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)
}
Expand Down
94 changes: 36 additions & 58 deletions packages/ui/src/stores/session-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -36,8 +36,6 @@ import {
cleanupBlankSessions,
syncInstanceSessionIndicator,
updateThreadTotalsForParent,
SESSION_PAGE_SIZE,
getSessionNextCursor,
setSessionPage,
prependSessionListId,
removeSessionListId,
Expand All @@ -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")

Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand All @@ -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

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<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")
Expand All @@ -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<string, any> = {}
try {
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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> {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/stores/session-list-options.ts
Original file line number Diff line number Diff line change
@@ -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",
}
}
32 changes: 20 additions & 12 deletions packages/ui/src/stores/session-pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 0 additions & 5 deletions packages/ui/src/stores/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -1070,7 +1066,6 @@ export {
sessionPagination,
sessionSearch,
getSessionListIds,
getSessionFetchLimit,
getSessionNextCursor,
setSessionPage,
getSessionHasMore,
Expand Down
2 changes: 0 additions & 2 deletions packages/ui/src/stores/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {
setSessionStatus,
toggleSessionParentExpanded,
clearSessionSearch,
getSessionFetchLimit,
getSessionHasMore,
isSessionSearchLoading,
resetSessionPagination,
Expand Down Expand Up @@ -161,7 +160,6 @@ export {
updateSessionAgent,
updateSessionModel,
clearSessionSearch,
getSessionFetchLimit,
getSessionHasMore,
isSessionSearchLoading,
resetSessionPagination,
Expand Down
Loading