Skip to content

Commit 4a90dda

Browse files
refactor - Add tabs menu - fix swipe nav
1 parent 29b9d02 commit 4a90dda

70 files changed

Lines changed: 3999 additions & 946 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ LOG_LEVEL=info
1717
OPENCODE_SERVER_PORT=5551
1818
OPENCODE_HOST=127.0.0.1
1919

20+
# Optional - bearer password required to talk to the spawned OpenCode server.
21+
# When set, the backend spawns OpenCode with this password and attaches it to
22+
# every proxied request. Leave unset to disable OpenCode-level auth.
23+
# OPENCODE_SERVER_PASSWORD=
24+
2025
# Optional - import an existing standalone OpenCode install on first startup
2126
# Useful for Docker when your host OpenCode data is bind-mounted into the container
2227
# OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json

backend/src/routes/mcp-oauth-proxy.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises'
66
import { storeMcpOAuthFlow, consumeMcpOAuthFlow, deleteMcpOAuthFlow, markMcpOAuthFlowCompleted, markMcpOAuthFlowFailed, getMcpOAuthFlowResult } from '../services/mcp-oauth-state'
77
import { logger } from '../utils/logger'
88
import { getWorkspacePath } from '@opencode-manager/shared/config/env'
9-
import { OPENCODE_SERVER_URL } from '../services/proxy'
9+
import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy'
1010

1111
const StartSchema = z.object({
1212
serverName: z.string(),
@@ -300,11 +300,13 @@ export function createMcpOauthProxyRoutes(requireAuth?: any) {
300300
}
301301
await fetch(reconnectUrl, {
302302
method: 'POST',
303+
headers: withOpenCodeAuth(),
303304
})
304305
if (flow.directory) {
305306
const globalReconnectUrl = `${OPENCODE_SERVER_URL}/mcp/${encodeURIComponent(flow.serverName)}/connect`
306307
await fetch(globalReconnectUrl, {
307308
method: 'POST',
309+
headers: withOpenCodeAuth(),
308310
})
309311
}
310312
} catch {

backend/src/routes/memory.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { resolveProjectId } from '../services/project-id-resolver'
88
import { getRepoById } from '../db/queries'
99
import { getWorkspacePath, getConfigPath } from '@opencode-manager/shared/config/env'
1010
import { parseJsonc } from '@opencode-manager/shared/utils'
11-
import { OPENCODE_SERVER_URL } from '../services/proxy'
11+
import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy'
1212
import {
1313
CreateMemoryRequestSchema,
1414
UpdateMemoryRequestSchema,
@@ -640,7 +640,7 @@ export function createMemoryRoutes(db: Database): Hono {
640640
try {
641641
const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${state.sessionId}/abort`)
642642
abortUrl.searchParams.set('directory', repo.fullPath)
643-
await fetch(abortUrl.toString(), { method: 'POST' })
643+
await fetch(abortUrl.toString(), { method: 'POST', headers: withOpenCodeAuth() })
644644
} catch {
645645
// Session may already be idle
646646
}

backend/src/services/opencode-single-server.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ import { patchOpenCodeConfig } from './proxy'
2424
const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT
2525
const OPENCODE_SERVER_HOST = ENV.OPENCODE.HOST
2626
const OPENCODE_SERVER_PUBLIC_URL = ENV.OPENCODE.PUBLIC_URL
27+
const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD
28+
const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME
29+
const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD
30+
? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}`
31+
: ''
2732
const MIN_OPENCODE_VERSION = '1.0.137'
2833
const MAX_STDERR_SIZE = 10240
2934

@@ -234,6 +239,13 @@ class OpenCodeServerManager {
234239

235240
let stderrOutput = ''
236241

242+
const cleanEnv = { ...process.env }
243+
delete cleanEnv.OPENCODE_SERVER_PASSWORD
244+
delete cleanEnv.OPENCODE_RUN_ID
245+
delete cleanEnv.OPENCODE_PROCESS_ROLE
246+
delete cleanEnv.OPENCODE_PID
247+
delete cleanEnv.OPENCODE
248+
237249
this.serverProcess = spawn(
238250
'opencode',
239251
['serve', '--port', OPENCODE_SERVER_PORT.toString(), '--hostname', OPENCODE_SERVER_HOST],
@@ -242,13 +254,19 @@ class OpenCodeServerManager {
242254
detached: !isDevelopment,
243255
stdio: isDevelopment ? 'inherit' : ['ignore', 'pipe', 'pipe'],
244256
env: {
245-
...process.env,
257+
...cleanEnv,
246258
...gitEnv,
247259
...gitIdentityEnv,
248260
GIT_SSH_COMMAND: gitSshCommand,
249261
XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'),
250262
XDG_CONFIG_HOME: path.join(openCodeServerDirectory, '.config'),
251263
...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}),
264+
...(OPENCODE_SERVER_PASSWORD
265+
? {
266+
OPENCODE_SERVER_PASSWORD,
267+
OPENCODE_SERVER_USERNAME,
268+
}
269+
: {}),
252270
OPENCODE_CONFIG: openCodeConfigPath,
253271
}
254272
}
@@ -450,8 +468,13 @@ class OpenCodeServerManager {
450468

451469
async checkHealth(): Promise<boolean> {
452470
try {
471+
const headers: Record<string, string> = {}
472+
if (OPENCODE_BASIC_AUTH) {
473+
headers.Authorization = OPENCODE_BASIC_AUTH
474+
}
453475
const response = await fetch(`http://${OPENCODE_SERVER_HOST}:${OPENCODE_SERVER_PORT}/doc`, {
454-
signal: AbortSignal.timeout(3000)
476+
signal: AbortSignal.timeout(3000),
477+
headers
455478
})
456479
return response.ok
457480
} catch {

backend/src/services/proxy.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,25 @@ import { ENV } from '@opencode-manager/shared/config/env'
33
import { parseJsonc } from '@opencode-manager/shared/utils'
44

55
export const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}`
6+
const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD
7+
const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME
8+
9+
const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD
10+
? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}`
11+
: ''
12+
13+
export function withOpenCodeAuth(headers: Record<string, string> = {}): Record<string, string> {
14+
if (OPENCODE_BASIC_AUTH) {
15+
return { ...headers, Authorization: OPENCODE_BASIC_AUTH }
16+
}
17+
return headers
18+
}
619

720
export async function setOpenCodeAuth(providerId: string, apiKey: string): Promise<boolean> {
821
try {
922
const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, {
1023
method: 'PUT',
11-
headers: { 'Content-Type': 'application/json' },
24+
headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }),
1225
body: JSON.stringify({ type: 'api', key: apiKey }),
1326
})
1427

@@ -29,6 +42,7 @@ export async function deleteOpenCodeAuth(providerId: string): Promise<boolean> {
2942
try {
3043
const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, {
3144
method: 'DELETE',
45+
headers: withOpenCodeAuth(),
3246
})
3347

3448
if (response.ok) {
@@ -196,7 +210,7 @@ export async function patchOpenCodeConfig(config: Record<string, unknown>): Prom
196210
try {
197211
const response = await fetch(`${OPENCODE_SERVER_URL}/config`, {
198212
method: 'PATCH',
199-
headers: { 'Content-Type': 'application/json' },
213+
headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }),
200214
body: JSON.stringify(config),
201215
})
202216

@@ -242,7 +256,7 @@ export async function patchOpenCodeConfig(config: Record<string, unknown>): Prom
242256
logger.info(`Retrying config patch after removing ${removedFields.length} problematic field(s): ${removedFields.join(', ')}`)
243257
const retryResponse = await fetch(`${OPENCODE_SERVER_URL}/config`, {
244258
method: 'PATCH',
245-
headers: { 'Content-Type': 'application/json' },
259+
headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }),
246260
body: JSON.stringify(cleanedConfig),
247261
})
248262

@@ -287,14 +301,14 @@ export async function proxyRequest(request: Request) {
287301
try {
288302
const headers: Record<string, string> = {}
289303
request.headers.forEach((value, key) => {
290-
if (!['host', 'connection'].includes(key.toLowerCase())) {
304+
if (!['host', 'connection', 'authorization'].includes(key.toLowerCase())) {
291305
headers[key] = value
292306
}
293307
})
294308

295309
const response = await fetch(targetUrl, {
296310
method: request.method,
297-
headers,
311+
headers: withOpenCodeAuth(headers),
298312
body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined,
299313
})
300314

@@ -337,7 +351,7 @@ export async function proxyToOpenCodeWithDirectory(
337351
try {
338352
const response = await fetch(url.toString(), {
339353
method,
340-
headers: headers || { 'Content-Type': 'application/json' },
354+
headers: withOpenCodeAuth(headers || { 'Content-Type': 'application/json' }),
341355
body,
342356
})
343357

@@ -378,7 +392,7 @@ export async function proxyMcpAuthStart(
378392
try {
379393
const response = await fetch(url.toString(), {
380394
method: 'POST',
381-
headers: { 'Content-Type': 'application/json' },
395+
headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }),
382396
})
383397

384398
const responseBody = await response.text()
@@ -409,7 +423,7 @@ export async function proxyMcpAuthAuthenticate(
409423
try {
410424
const response = await fetch(url.toString(), {
411425
method: 'POST',
412-
headers: { 'Content-Type': 'application/json' },
426+
headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }),
413427
})
414428

415429
const responseBody = await response.text()

backend/test/services/opencode-single-server.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ describe('OpenCodeServerManager - reloadConfig', () => {
254254
const mockPatchResult = { success: true }
255255
vi.mocked(patchOpenCodeConfig).mockResolvedValue(mockPatchResult)
256256

257+
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
258+
257259
const { opencodeServerManager } = await import('../../src/services/opencode-single-server')
258260

259261
await opencodeServerManager.reloadConfig()
@@ -263,5 +265,7 @@ describe('OpenCodeServerManager - reloadConfig', () => {
263265
'utf-8'
264266
)
265267
expect(patchOpenCodeConfig).toHaveBeenCalled()
268+
269+
fetchSpy.mockRestore()
266270
})
267271
})

frontend/src/App.tsx

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3-
import { createBrowserRouter, RouterProvider, Outlet, useNavigate } from 'react-router-dom'
4-
import { useEffect } from 'react'
3+
import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useLocation } from 'react-router-dom'
4+
import { useEffect, useRef } from 'react'
55
import { Toaster } from 'sonner'
66
import { Repos } from './pages/Repos'
77
import { RepoDetail } from './pages/RepoDetail'
@@ -15,14 +15,16 @@ import { Setup } from './pages/Setup'
1515
import { SettingsDialog } from './components/settings/SettingsDialog'
1616
import { VersionNotifier } from './components/VersionNotifier'
1717
import { PwaUpdatePrompt } from '@/components/PwaUpdatePrompt'
18+
import { MobileTabBar } from '@/components/navigation/MobileTabBar'
19+
import { MobileSheetHost } from '@/components/navigation/MobileSheetHost'
1820
import { useTheme } from './hooks/useTheme'
21+
import { useSwipeBack } from './hooks/useMobile'
1922
import { TTSProvider } from './contexts/TTSContext'
2023
import { AuthProvider } from './contexts/AuthContext'
2124
import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext'
25+
import { SwipeNavigationProvider } from '@/contexts/SwipeNavigationContext'
2226
import { PermissionRequestDialog } from './components/session/PermissionRequestDialog'
2327
import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog'
24-
import { PageTransition } from './components/ui/PageTransition'
25-
import { useNavigationDirection } from './hooks/useNavigationDirection'
2628
import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders'
2729

2830
const queryClient = new QueryClient({
@@ -70,8 +72,61 @@ function PermissionDialogWrapper() {
7072

7173
function AppShell() {
7274
const navigate = useNavigate()
75+
const location = useLocation()
76+
const rootRef = useRef<HTMLDivElement>(null)
7377
useTheme()
74-
useNavigationDirection()
78+
79+
const getSwipeBackTarget = () => {
80+
const path = location.pathname
81+
if (path.match(/^\/repos\/[^/]+\/sessions\/[^/]+$/)) {
82+
const repoId = path.split('/')[2]
83+
return `/repos/${repoId}`
84+
}
85+
if (path.match(/^\/repos\/[^/]+$/)) {
86+
return '/'
87+
}
88+
if (path.match(/^\/repos\/[^/]+\/memories$/)) {
89+
const repoId = path.split('/')[2]
90+
return `/repos/${repoId}`
91+
}
92+
if (path.match(/^\/repos\/[^/]+\/schedules$/)) {
93+
const repoId = path.split('/')[2]
94+
return `/repos/${repoId}`
95+
}
96+
if (path === '/schedules') {
97+
return '/'
98+
}
99+
return null
100+
}
101+
102+
const canSwipeBack = () => {
103+
const path = location.pathname
104+
return !['/login', '/setup', '/register', '/'].includes(path) && getSwipeBackTarget() !== null
105+
}
106+
107+
const handleSwipeBack = () => {
108+
const target = getSwipeBackTarget()
109+
if (target) {
110+
navigate(target)
111+
}
112+
}
113+
114+
const { bind: bindRouteSwipe } = useSwipeBack(
115+
() => {},
116+
{
117+
enabled: true,
118+
suspendsRouteSwipe: false,
119+
canBack: canSwipeBack,
120+
onBack: handleSwipeBack,
121+
}
122+
)
123+
124+
useEffect(() => {
125+
const cleanup = bindRouteSwipe(rootRef.current)
126+
return () => {
127+
cleanup?.()
128+
}
129+
}, [bindRouteSwipe])
75130

76131
useEffect(() => {
77132
const channel = new BroadcastChannel('notification-click')
@@ -87,9 +142,11 @@ function AppShell() {
87142
return (
88143
<AuthProvider>
89144
<EventProvider>
90-
<PageTransition>
145+
<div ref={rootRef} className="contents">
91146
<Outlet />
92-
</PageTransition>
147+
</div>
148+
<MobileTabBar />
149+
<MobileSheetHost />
93150
<PermissionDialogWrapper />
94151
<SSHHostKeyDialogWrapper />
95152
<SettingsDialog />
@@ -164,7 +221,9 @@ function App() {
164221
return (
165222
<QueryClientProvider client={queryClient}>
166223
<TTSProvider>
167-
<RouterProvider router={router} />
224+
<SwipeNavigationProvider>
225+
<RouterProvider router={router} />
226+
</SwipeNavigationProvider>
168227
</TTSProvider>
169228
</QueryClientProvider>
170229
)

0 commit comments

Comments
 (0)