feat(core): filter sessions by path and add setting to disable (#24849)
Some checks failed
deploy / deploy (push) Waiting to run
generate / generate (push) Waiting to run
nix-eval / nix-eval (push) Waiting to run
publish / build-cli (push) Blocked by required conditions
publish / version (push) Waiting to run
publish / sign-cli-windows (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Blocked by required conditions
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Blocked by required conditions
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Blocked by required conditions
publish / publish (push) Blocked by required conditions
storybook / storybook build (push) Waiting to run
test / unit (linux) (push) Waiting to run
test / unit (windows) (push) Waiting to run
test / e2e (linux) (push) Waiting to run
test / e2e (windows) (push) Waiting to run
typecheck / typecheck (push) Waiting to run
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled

This commit is contained in:
James Long 2026-04-28 16:49:13 -04:00 committed by GitHub
parent 379e7f3f20
commit 9209c04370
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 360 additions and 27 deletions

View file

@ -736,6 +736,18 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
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",

View file

@ -32,11 +32,14 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>()
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)

View file

@ -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<string>()
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<unknown>[] = [
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) {

View file

@ -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,

View file

@ -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,

View file

@ -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))
}

View file

@ -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<typeof useSync>
let kv!: ReturnType<typeof useKV>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<ArgsProvider>
<ExitProvider>
<KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
<ProjectProvider>
<SyncProvider>
<Probe
onReady={(ctx) => {
sync = ctx.sync
kv = ctx.kv
done()
}}
/>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
))
await ready
await wait(() => sync.status === "complete")
return { app, kv, sync, session: calls.session }
}
function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
const kv = useKV()
const sync = useSync()
onMount(() => {
props.onReady({ kv, sync })
})
return <box />
}
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
}
})
})

View file

@ -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<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
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)
},
})
})

View file

@ -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" },

View file

@ -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)
*/