Skip to content
Open
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
7 changes: 5 additions & 2 deletions packages/app/src/context/global-sync/session-load.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { RootLoadArgs } from "./types"

export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
const baseQuery = input.projectID
? ({ projectID: input.projectID, scope: "project", roots: true } as const)
: ({ directory: input.directory, roots: true } as const)
try {
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
const result = await input.list({ ...baseQuery, limit: input.limit })
return {
data: result.data,
limit: input.limit,
limited: true,
} as const
} catch {
const result = await input.list({ directory: input.directory, roots: true })
const result = await input.list(baseQuery)
return {
data: result.data,
limit: input.limit,
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/context/global-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,15 @@ export type DisposeCheck = {

export type RootLoadArgs = {
directory: string
projectID?: string
limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
list: (query: {
directory?: string
projectID?: string
scope?: "project"
roots: true
limit?: number
}) => Promise<{ data?: Session[] }>
}

export type RootLoadResult = {
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync.project.loadSessions(project.worktree)
const projectID = "id" in project && typeof project.id === "string" ? project.id : undefined
return serverSync.project.loadSessions(project.worktree, { projectID })
}),
)
}, 0)
Expand Down
22 changes: 20 additions & 2 deletions packages/app/src/context/server-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe("pickDirectoriesToEvict", () => {

describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
const calls: Array<{ directory?: string; projectID?: string; scope?: "project"; roots: true; limit?: number }> = []

const result = await loadRootSessionsWithFallback({
directory: "dir",
Expand All @@ -42,7 +42,7 @@ describe("loadRootSessionsWithFallback", () => {
})

test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
const calls: Array<{ directory?: string; projectID?: string; scope?: "project"; roots: true; limit?: number }> = []

const result = await loadRootSessionsWithFallback({
directory: "dir",
Expand All @@ -61,6 +61,24 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true },
])
})

test("uses project scoped query when project id is provided", async () => {
const calls: Array<{ directory?: string; projectID?: string; scope?: "project"; roots: true; limit?: number }> = []

const result = await loadRootSessionsWithFallback({
directory: "dir",
projectID: "project",
limit: 10,
list: async (query) => {
calls.push(query)
return { data: [] }
},
})

expect(result.data).toEqual([])
expect(result.limited).toBe(true)
expect(calls).toEqual([{ projectID: "project", scope: "project", roots: true, limit: 10 }])
})
})

describe("estimateRootSessionTotal", () => {
Expand Down
28 changes: 18 additions & 10 deletions packages/app/src/context/server-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function makeQueryOptionsApi(
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
sessions: (directory: PathKey, projectID?: string) => ({ queryKey: [scope, directory, projectID, "loadSessions"] as const }),
}
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
Expand All @@ -93,7 +93,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
const sessionMeta = new Map<string, { limit: number; projectID?: string }>()

const sdkFor = (directory: string) => {
const key = directoryKey(directory)
Expand Down Expand Up @@ -216,7 +216,13 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
isLoadingSessions: (directory) => {
const key = directoryKey(directory)
for (const loadKey of sessionLoads.keys()) {
if (loadKey === key || loadKey.startsWith(`${key}:`)) return true
}
return false
},
onBootstrap: (directory) => {
void bootstrapInstance(directory)
},
Expand Down Expand Up @@ -248,9 +254,10 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
},
})

async function loadSessions(directory: string, options?: { limit?: number }) {
async function loadSessions(directory: string, options?: { limit?: number; projectID?: string }) {
const key = directoryKey(directory)
const pending = sessionLoads.get(key)
const loadKey = `${key}:${options?.projectID ?? ""}`
const pending = sessionLoads.get(loadKey)
if (pending) {
await pending
return loadSessions(directory, options)
Expand All @@ -260,7 +267,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key)
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
if (meta && meta.limit >= retainedLimit) {
if (meta && meta.projectID === options?.projectID && meta.limit >= retainedLimit) {
const next = trimSessions(store.session, {
limit: retainedLimit,
permission: store.permission,
Expand All @@ -276,10 +283,11 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
...queryOptionsApi.sessions(key),
...queryOptionsApi.sessions(key, options?.projectID),
queryFn: () =>
loadRootSessionsWithFallback({
directory,
projectID: options?.projectID,
limit,
list: (query) => serverSDK.client.session.list(query),
})
Expand All @@ -306,7 +314,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
})
sessionMeta.set(key, { limit })
sessionMeta.set(key, { limit, projectID: options?.projectID })
})
.catch((err) => {
console.error("Failed to load sessions", err)
Expand All @@ -321,9 +329,9 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
})
.then(() => {})

sessionLoads.set(key, promise)
sessionLoads.set(loadKey, promise)
void promise.finally(() => {
sessionLoads.delete(key)
sessionLoads.delete(loadKey)
children.unpin(key)
})
return promise
Expand Down
25 changes: 19 additions & 6 deletions packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,31 @@ function HomeDesign() {
projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const projectDirectoryEntries = createMemo(() => {
const project = selectedProject()
if (!project) return projects().flatMap(directories)
return directories(project)
const list = project ? [project] : projects()
return list.flatMap((project) =>
directories(project).map((directory) => ({
directory,
projectID: directory === project.worktree ? project.id : undefined,
})),
)
})
const projectDirectories = createMemo(() => {
return projectDirectoryEntries().map((entry) => entry.directory)
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryKey: [
"home",
"sessions",
state.selection.server,
...projectDirectoryEntries().map((entry) => `${entry.directory}:${entry.projectID ?? ""}`),
] as const,
queryFn: async () => {
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
projectDirectoryEntries().map((entry) =>
focusedSync().project.loadSessions(entry.directory, { limit: HOME_SESSION_LIMIT, projectID: entry.projectID }),
),
)
return null
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1829,14 +1829,18 @@ export default function Layout(props: ParentProps) {
}

const next = new Set(dirs)
const project = currentProject()
for (const directory of next) {
if (loadedSessionDirs.has(directory)) continue
void serverSync.project.loadSessions(directory)
const projectID = directory === project?.worktree ? project.id : undefined
const loadKey = `${directory}:${projectID ?? ""}`
if (loadedSessionDirs.has(loadKey)) continue
void serverSync.project.loadSessions(directory, { projectID })
}

loadedSessionDirs.clear()
for (const directory of next) {
loadedSessionDirs.add(directory)
const projectID = directory === project?.worktree ? project.id : undefined
loadedSessionDirs.add(`${directory}:${projectID ?? ""}`)
}
},
{ defer: true },
Expand Down
10 changes: 6 additions & 4 deletions packages/app/src/pages/layout/sidebar-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,14 +321,16 @@ export const SortableWorkspace = (props: {
const boot = createMemo(() => open() || active())
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.directory)))
const fetching = useIsFetching(() =>
queryOptions.sessions(pathKey(props.directory), local() ? props.project.id : undefined),
)
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const loading = () => fetching() > 0 && count() === 0
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.directory)
await serverSync.project.loadSessions(props.directory, { projectID: local() ? props.project.id : undefined })
}

const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
Expand Down Expand Up @@ -456,12 +458,12 @@ export const LocalWorkspace = (props: {
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const count = createMemo(() => sessions()?.length ?? 0)
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree)))
const fetching = useIsFetching(() => queryOptions.sessions(pathKey(props.project.worktree), props.project.id))
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => fetching() > 0 && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await serverSync.project.loadSessions(props.project.worktree)
await serverSync.project.loadSessions(props.project.worktree, { projectID: props.project.id })
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"

const root = "/session"
export const ListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
projectID: Schema.optional(ProjectV2.ID),
scope: Schema.optional(Schema.Literals(["project"])),
path: Schema.optional(Schema.String),
roots: Schema.optional(QueryBoolean),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",

const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
return yield* session.list({
projectID: ctx.query.projectID,
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
scope: ctx.query.scope,
path: ctx.query.path,
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export const MessagesInput = Schema.Struct({
limit: Schema.optional(NonNegativeInt),
})
export type ListInput = {
projectID?: ProjectV2.ID
directory?: string
scope?: "project"
path?: string
Expand Down Expand Up @@ -587,9 +588,9 @@ export const layer: Layer.Layer<
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
const ctx = yield* InstanceState.context
return yield* listByProject(db, {
projectID: ctx.project.id,
experimentalWorkspaces: flags.experimentalWorkspaces,
...input,
projectID: input?.projectID ?? ctx.project.id,
})
})

Expand Down
21 changes: 20 additions & 1 deletion packages/opencode/test/server/session-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Session as SessionNs } from "@/session/session"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, TestInstance, tmpdir } from "../fixture/fixture"
import { mkdir } from "fs/promises"
import path from "path"
import { SessionTable } from "@opencode-ai/core/session/sql"
Expand Down Expand Up @@ -64,6 +64,9 @@ describe("session.list", () => {
expect(ids).toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).toContain(sibling.id)

const fallbackIDs = (yield* SessionNs.use.list({ projectID: undefined })).map((session) => session.id)
expect(fallbackIDs).toContain(current.id)
}),
{ git: true },
)
Expand Down Expand Up @@ -98,6 +101,22 @@ describe("session.list", () => {
{ git: true },
)

it.instance(
"filters by explicit project id",
() =>
Effect.gen(function* () {
const other = yield* Effect.promise(() => tmpdir({ git: true }))
yield* Effect.addFinalizer(() => Effect.promise(() => other[Symbol.asyncDispose]()))
const current = yield* withSession({ title: "current-project" })
const foreign = yield* withSession({ title: "other-project" }).pipe(provideInstance(other.path))

const ids = (yield* SessionNs.use.list({ projectID: foreign.projectID })).map((session) => session.id)
expect(ids).not.toContain(current.id)
expect(ids).toContain(foreign.id)
}),
{ git: true },
)

itWorkspaces.instance(
"filters by directory when experimental workspaces are enabled",
() =>
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3377,6 +3377,7 @@ export class Session2 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
projectID?: string
scope?: "project"
path?: string
roots?: boolean | "true" | "false"
Expand All @@ -3393,6 +3394,7 @@ export class Session2 extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "projectID" },
{ in: "query", key: "scope" },
{ in: "query", key: "path" },
{ in: "query", key: "roots" },
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7674,6 +7674,7 @@ export type SessionListData = {
query?: {
directory?: string
workspace?: string
projectID?: string
scope?: "project"
path?: string
roots?: boolean | "true" | "false"
Expand Down
Loading