Skip to content

Commit d3d7c85

Browse files
feat: enhance assistant mode with session list view and legacy config migration
- Add session list view for assistant mode with full workspace management - Migrate legacy opencode.json permission format to granular permissions - Enable assistant navigation from mobile tab bar and repo switcher - Preserve assistant context in session routes via query params - Auto-restart OpenCode server after assistant mode initialization - Add comprehensive tests for legacy config detection and navigation
1 parent 86763d9 commit d3d7c85

12 files changed

Lines changed: 539 additions & 76 deletions

File tree

backend/src/routes/repos.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,10 @@ app.get('/', async (c) => {
410410
const options = AssistantModeInitRequestSchema.parse(body)
411411

412412
const status = await ensureAssistantMode(repo, options)
413+
if (status.files.opencodeJson.created) {
414+
opencodeServerManager.clearStartupError()
415+
await opencodeServerManager.restart()
416+
}
413417
return c.json(status)
414418
} catch (error: unknown) {
415419
logger.error('Failed to initialize assistant mode:', error)

backend/src/services/assistant-mode.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
OpenCodeConfigInput,
77
} from '@opencode-manager/shared/types'
88
import {
9+
readFileContent,
910
writeFileContent,
1011
fileExists,
1112
ensureDirectoryExists,
@@ -71,12 +72,13 @@ export function buildAssistantOpenCodeConfig(): OpenCodeConfigInput {
7172
'AGENTS.md',
7273
],
7374
permission: {
74-
allow: [
75-
'**/*',
76-
],
77-
ask: [
78-
'../**/*',
79-
],
75+
read: 'allow',
76+
edit: 'allow',
77+
glob: 'allow',
78+
grep: 'allow',
79+
list: 'allow',
80+
bash: 'allow',
81+
external_directory: 'ask',
8082
},
8183
}
8284

@@ -110,7 +112,9 @@ export async function ensureAssistantMode(
110112
await writeFileContent(agentsMdPath, content)
111113
}
112114

113-
if (!opencodeJsonExists || overwriteOpenCodeConfig) {
115+
const hasLegacyOpenCodeConfig = opencodeJsonExists && await isLegacyAssistantOpenCodeConfig(opencodeJsonPath)
116+
117+
if (!opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig) {
114118
const config = buildAssistantOpenCodeConfig()
115119
await writeFileContent(opencodeJsonPath, JSON.stringify(config, null, 2))
116120
}
@@ -128,12 +132,22 @@ export async function ensureAssistantMode(
128132
opencodeJson: {
129133
path: opencodeJsonPath,
130134
exists: true,
131-
created: !opencodeJsonExists || overwriteOpenCodeConfig,
135+
created: !opencodeJsonExists || overwriteOpenCodeConfig || hasLegacyOpenCodeConfig,
132136
},
133137
},
134138
}
135139
}
136140

141+
async function isLegacyAssistantOpenCodeConfig(opencodeJsonPath: string): Promise<boolean> {
142+
try {
143+
const content = await readFileContent(opencodeJsonPath)
144+
const config = JSON.parse(content) as { permission?: { allow?: unknown; ask?: unknown } }
145+
return Array.isArray(config.permission?.allow) || Array.isArray(config.permission?.ask)
146+
} catch {
147+
return false
148+
}
149+
}
150+
137151
export async function getAssistantModeStatus(repo: Repo): Promise<AssistantModeStatus> {
138152
const assistantDir = getAssistantModeDirectory()
139153

backend/test/routes/repos.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ vi.mock('../../src/services/assistant-mode', () => ({
2828
buildAssistantOpenCodeConfig: vi.fn(),
2929
}))
3030

31+
vi.mock('../../src/services/opencode-single-server', () => ({
32+
opencodeServerManager: {
33+
clearStartupError: vi.fn(),
34+
restart: vi.fn().mockResolvedValue(undefined),
35+
},
36+
}))
37+
3138
import * as db from '../../src/db/queries'
3239
import { createRepoRoutes } from '../../src/routes/repos'
3340
import type { GitAuthService } from '../../src/services/git-auth'

backend/test/services/assistant-mode.test.ts

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,33 @@ const mockRepo: Repo = {
2727
isLocal: false,
2828
}
2929

30-
const writeFile = vi.fn()
31-
const mkdir = vi.fn()
32-
const access = vi.fn()
33-
const stat = vi.fn()
30+
const fsMocks = vi.hoisted(() => ({
31+
writeFile: vi.fn(),
32+
mkdir: vi.fn(),
33+
access: vi.fn(),
34+
stat: vi.fn(),
35+
readFile: vi.fn(),
36+
}))
37+
38+
const { writeFile, mkdir, access, readFile } = fsMocks
3439

3540
vi.mock('fs/promises', () => ({
3641
default: {
37-
writeFile,
38-
mkdir,
39-
access,
40-
stat,
42+
writeFile: fsMocks.writeFile,
43+
readFile: fsMocks.readFile,
44+
mkdir: fsMocks.mkdir,
45+
access: fsMocks.access,
46+
stat: fsMocks.stat,
47+
},
48+
}))
49+
50+
vi.mock('fs', () => ({
51+
promises: {
52+
writeFile: fsMocks.writeFile,
53+
readFile: fsMocks.readFile,
54+
mkdir: fsMocks.mkdir,
55+
access: fsMocks.access,
56+
stat: fsMocks.stat,
4157
},
4258
}))
4359

@@ -67,13 +83,22 @@ describe('buildAssistantOpenCodeConfig', () => {
6783

6884
it('has permission rules for the assistant workspace', () => {
6985
const config = buildAssistantOpenCodeConfig()
70-
expect(config.permission).toBeDefined()
86+
expect(config.permission).toEqual({
87+
read: 'allow',
88+
edit: 'allow',
89+
glob: 'allow',
90+
grep: 'allow',
91+
list: 'allow',
92+
bash: 'allow',
93+
external_directory: 'ask',
94+
})
7195
})
7296
})
7397

7498
describe('ensureAssistantMode', () => {
7599
beforeEach(() => {
76100
vi.clearAllMocks()
101+
readFile.mockResolvedValue(JSON.stringify(buildAssistantOpenCodeConfig()))
77102
})
78103

79104
it('creates the shared assistant workspace and files when missing', async () => {
@@ -114,6 +139,24 @@ describe('ensureAssistantMode', () => {
114139
expect(result.files.opencodeJson.created).toBe(true)
115140
})
116141

142+
it('overwrites legacy invalid assistant opencode config', async () => {
143+
access.mockResolvedValue(undefined)
144+
readFile.mockResolvedValue(JSON.stringify({
145+
instructions: ['AGENTS.md'],
146+
permission: {
147+
allow: ['**/*'],
148+
ask: ['../**/*'],
149+
},
150+
}))
151+
152+
const result = await ensureAssistantMode(mockRepo)
153+
154+
expect(result.files.opencodeJson.created).toBe(true)
155+
const content = writeFile.mock.calls[0]?.[1]
156+
expect(Buffer.isBuffer(content)).toBe(true)
157+
expect((content as Buffer).toString('utf8')).toContain('external_directory')
158+
})
159+
117160
it('returns a directory under the repos root', async () => {
118161
access.mockRejectedValue(new Error('File not found'))
119162
mkdir.mockResolvedValue(undefined)

frontend/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ const router = createBrowserRouter([
189189
element: <Repos />,
190190
loader: protectedLoader,
191191
},
192+
{
193+
path: '/assistant',
194+
element: <AssistantRedirect />,
195+
loader: protectedLoader,
196+
},
192197
{
193198
path: '/repos/:id',
194199
element: <RepoDetail />,

frontend/src/components/navigation/MobileTabBar.test.tsx

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ vi.mock('@/hooks/useMobile', () => ({
55
}))
66

77
import { render, screen } from '@testing-library/react'
8+
import userEvent from '@testing-library/user-event'
89
import { describe, it, expect, beforeEach } from 'vitest'
9-
import { MemoryRouter } from 'react-router-dom'
10+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
1011
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1112
import { MobileTabBar } from './MobileTabBar'
1213
import { useMobile } from '@/hooks/useMobile'
1314

15+
function LocationSpy() {
16+
const { pathname, search } = useLocation()
17+
return <div data-testid="location">{`${pathname}${search}`}</div>
18+
}
19+
1420
describe('MobileTabBar', () => {
1521
beforeEach(() => {
1622
vi.clearAllMocks()
@@ -56,6 +62,65 @@ describe('MobileTabBar', () => {
5662
expect(screen.getByText('Schedules')).toBeInTheDocument()
5763
})
5864

65+
it('renders global tabs on assistant session list path', () => {
66+
vi.mocked(useMobile).mockReturnValue(true)
67+
const queryClient = new QueryClient()
68+
render(
69+
<QueryClientProvider client={queryClient}>
70+
<MemoryRouter initialEntries={['/repos/123/assistant?view=sessions']}>
71+
<MobileTabBar />
72+
</MemoryRouter>
73+
</QueryClientProvider>,
74+
)
75+
expect(screen.getByText('Repos')).toBeInTheDocument()
76+
expect(screen.getByText('Assistant')).toBeInTheDocument()
77+
expect(screen.getByText('Schedules')).toBeInTheDocument()
78+
})
79+
80+
it('navigates to assistant route when repo id is present', async () => {
81+
vi.mocked(useMobile).mockReturnValue(true)
82+
const queryClient = new QueryClient()
83+
const user = userEvent.setup()
84+
85+
render(
86+
<QueryClientProvider client={queryClient}>
87+
<MemoryRouter initialEntries={['/repos/123']}>
88+
<Routes>
89+
<Route path="*" element={<>
90+
<MobileTabBar />
91+
<LocationSpy />
92+
</>} />
93+
</Routes>
94+
</MemoryRouter>
95+
</QueryClientProvider>,
96+
)
97+
98+
await user.click(screen.getByRole('button', { name: 'Assistant' }))
99+
expect(screen.getByTestId('location')).toHaveTextContent('/repos/123/assistant')
100+
})
101+
102+
it('navigates to assistant route when assistant is clicked without repo id', async () => {
103+
vi.mocked(useMobile).mockReturnValue(true)
104+
const queryClient = new QueryClient()
105+
const user = userEvent.setup()
106+
107+
render(
108+
<QueryClientProvider client={queryClient}>
109+
<MemoryRouter initialEntries={['/schedules']}>
110+
<Routes>
111+
<Route path="*" element={<>
112+
<MobileTabBar />
113+
<LocationSpy />
114+
</>} />
115+
</Routes>
116+
</MemoryRouter>
117+
</QueryClientProvider>,
118+
)
119+
120+
await user.click(screen.getByRole('button', { name: 'Assistant' }))
121+
expect(screen.getByTestId('location')).toHaveTextContent('/assistant')
122+
})
123+
59124
it('renders schedule tabs on /repos/:id/schedules path', () => {
60125
vi.mocked(useMobile).mockReturnValue(true)
61126
const queryClient = new QueryClient()

0 commit comments

Comments
 (0)