Skip to content

Commit eedbbfb

Browse files
feat(session): paginated session list with infinite scroll and assistant mode improvements (#243)
* feat(session): paginated session list with infinite scroll and search Replace per-directory useQueries session listing with a combined cursor-based useInfiniteQuery (25 per page, loads more on scroll) and move search into the API query instead of client-side filtering. Add OpenCodeClient.listSessionsPage backed by the v2 /api/session endpoint plus a toLegacySession adapter. Add invalidateSessionListCaches to centralize session-list cache invalidation for infinite queries, and update EventContext and useSSE to handle the new infinite-query cache shape. * fix(session): centralize and debounce session-list cache invalidation Migrate ResetPermissionsDialog off the stale per-directory cache key (which no longer matches the infinite-query key shape and hardcoded the opencode URL) to the shared invalidateSessionListCaches helper, and drop the now-unused repoDirectory prop and its call sites. Debounce session-list invalidation on SSE session.updated events in EventContext and useSSE so a burst of updates coalesces into a single refetch instead of one per event. Fix the directory fallback in EventContext getClient so it never returns the joined directory key. * fix(permissions): scope instance dispose to the repo directory Drop the session-list cache invalidation from ResetPermissionsDialog: disposing the OpenCode instance does not change the persisted session list, so the refetch was unnecessary, and the prior cache key no longer matched the paginated shape. Guard the reset-permissions route against an empty repo directory so it never forwards /instance/dispose without a directory, which would dispose the entire server instead of only the repo's instance.
1 parent 78d9347 commit eedbbfb

17 files changed

Lines changed: 931 additions & 116 deletions

‎backend/src/routes/repos.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,11 @@ app.get('/', async (c) => {
470470
if (!repo) {
471471
return c.json({ error: 'Repo not found' }, 404)
472472
}
473-
473+
474+
if (!repo.fullPath) {
475+
return c.json({ error: 'Repo has no directory to reset' }, 400)
476+
}
477+
474478
const response = await openCodeClient.forward({
475479
method: 'POST',
476480
path: '/instance/dispose',

‎backend/test/routes/repos.test.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,4 +248,63 @@ describe('Repo Routes', () => {
248248
expect(res.status).toBe(500)
249249
})
250250
})
251+
252+
describe('POST /:id/reset-permissions', () => {
253+
it('should return 404 when repo not found', async () => {
254+
vi.mocked(db.getRepoById).mockReturnValue(null)
255+
256+
const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient())
257+
const res = await app.request('/1/reset-permissions', { method: 'POST' })
258+
259+
expect(res.status).toBe(404)
260+
})
261+
262+
it('should return 400 without disposing when repo has no directory', async () => {
263+
const mockRepo = {
264+
id: 1,
265+
repoUrl: undefined,
266+
localPath: 'assistant',
267+
fullPath: '',
268+
sourcePath: undefined,
269+
branch: undefined,
270+
defaultBranch: 'main',
271+
cloneStatus: 'ready' as const,
272+
clonedAt: Date.now(),
273+
}
274+
vi.mocked(db.getRepoById).mockReturnValue(mockRepo)
275+
276+
const forward = vi.fn(async () => new Response(JSON.stringify(true), { status: 200 }))
277+
const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward }))
278+
const res = await app.request('/1/reset-permissions', { method: 'POST' })
279+
280+
expect(res.status).toBe(400)
281+
expect(forward).not.toHaveBeenCalled()
282+
})
283+
284+
it('should dispose only the repo directory and return success', async () => {
285+
const mockRepo = {
286+
id: 1,
287+
repoUrl: 'https://github.com/test/repo',
288+
localPath: 'repos/test-repo',
289+
fullPath: '/tmp/test-repo',
290+
sourcePath: '/tmp/test-repo/.git',
291+
branch: 'main',
292+
defaultBranch: 'main',
293+
cloneStatus: 'ready' as const,
294+
clonedAt: Date.now(),
295+
}
296+
vi.mocked(db.getRepoById).mockReturnValue(mockRepo)
297+
298+
const forward = vi.fn(async () => new Response(JSON.stringify(true), { status: 200 }))
299+
const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService, createStubOpenCodeClient({ forward }))
300+
const res = await app.request('/1/reset-permissions', { method: 'POST' })
301+
302+
expect(res.status).toBe(200)
303+
expect(forward).toHaveBeenCalledWith({
304+
method: 'POST',
305+
path: '/instance/dispose',
306+
directory: '/tmp/test-repo',
307+
})
308+
})
309+
})
251310
})

‎frontend/src/api/opencode.test.ts‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,130 @@ describe('OpenCodeClient', () => {
4242
'Workspace not found: wrk_stale',
4343
)
4444
})
45+
46+
describe('listSessionsPage', () => {
47+
it('sends first-page params to /api/session with directory and returns adapted sessions', async () => {
48+
fetchMock.mockResolvedValue(
49+
new Response(
50+
JSON.stringify({
51+
items: [
52+
{
53+
id: 'ses_1',
54+
projectID: 'proj_1',
55+
workspaceID: 'ws_1',
56+
parentID: 'parent_1',
57+
title: 'My Session',
58+
time: { created: 1000, updated: 2000 },
59+
},
60+
],
61+
cursor: { next: 'cursor_next' },
62+
}),
63+
{ status: 200 },
64+
),
65+
)
66+
67+
const result = await new OpenCodeClient('/api/opencode', '/repo').listSessionsPage({
68+
limit: 25,
69+
order: 'desc',
70+
search: 'deploy',
71+
})
72+
73+
expect(fetchMock).toHaveBeenCalledWith(
74+
'http://localhost/api/opencode/api/session?limit=25&order=desc&search=deploy&directory=%2Frepo',
75+
expect.any(Object),
76+
)
77+
expect(result.items).toHaveLength(1)
78+
expect(result.items[0]).toMatchObject({
79+
id: 'ses_1',
80+
projectID: 'proj_1',
81+
directory: '/repo',
82+
parentID: 'parent_1',
83+
title: 'My Session',
84+
version: 'v2',
85+
time: { created: 1000, updated: 2000 },
86+
})
87+
})
88+
89+
it('sends cursor-only params without directory for cursor-based requests', async () => {
90+
fetchMock.mockResolvedValue(
91+
new Response(
92+
JSON.stringify({
93+
items: [],
94+
}),
95+
{ status: 200 },
96+
),
97+
)
98+
99+
await new OpenCodeClient('/api/opencode', '/repo').listSessionsPage({ cursor: 'cursor_123' })
100+
101+
expect(fetchMock).toHaveBeenCalledWith(
102+
'http://localhost/api/opencode/api/session?cursor=cursor_123',
103+
expect.any(Object),
104+
)
105+
})
106+
107+
it('exposes response cursor as nextCursor', async () => {
108+
fetchMock.mockResolvedValue(
109+
new Response(
110+
JSON.stringify({
111+
items: [],
112+
cursor: { next: 'next_1' },
113+
}),
114+
{ status: 200 },
115+
),
116+
)
117+
118+
const result = await new OpenCodeClient('/api/opencode', '/repo').listSessionsPage({ limit: 10 })
119+
120+
expect(result.nextCursor).toBe('next_1')
121+
})
122+
123+
it('uses Untitled Session for empty title', async () => {
124+
fetchMock.mockResolvedValue(
125+
new Response(
126+
JSON.stringify({
127+
items: [
128+
{
129+
id: 'ses_2',
130+
projectID: 'proj_2',
131+
title: '',
132+
time: { created: 1000, updated: 2000 },
133+
},
134+
],
135+
}),
136+
{ status: 200 },
137+
),
138+
)
139+
140+
const result = await new OpenCodeClient('/api/opencode', '/repo').listSessionsPage()
141+
142+
expect(result.items[0].title).toBe('Untitled Session')
143+
})
144+
145+
it('works without directory set', async () => {
146+
fetchMock.mockResolvedValue(
147+
new Response(
148+
JSON.stringify({
149+
items: [
150+
{
151+
id: 'ses_3',
152+
projectID: 'proj_3',
153+
title: 'No Dir',
154+
time: { created: 1000, updated: 2000 },
155+
},
156+
],
157+
}),
158+
{ status: 200 },
159+
),
160+
)
161+
162+
const result = await new OpenCodeClient('/api/opencode').listSessionsPage({ limit: 5 })
163+
164+
expect(fetchMock).toHaveBeenCalledWith(
165+
'http://localhost/api/opencode/api/session?limit=5',
166+
expect.any(Object),
167+
)
168+
expect(result.items[0].directory).toBe('')
169+
})
170+
})
45171
})

‎frontend/src/api/opencode.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@ type SendPromptResponse = paths['/session/{sessionID}/message']['post']['respons
1919
type LspStatusResponse = paths['/lsp']['get']['responses']['200']['content']['application/json']
2020
type LspStatus = LspStatusResponse[number]
2121

22+
type LegacySession = SessionListResponse[number]
23+
type SessionV2Info = {
24+
id: string
25+
parentID?: string
26+
projectID: string
27+
workspaceID?: string
28+
title: string
29+
time: LegacySession['time']
30+
path?: unknown
31+
}
32+
type SessionPageCursor = { previous?: string; next?: string }
33+
type SessionPageResponse = { items: SessionV2Info[]; cursor?: SessionPageCursor }
34+
type SessionPageParams = { limit?: number; order?: 'asc' | 'desc'; search?: string; cursor?: string }
35+
type SessionPage = { items: LegacySession[]; nextCursor?: string }
36+
37+
function toLegacySession(session: SessionV2Info, directory?: string): LegacySession {
38+
return {
39+
id: session.id,
40+
projectID: session.projectID,
41+
workspaceID: session.workspaceID,
42+
directory: directory ?? '',
43+
parentID: session.parentID,
44+
title: session.title || 'Untitled Session',
45+
version: 'v2',
46+
time: session.time,
47+
} as LegacySession
48+
}
49+
2250
export type { SendPromptResponse, SendCommandResponse, LspStatus }
2351

2452
export class OpenCodeClient {
@@ -34,7 +62,7 @@ export class OpenCodeClient {
3462
this.directory = directory
3563
}
3664

37-
private getParams(params?: Record<string, string>) {
65+
private getParams(params?: Record<string, string | number | boolean | undefined>) {
3866
if (!this.directory) return params
3967
return { ...params, directory: this.directory }
4068
}
@@ -45,6 +73,24 @@ export class OpenCodeClient {
4573
})
4674
}
4775

76+
async listSessionsPage(params?: SessionPageParams): Promise<SessionPage> {
77+
const isCursorRequest = params?.cursor !== undefined
78+
const queryParams = isCursorRequest
79+
? { cursor: params.cursor }
80+
: this.getParams({
81+
...(params?.limit !== undefined && { limit: params.limit }),
82+
...(params?.order !== undefined && { order: params.order }),
83+
...(params?.search !== undefined && { search: params.search }),
84+
})
85+
const response = await fetchWrapper<SessionPageResponse>(`${this.baseURL}/api/session`, {
86+
params: queryParams,
87+
})
88+
return {
89+
items: response.items.map((item) => toLegacySession(item, this.directory)),
90+
nextCursor: response.cursor?.next,
91+
}
92+
}
93+
4894
async getSession(sessionID: string) {
4995
return fetchWrapper<SessionResponse>(`${this.baseURL}/session/${sessionID}`, {
5096
params: this.getParams(),

‎frontend/src/components/repo/ResetPermissionsDialog.tsx‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMutation, useQueryClient } from "@tanstack/react-query";
1+
import { useMutation } from "@tanstack/react-query";
22
import { resetRepoPermissions } from "@/api/repos";
33
import {
44
Dialog,
@@ -16,23 +16,16 @@ interface ResetPermissionsDialogProps {
1616
open: boolean;
1717
onOpenChange: (open: boolean) => void;
1818
repoId: number;
19-
repoDirectory?: string;
2019
}
2120

2221
export function ResetPermissionsDialog({
2322
open,
2423
onOpenChange,
2524
repoId,
26-
repoDirectory,
2725
}: ResetPermissionsDialogProps) {
28-
const queryClient = useQueryClient();
29-
3026
const resetPermissionsMutation = useMutation({
3127
mutationFn: () => resetRepoPermissions(repoId),
3228
onSuccess: () => {
33-
queryClient.invalidateQueries({
34-
queryKey: ["opencode", "sessions", "http://localhost:5551", repoDirectory],
35-
});
3629
showToast.success("Permissions reset successfully");
3730
onOpenChange(false);
3831
},

0 commit comments

Comments
 (0)