diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 833c8dc8c3..703da1b59d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -736,6 +736,18 @@ function App(props: { onSnapshot?: () => Promise }) { dialog.clear() }, }, + { + title: kv.get("session_directory_filter_enabled", true) + ? "Disable session directory filtering" + : "Enable session directory filtering", + value: "app.toggle.session_directory_filter", + category: "System", + onSelect: async (dialog) => { + kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) + await sync.session.refresh() + dialog.clear() + }, + }, { title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", value: "app.toggle.diffwrap", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 576098178b..72d60767bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -32,11 +32,14 @@ export function DialogSessionList() { const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [searchResults, { refetch }] = createResource(search, async (query) => { - if (!query) return undefined - const result = await sdk.client.session.list({ search: query, limit: 30 }) - return result.data ?? [] - }) + const [searchResults, { refetch }] = createResource( + () => ({ query: search(), filter: sync.session.query() }), + async (input) => { + if (!input.query) return undefined + const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) + return result.data ?? [] + }, + ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 7b18d7f4ee..24609dd81e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -30,6 +30,8 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" +import path from "path" +import { useKV } from "./kv" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -107,10 +109,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const event = useEvent() const project = useProject() const sdk = useSDK() + const kv = useKV() const fullSyncedSessions = new Set() let syncedWorkspace = project.workspace.current() + function sessionListQuery(): { scope?: "project"; path?: string } { + if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" } + if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" } + return { + path: path + .relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory) + .replaceAll("\\", "/"), + } + } + + function listSessions() { + return sdk.client.session + .list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() }) + .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + } + event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": @@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ fullSyncedSessions.clear() syncedWorkspace = workspace } - const start = Date.now() - 30 * 24 * 60 * 60 * 1000 - const sessionListPromise = sdk.client.session - .list({ start: start }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + const projectPromise = project.sync() + const sessionListPromise = projectPromise.then(() => listSessions()) // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) @@ -374,7 +391,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const projectPromise = project.sync() const blockingRequests: Promise[] = [ providersPromise, providerListPromise, @@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) return store.session[match.index] return undefined }, + query() { + return sessionListQuery() + }, async refresh() { - const start = Date.now() - 30 * 24 * 60 * 60 * 1000 - const list = await sdk.client.session - .list({ start }) - .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) + const list = await listSessions() setStore("session", reconcile(list)) }, status(sessionID: string) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index 9001ae49d5..6ea19f19e4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -45,6 +45,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( ) const ListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), + scope: Schema.optional(Schema.Literals(["project"])), + path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), search: Schema.optional(Schema.String), @@ -444,6 +446,8 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand Array.from( Session.list({ directory: ctx.query.directory, + scope: ctx.query.scope, + path: ctx.query.path, roots: ctx.query.roots, start: ctx.query.start, search: ctx.query.search, diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 8a7752e341..410d8bba0c 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -62,7 +62,11 @@ export const SessionRoutes = lazy(() => validator( "query", z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + directory: z.string().optional().meta({ description: "Filter sessions by directory" }), + // TODO: in 2.0 remove `scope` and `directory` and default + // to list all sessions for a project + scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), + path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() @@ -76,7 +80,8 @@ export const SessionRoutes = lazy(() => const query = c.req.valid("query") const sessions: Session.Info[] = [] for await (const session of Session.list({ - directory: query.directory, + directory: query.scope === "project" ? undefined : query.directory, + path: query.path, roots: queryBoolean(query.roots), start: query.start, search: query.search, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f5b6279bd0..45b8f0078f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -18,6 +18,7 @@ import { desc } from "drizzle-orm" import { like } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" +import { or } from "drizzle-orm" import { SyncEvent } from "../sync" import type { SQL } from "drizzle-orm" import { PartTable, SessionTable } from "./session.sql" @@ -759,6 +760,8 @@ export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(S export function* list(input?: { directory?: string + scope?: "project" + path?: string workspaceID?: WorkspaceID roots?: boolean start?: number @@ -771,7 +774,17 @@ export function* list(input?: { if (input?.workspaceID) { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { + if (input?.path !== undefined) { + if (input.path) { + const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)] + + conditions.push( + input.directory + ? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)! + : or(...conds)!, + ) + } + } else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (input?.directory) { conditions.push(eq(SessionTable.directory, input.directory)) } diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx new file mode 100644 index 0000000000..993484d3ca --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -0,0 +1,149 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { Global } from "@opencode-ai/core/global" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" +import { tmpdir } from "../../../fixture/fixture" + +const worktree = "/tmp/opencode" +const directory = `${worktree}/packages/opencode` + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }) +} + +function eventSource(): EventSource { + return { + subscribe: async () => () => {}, + } +} + +function createFetch() { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +async function mount() { + const calls = createFetch() + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + + + + + { + sync = ctx.sync + kv = ctx.kv + done() + }} + /> + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} + +function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { + const kv = useKV() + const sync = useSync() + + onMount(() => { + props.onReady({ kv, sync }) + }) + + return +} + +describe("tui sync", () => { + test("refresh scopes sessions by default and lists project sessions when disabled", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + const { app, kv, sync, session } = await mount() + + try { + expect(kv.get("session_directory_filter_enabled", true)).toBe(true) + expect(session.at(-1)?.searchParams.get("scope")).toBeNull() + expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode") + + kv.set("session_directory_filter_enabled", false) + await sync.session.refresh() + + expect(session.at(-1)?.searchParams.get("scope")).toBe("project") + expect(session.at(-1)?.searchParams.get("path")).toBeNull() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 2e2945d075..cbdda6b426 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -4,8 +4,15 @@ import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { tmpdir } from "../fixture/fixture" +import { Flag } from "@opencode-ai/core/flag/flag" +import { mkdir } from "fs/promises" +import path from "path" +import { Database } from "@/storage/db" +import { SessionTable } from "@/session/session.sql" +import { eq } from "drizzle-orm" void Log.init({ print: false }) +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES function run(fx: Effect.Effect) { return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) @@ -19,28 +26,140 @@ const svc = { } afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await Instance.disposeAll() }) describe("session.list", () => { - test("filters by directory", async () => { + test("does not filter by directory when directory is omitted", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + await Instance.provide({ directory: tmp.path, fn: async () => { - const first = await svc.create({}) + const root = await svc.create({ title: "root" }) - await using other = await tmpdir({ git: true }) - const second = await Instance.provide({ - directory: other.path, - fn: async () => svc.create({}), + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), }) - const sessions = [...svc.list({ directory: tmp.path })] - const ids = sessions.map((s) => s.id) + const ids = [...svc.list()].map((s) => s.id) + expect(ids).toContain(root.id) + expect(ids).toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).toContain(sibling.id) + }, + }) + }) - expect(ids).toContain(first.id) - expect(ids).not.toContain(second.id) + test("filters by directory when directory is provided", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const root = await svc.create({ title: "root" }) + + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), + }) + + const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id) + expect(ids).not.toContain(root.id) + expect(ids).not.toContain(parent.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(sibling.id) + }, + }) + }) + + test("filters by path and ignores directory when path is provided", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parent = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode"), + fn: async () => svc.create({ title: "parent" }), + }) + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + fn: async () => svc.create({ title: "current" }), + }) + const deeper = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), + fn: async () => svc.create({ title: "deeper" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "sibling" }), + }) + + const pathIDs = [ + ...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }), + ].map((s) => s.id) + expect(pathIDs).not.toContain(parent.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).toContain(deeper.id) + expect(pathIDs).not.toContain(sibling.id) + }, + }) + }) + + test("falls back to directory when filtering legacy sessions without path", async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false + await using tmp = await tmpdir({ git: true }) + await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) + await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const current = await Instance.provide({ + directory: path.join(tmp.path, "packages", "opencode", "src"), + fn: async () => svc.create({ title: "legacy-current" }), + }) + const sibling = await Instance.provide({ + directory: path.join(tmp.path, "packages", "app"), + fn: async () => svc.create({ title: "legacy-sibling" }), + }) + + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run()) + Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run()) + + const pathIDs = [ + ...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }), + ].map((s) => s.id) + expect(pathIDs).toContain(current.id) + expect(pathIDs).not.toContain(sibling.id) }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1dafe88d1c..2da7c865d7 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1647,6 +1647,8 @@ export class Session2 extends HeyApiClient { parameters?: { directory?: string workspace?: string + scope?: "project" + path?: string roots?: boolean | "true" | "false" start?: number search?: string @@ -1661,6 +1663,8 @@ export class Session2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "scope" }, + { in: "query", key: "path" }, { in: "query", key: "roots" }, { in: "query", key: "start" }, { in: "query", key: "search" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ed9954aecd..03742e0399 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3289,6 +3289,14 @@ export type SessionListData = { */ directory?: string workspace?: string + /** + * List all sessions for the current project + */ + scope?: "project" + /** + * Filter sessions by project-relative path + */ + path?: string /** * Only return root sessions (no parentID) */