From b0f124c6f0d7b4bdfd3654781a89327190d85020 Mon Sep 17 00:00:00 2001 From: 00xObi <192422376+00xObi@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:03:57 +0300 Subject: [PATCH] fix(app): scope web sessions by project --- .../src/context/global-sync/session-load.ts | 7 +++-- packages/app/src/context/global-sync/types.ts | 9 +++++- packages/app/src/context/layout.tsx | 3 +- packages/app/src/context/server-sync.test.ts | 22 +++++++++++++-- packages/app/src/context/server-sync.tsx | 28 ++++++++++++------- packages/app/src/pages/home.tsx | 25 +++++++++++++---- packages/app/src/pages/layout.tsx | 10 +++++-- .../src/pages/layout/sidebar-workspace.tsx | 10 ++++--- .../routes/instance/httpapi/groups/session.ts | 2 ++ .../instance/httpapi/handlers/session.ts | 1 + packages/opencode/src/session/session.ts | 3 +- .../opencode/test/server/session-list.test.ts | 21 +++++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 14 files changed, 113 insertions(+), 31 deletions(-) diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts index 3693dcb460de..d67900c29844 100644 --- a/packages/app/src/context/global-sync/session-load.ts +++ b/packages/app/src/context/global-sync/session-load.ts @@ -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, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 8db24b904b29..d822b76fada7 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -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 = { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 49d04df54665..d72b24a23431 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -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) diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 93e9c4175557..724ace0dbf52 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -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", @@ -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", @@ -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", () => { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 105d40eecab0..734a472f3519 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -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 @@ -93,7 +93,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { const sdkCache = new Map() const booting = new Map>() const sessionLoads = new Map>() - const sessionMeta = new Map() + const sessionMeta = new Map() const sdkFor = (directory: string) => { const key = directoryKey(directory) @@ -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) }, @@ -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) @@ -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, @@ -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), }) @@ -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) @@ -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 diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index eed3b6bde045..4901da0674d5 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -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 diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 199db2fda66c..f93f1fca7b51 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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 }, diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index a8b6ad8f8e5c..f8132fe3812e 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -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}`)) @@ -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 ( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..8ba2a5dfafe4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -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), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2ebf0cf16f3b..1b86036a9758 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -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, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 7abbba0b838c..8ccfa39b9e19 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -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 @@ -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, }) }) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 213e3cdce3cc..e8614cb84be6 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -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" @@ -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 }, ) @@ -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", () => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index fa040c42b75e..0eaffc713122 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3377,6 +3377,7 @@ export class Session2 extends HeyApiClient { parameters?: { directory?: string workspace?: string + projectID?: string scope?: "project" path?: string roots?: boolean | "true" | "false" @@ -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" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index eb75bf7673ad..00b39d46144d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7674,6 +7674,7 @@ export type SessionListData = { query?: { directory?: string workspace?: string + projectID?: string scope?: "project" path?: string roots?: boolean | "true" | "false"