Skip to content

Commit 9209c04

Browse files
authored
feat(core): filter sessions by path and add setting to disable (#24849)
1 parent 379e7f3 commit 9209c04

10 files changed

Lines changed: 360 additions & 27 deletions

File tree

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,18 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
736736
dialog.clear()
737737
},
738738
},
739+
{
740+
title: kv.get("session_directory_filter_enabled", true)
741+
? "Disable session directory filtering"
742+
: "Enable session directory filtering",
743+
value: "app.toggle.session_directory_filter",
744+
category: "System",
745+
onSelect: async (dialog) => {
746+
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
747+
await sync.session.refresh()
748+
dialog.clear()
749+
},
750+
},
739751
{
740752
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
741753
value: "app.toggle.diffwrap",

packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ export function DialogSessionList() {
3232
const [toDelete, setToDelete] = createSignal<string>()
3333
const [search, setSearch] = createDebouncedSignal("", 150)
3434

35-
const [searchResults, { refetch }] = createResource(search, async (query) => {
36-
if (!query) return undefined
37-
const result = await sdk.client.session.list({ search: query, limit: 30 })
38-
return result.data ?? []
39-
})
35+
const [searchResults, { refetch }] = createResource(
36+
() => ({ query: search(), filter: sync.session.query() }),
37+
async (input) => {
38+
if (!input.query) return undefined
39+
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
40+
return result.data ?? []
41+
},
42+
)
4043

4144
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
4245
const sessions = createMemo(() => searchResults() ?? sync.data.session)

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { useArgs } from "./args"
3030
import { batch, onMount } from "solid-js"
3131
import * as Log from "@opencode-ai/core/util/log"
3232
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
33+
import path from "path"
34+
import { useKV } from "./kv"
3335

3436
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
3537
name: "Sync",
@@ -107,10 +109,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
107109
const event = useEvent()
108110
const project = useProject()
109111
const sdk = useSDK()
112+
const kv = useKV()
110113

111114
const fullSyncedSessions = new Set<string>()
112115
let syncedWorkspace = project.workspace.current()
113116

117+
function sessionListQuery(): { scope?: "project"; path?: string } {
118+
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
119+
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
120+
return {
121+
path: path
122+
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
123+
.replaceAll("\\", "/"),
124+
}
125+
}
126+
127+
function listSessions() {
128+
return sdk.client.session
129+
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
130+
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
131+
}
132+
114133
event.subscribe((event) => {
115134
switch (event.type) {
116135
case "server.instance.disposed":
@@ -360,10 +379,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
360379
fullSyncedSessions.clear()
361380
syncedWorkspace = workspace
362381
}
363-
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
364-
const sessionListPromise = sdk.client.session
365-
.list({ start: start })
366-
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
382+
const projectPromise = project.sync()
383+
const sessionListPromise = projectPromise.then(() => listSessions())
367384

368385
// blocking - include session.list when continuing a session
369386
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
@@ -374,7 +391,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
374391
.catch(() => emptyConsoleState)
375392
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
376393
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
377-
const projectPromise = project.sync()
378394
const blockingRequests: Promise<unknown>[] = [
379395
providersPromise,
380396
providerListPromise,
@@ -479,11 +495,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
479495
if (match.found) return store.session[match.index]
480496
return undefined
481497
},
498+
query() {
499+
return sessionListQuery()
500+
},
482501
async refresh() {
483-
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
484-
const list = await sdk.client.session
485-
.list({ start })
486-
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
502+
const list = await listSessions()
487503
setStore("session", reconcile(list))
488504
},
489505
status(sessionID: string) {

packages/opencode/src/server/routes/instance/httpapi/session.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
4545
)
4646
const ListQuery = Schema.Struct({
4747
directory: Schema.optional(Schema.String),
48+
scope: Schema.optional(Schema.Literals(["project"])),
49+
path: Schema.optional(Schema.String),
4850
roots: Schema.optional(QueryBoolean),
4951
start: Schema.optional(Schema.NumberFromString),
5052
search: Schema.optional(Schema.String),
@@ -444,6 +446,8 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
444446
Array.from(
445447
Session.list({
446448
directory: ctx.query.directory,
449+
scope: ctx.query.scope,
450+
path: ctx.query.path,
447451
roots: ctx.query.roots,
448452
start: ctx.query.start,
449453
search: ctx.query.search,

packages/opencode/src/server/routes/instance/session.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ export const SessionRoutes = lazy(() =>
6262
validator(
6363
"query",
6464
z.object({
65-
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
65+
directory: z.string().optional().meta({ description: "Filter sessions by directory" }),
66+
// TODO: in 2.0 remove `scope` and `directory` and default
67+
// to list all sessions for a project
68+
scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }),
69+
path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }),
6670
roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }),
6771
start: z.coerce
6872
.number()
@@ -76,7 +80,8 @@ export const SessionRoutes = lazy(() =>
7680
const query = c.req.valid("query")
7781
const sessions: Session.Info[] = []
7882
for await (const session of Session.list({
79-
directory: query.directory,
83+
directory: query.scope === "project" ? undefined : query.directory,
84+
path: query.path,
8085
roots: queryBoolean(query.roots),
8186
start: query.start,
8287
search: query.search,

packages/opencode/src/session/session.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { desc } from "drizzle-orm"
1818
import { like } from "drizzle-orm"
1919
import { inArray } from "drizzle-orm"
2020
import { lt } from "drizzle-orm"
21+
import { or } from "drizzle-orm"
2122
import { SyncEvent } from "../sync"
2223
import type { SQL } from "drizzle-orm"
2324
import { PartTable, SessionTable } from "./session.sql"
@@ -759,6 +760,8 @@ export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(S
759760

760761
export function* list(input?: {
761762
directory?: string
763+
scope?: "project"
764+
path?: string
762765
workspaceID?: WorkspaceID
763766
roots?: boolean
764767
start?: number
@@ -771,7 +774,17 @@ export function* list(input?: {
771774
if (input?.workspaceID) {
772775
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
773776
}
774-
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
777+
if (input?.path !== undefined) {
778+
if (input.path) {
779+
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
780+
781+
conditions.push(
782+
input.directory
783+
? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)!
784+
: or(...conds)!,
785+
)
786+
}
787+
} else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
775788
if (input?.directory) {
776789
conditions.push(eq(SessionTable.directory, input.directory))
777790
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { describe, expect, test } from "bun:test"
3+
import { testRender } from "@opentui/solid"
4+
import { onMount } from "solid-js"
5+
import { Global } from "@opencode-ai/core/global"
6+
import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args"
7+
import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit"
8+
import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv"
9+
import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project"
10+
import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk"
11+
import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync"
12+
import { tmpdir } from "../../../fixture/fixture"
13+
14+
const worktree = "/tmp/opencode"
15+
const directory = `${worktree}/packages/opencode`
16+
17+
async function wait(fn: () => boolean, timeout = 2000) {
18+
const start = Date.now()
19+
while (!fn()) {
20+
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
21+
await Bun.sleep(10)
22+
}
23+
}
24+
25+
function json(data: unknown) {
26+
return new Response(JSON.stringify(data), {
27+
headers: { "content-type": "application/json" },
28+
})
29+
}
30+
31+
function eventSource(): EventSource {
32+
return {
33+
subscribe: async () => () => {},
34+
}
35+
}
36+
37+
function createFetch() {
38+
const session = [] as URL[]
39+
const fetch = (async (input: RequestInfo | URL) => {
40+
const url = new URL(input instanceof Request ? input.url : String(input))
41+
if (url.pathname === "/session") session.push(url)
42+
43+
switch (url.pathname) {
44+
case "/agent":
45+
case "/command":
46+
case "/experimental/workspace":
47+
case "/experimental/workspace/status":
48+
case "/formatter":
49+
case "/lsp":
50+
return json([])
51+
case "/config":
52+
case "/experimental/resource":
53+
case "/mcp":
54+
case "/provider/auth":
55+
case "/session/status":
56+
return json({})
57+
case "/config/providers":
58+
return json({ providers: {}, default: {} })
59+
case "/experimental/console":
60+
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
61+
case "/path":
62+
return json({ home: "", state: "", config: "", worktree, directory })
63+
case "/project/current":
64+
return json({ id: "proj_test" })
65+
case "/provider":
66+
return json({ all: [], default: {}, connected: [] })
67+
case "/session":
68+
return json([])
69+
case "/vcs":
70+
return json({ branch: "main" })
71+
}
72+
73+
throw new Error(`unexpected request: ${url.pathname}`)
74+
}) as typeof globalThis.fetch
75+
76+
return { fetch, session }
77+
}
78+
79+
async function mount() {
80+
const calls = createFetch()
81+
let sync!: ReturnType<typeof useSync>
82+
let kv!: ReturnType<typeof useKV>
83+
let done!: () => void
84+
const ready = new Promise<void>((resolve) => {
85+
done = resolve
86+
})
87+
88+
const app = await testRender(() => (
89+
<ArgsProvider>
90+
<ExitProvider>
91+
<KVProvider>
92+
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={eventSource()}>
93+
<ProjectProvider>
94+
<SyncProvider>
95+
<Probe
96+
onReady={(ctx) => {
97+
sync = ctx.sync
98+
kv = ctx.kv
99+
done()
100+
}}
101+
/>
102+
</SyncProvider>
103+
</ProjectProvider>
104+
</SDKProvider>
105+
</KVProvider>
106+
</ExitProvider>
107+
</ArgsProvider>
108+
))
109+
110+
await ready
111+
await wait(() => sync.status === "complete")
112+
return { app, kv, sync, session: calls.session }
113+
}
114+
115+
function Probe(props: { onReady: (ctx: { kv: ReturnType<typeof useKV>; sync: ReturnType<typeof useSync> }) => void }) {
116+
const kv = useKV()
117+
const sync = useSync()
118+
119+
onMount(() => {
120+
props.onReady({ kv, sync })
121+
})
122+
123+
return <box />
124+
}
125+
126+
describe("tui sync", () => {
127+
test("refresh scopes sessions by default and lists project sessions when disabled", async () => {
128+
const previous = Global.Path.state
129+
await using tmp = await tmpdir()
130+
Global.Path.state = tmp.path
131+
await Bun.write(`${tmp.path}/kv.json`, "{}")
132+
const { app, kv, sync, session } = await mount()
133+
134+
try {
135+
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
136+
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
137+
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/opencode")
138+
139+
kv.set("session_directory_filter_enabled", false)
140+
await sync.session.refresh()
141+
142+
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
143+
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
144+
} finally {
145+
app.renderer.destroy()
146+
Global.Path.state = previous
147+
}
148+
})
149+
})

0 commit comments

Comments
 (0)