From bc6e86c27a5d2ba3fdefa402e3594ffd9aac5721 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:55:59 -0400 Subject: [PATCH 01/19] loop: loop-stt-overhaul-lazy-mic-audioworklet-session-cleanup completed after 4 iterations --- backend/src/routes/stt.ts | 20 +-- frontend/public/audio-worklet-processor.js | 22 +++ .../src/components/message/PromptInput.tsx | 7 + frontend/src/hooks/useSTT.ts | 34 +++-- frontend/src/lib/audioRecorder.ts | 137 +++++++++--------- 5 files changed, 122 insertions(+), 98 deletions(-) create mode 100644 frontend/public/audio-worklet-processor.js diff --git a/backend/src/routes/stt.ts b/backend/src/routes/stt.ts index f891c9a5..eb3884fe 100644 --- a/backend/src/routes/stt.ts +++ b/backend/src/routes/stt.ts @@ -10,17 +10,7 @@ import { generateDiscoveryCacheKey, fetchAvailableModels, } from '../utils/discovery-cache' - -type STTConfigExtended = { - enabled: boolean - provider: 'external' | 'builtin' - endpoint: string - apiKey: string - model: string - language: string - availableModels?: string[] - lastModelsFetch?: number -} +import { type STTConfig } from '@opencode-manager/shared' export function createSTTRoutes(db: Database) { const app = new Hono() @@ -38,7 +28,7 @@ export function createSTTRoutes(db: Database) { const settingsService = new SettingsService(db) const settings = settingsService.getSettings(userId) - const sttConfig = settings.preferences.stt as STTConfigExtended | undefined + const sttConfig = settings.preferences.stt as STTConfig | undefined if (!sttConfig?.enabled) { return c.json({ error: 'STT is not enabled' }, 400) @@ -146,7 +136,7 @@ export function createSTTRoutes(db: Database) { const settingsService = new SettingsService(db) const settings = settingsService.getSettings(userId) - const sttConfig = settings.preferences.stt as STTConfigExtended | undefined + const sttConfig = settings.preferences.stt as STTConfig | undefined if (!sttConfig?.endpoint) { return c.json({ error: 'STT not configured' }, 400) @@ -178,7 +168,7 @@ export function createSTTRoutes(db: Database) { ...sttConfig, availableModels: models, lastModelsFetch: Date.now() - } as STTConfigExtended + } as STTConfig }, userId) logger.info(`Fetched ${models.length} STT models`) @@ -193,7 +183,7 @@ export function createSTTRoutes(db: Database) { const userId = c.req.query('userId') || 'default' const settingsService = new SettingsService(db) const settings = settingsService.getSettings(userId) - const sttConfig = settings.preferences.stt as STTConfigExtended | undefined + const sttConfig = settings.preferences.stt as STTConfig | undefined return c.json({ enabled: sttConfig?.enabled || false, diff --git a/frontend/public/audio-worklet-processor.js b/frontend/public/audio-worklet-processor.js new file mode 100644 index 00000000..7a3c9631 --- /dev/null +++ b/frontend/public/audio-worklet-processor.js @@ -0,0 +1,22 @@ +class RecorderProcessor extends AudioWorkletProcessor { + constructor() { + super() + this._active = true + this.port.onmessage = (e) => { + if (e.data === 'stop') { + this._active = false + } + } + } + + process(inputs) { + if (!this._active) return false + const input = inputs[0] + if (input && input[0] && input[0].length > 0) { + this.port.postMessage(new Float32Array(input[0])) + } + return true + } +} + +registerProcessor('recorder-processor', RecorderProcessor) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index fb3c5c5b..f4ebf22a 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -754,7 +754,14 @@ const { model, modelString } = useModelSelection(opcodeUrl, directory) }, [prompt, onPromptChange]) useEffect(() => { + if (isRecording) { + abortRecording() + } + clearSTT() + lastAddedTranscriptRef.current = '' + setIsTogglingRecording(false) setLocalMode(null) + // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally only run on sessionID change to avoid clearing transcript when recording state changes }, [sessionID]) diff --git a/frontend/src/hooks/useSTT.ts b/frontend/src/hooks/useSTT.ts index 262315eb..9320accd 100644 --- a/frontend/src/hooks/useSTT.ts +++ b/frontend/src/hooks/useSTT.ts @@ -17,9 +17,10 @@ export function useSTT(userId = 'default') { const recognizer = useRef(getWebSpeechRecognizer()) const audioRecorder = useRef(null) - const hasShownPermissionError = useRef(false) const abortControllerRef = useRef(null) const userIdRef = useRef(userId) + const errorTimeoutRef = useRef | null>(null) + const lastProcessedBlobRef = useRef(null) useEffect(() => { userIdRef.current = userId @@ -60,13 +61,11 @@ export function useSTT(userId = 'default') { setIsError(true) setError(errorMessage) - if (!hasShownPermissionError.current && errorMessage.includes('denied')) { - hasShownPermissionError.current = true - } - - setTimeout(() => { + if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = setTimeout(() => { setIsError(false) setError(null) + errorTimeoutRef.current = null }, 3000) }) @@ -112,13 +111,20 @@ export function useSTT(userId = 'default') { setIsError(true) setError(errorMessage) - setTimeout(() => { + if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = setTimeout(() => { setIsError(false) setError(null) + errorTimeoutRef.current = null }, 3000) }) recorder.setOnDataAvailable(async (blob) => { + if (lastProcessedBlobRef.current === blob) { + return + } + lastProcessedBlobRef.current = blob + setInterimTranscript('Processing...') setIsProcessing(true) @@ -145,9 +151,11 @@ export function useSTT(userId = 'default') { const errorMessage = err instanceof Error ? err.message : 'Transcription failed' setError(errorMessage) - setTimeout(() => { + if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + errorTimeoutRef.current = setTimeout(() => { setIsError(false) setError(null) + errorTimeoutRef.current = null }, 3000) } finally { setIsProcessing(false) @@ -166,8 +174,6 @@ export function useSTT(userId = 'default') { audioRecorder.current = new AudioRecorder() } - audioRecorder.current.warmup() - setupAudioRecorder(audioRecorder.current) return () => { @@ -194,7 +200,7 @@ export function useSTT(userId = 'default') { setInterimTranscript('') setIsError(false) setError(null) - hasShownPermissionError.current = false + lastProcessedBlobRef.current = null if (isExternalProvider) { if (!audioRecorder.current) { @@ -273,6 +279,12 @@ export function useSTT(userId = 'default') { setInterimTranscript('') }, []) + useEffect(() => { + return () => { + if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current) + } + }, []) + return { isRecording, isProcessing, diff --git a/frontend/src/lib/audioRecorder.ts b/frontend/src/lib/audioRecorder.ts index 3ad00cc0..0f80b61b 100644 --- a/frontend/src/lib/audioRecorder.ts +++ b/frontend/src/lib/audioRecorder.ts @@ -78,8 +78,9 @@ export class AudioRecorder { private mediaStream: MediaStream | null = null private source: MediaStreamAudioSourceNode | null = null private processor: ScriptProcessorNode | null = null - private audioBuffer: Float32Array | null = null - private recordingLength: number = 0 + private workletNode: AudioWorkletNode | null = null + private chunks: Float32Array[] = [] + private totalSamples: number = 0 private state: AudioRecorderState = 'idle' private options: AudioRecorderOptions private isAborted: boolean = false @@ -102,26 +103,6 @@ export class AudioRecorder { ) } - async warmup(): Promise { - if (!AudioRecorder.isSupported()) { - return false - } - - try { - const stream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true, - }, - }) - stream.getTracks().forEach(track => track.stop()) - return true - } catch { - return false - } - } - getState(): AudioRecorderState { return this.state } @@ -152,7 +133,8 @@ export class AudioRecorder { try { this.isAborted = false - this.recordingLength = 0 + this.chunks = [] + this.totalSamples = 0 this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -168,26 +150,33 @@ export class AudioRecorder { this.source = this.audioContext.createMediaStreamSource(this.mediaStream) - const bufferSize = 4096 - this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1) - - this.audioBuffer = new Float32Array(0) - - this.processor.onaudioprocess = (e) => { - if (this.audioBuffer) { - const inputData = e.inputBuffer.getChannelData(0) - const newLength = this.recordingLength + inputData.length - const newBuffer = new Float32Array(newLength) - newBuffer.set(this.audioBuffer, 0) - newBuffer.set(inputData, this.recordingLength) - this.audioBuffer = newBuffer - this.recordingLength = newLength + if (this.audioContext.audioWorklet) { + try { + await this.audioContext.audioWorklet.addModule('/audio-worklet-processor.js') + this.workletNode = new AudioWorkletNode(this.audioContext, 'recorder-processor') + this.workletNode.port.onmessage = (e: MessageEvent) => { + this.chunks.push(e.data) + this.totalSamples += e.data.length + } + this.source.connect(this.workletNode) + this.workletNode.connect(this.audioContext.destination) + } catch { + this.audioContext.close() + this.audioContext = null + throw new Error('Failed to load audio worklet processor') } + } else if (this.audioContext) { + const bufferSize = 4096 + this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1) + this.processor.onaudioprocess = (e) => { + const inputData = new Float32Array(e.inputBuffer.getChannelData(0)) + this.chunks.push(inputData) + this.totalSamples += inputData.length + } + this.source.connect(this.processor) + this.processor.connect(this.audioContext.destination) } - this.source.connect(this.processor) - this.processor.connect(this.audioContext.destination) - this.setState('recording') } catch (error) { this.setState('error') @@ -210,37 +199,41 @@ export class AudioRecorder { } stop(): void { - if (this.processor) { + if (this.processor || this.workletNode) { this.processRecording() } + this.resetRecordingState() this.cleanup() this.setState('stopped') } abort(): void { this.isAborted = true - this.audioBuffer = null - this.recordingLength = 0 + this.resetRecordingState() this.cleanup() this.setState('idle') } private processRecording(): void { - if (this.isAborted || !this.audioBuffer || this.recordingLength === 0) { + if (this.isAborted || this.chunks.length === 0 || this.totalSamples === 0) { return } try { + const merged = new Float32Array(this.totalSamples) + let offset = 0 + for (const chunk of this.chunks) { + merged.set(chunk, offset) + offset += chunk.length + } + const audioBuffer = this.audioContext!.createBuffer( 1, - this.recordingLength, + this.totalSamples, this.audioContext!.sampleRate ) - const channelData = new Float32Array(this.recordingLength) - channelData.set(this.audioBuffer!.subarray(0, this.recordingLength)) - audioBuffer.copyToChannel(channelData, 0) - + audioBuffer.copyToChannel(merged, 0) const wavBlob = encodeWAV(audioBuffer) this.onDataAvailable?.(wavBlob) } catch { @@ -250,7 +243,15 @@ export class AudioRecorder { } private cleanup(): void { + if (this.workletNode) { + this.workletNode.port.onmessage = null + this.workletNode.port.postMessage('stop') + this.workletNode.disconnect() + this.workletNode = null + } + if (this.processor) { + this.processor.onaudioprocess = null this.processor.disconnect() this.processor = null } @@ -271,42 +272,34 @@ export class AudioRecorder { } } + private resetRecordingState(): void { + this.chunks = [] + this.totalSamples = 0 + } + getRecordingBlob(): Blob | null { - if (!this.audioContext || !this.audioBuffer || this.recordingLength === 0) { + if (!this.audioContext || this.chunks.length === 0 || this.totalSamples === 0) { return null } try { + const merged = new Float32Array(this.totalSamples) + let offset = 0 + for (const chunk of this.chunks) { + merged.set(chunk, offset) + offset += chunk.length + } + const audioBuffer = this.audioContext.createBuffer( 1, - this.recordingLength, + this.totalSamples, this.audioContext.sampleRate ) - const channelData = new Float32Array(this.recordingLength) - channelData.set(this.audioBuffer.subarray(0, this.recordingLength)) - audioBuffer.copyToChannel(channelData, 0) + audioBuffer.copyToChannel(merged, 0) return encodeWAV(audioBuffer) } catch { return null } } } - -let recorderInstance: AudioRecorder | null = null - -export function getAudioRecorder(): AudioRecorder { - if (!recorderInstance) { - recorderInstance = new AudioRecorder() - } - return recorderInstance -} - -export function isAudioRecordingSupported(): boolean { - return AudioRecorder.isSupported() -} - -export async function warmupAudioRecorder(): Promise { - const recorder = getAudioRecorder() - return recorder.warmup() -} From 5f71b637fe397ae7c67494a292a25d25af92b1b6 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:52:43 -0400 Subject: [PATCH 02/19] Fix Docker volume permissions for SQLite database - Add runtime permission fix in docker-entrypoint.sh to ensure /app/data volume has correct ownership before app starts - Remove build-time chown for /app/data from Dockerfile since Docker volumes are mounted at runtime, not build time - This resolves SQLITE_READONLY error when upgrading to OpenCode 1.4.0 The database volume may have incorrect ownership when mounted by Docker, causing the SQLite database to be read-only. The entrypoint script now fixes permissions before starting the backend (runs as root before USER node). --- Dockerfile | 5 +++-- scripts/docker-entrypoint.sh | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b075a5b3..a1fd9733 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,8 +93,9 @@ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -RUN mkdir -p /workspace /app/data && \ - chown -R node:node /workspace /app/data +RUN mkdir -p /workspace && \ + chown -R node:node /workspace +# Note: /app/data is a Docker volume, permissions are handled at runtime in entrypoint EXPOSE 5003 5100 5101 5102 5103 diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 4446c209..5fa2f6de 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -75,5 +75,14 @@ if [ -z "$AUTH_SECRET" ]; then exit 1 fi +# Fix database directory permissions for Docker volumes +# Docker volumes are mounted at runtime and may have incorrect ownership +if [ -d "/app/data" ]; then + echo "🔧 Ensuring database directory permissions..." + chown -R node:node /app/data 2>/dev/null || true + chmod -R 755 /app/data 2>/dev/null || true + echo "✅ Database directory permissions set" +fi + exec "$@" From 9fb720cce0a80ae97990e7ae10931a5ab3906c50 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:01:59 -0400 Subject: [PATCH 03/19] Fix SQLite readonly: entrypoint must run as root to fix volume permissions Entrypoint ran as USER node (inherited from Dockerfile) so chown silently failed with EPERM. SQLite needs write access to the directory to create WAL/journal files alongside the database. - Remove USER node from Dockerfile so entrypoint runs as root - Restore mkdir /app/data + chown for fresh volume initialization - Entrypoint now fixes ownership then drops to node via runuser before exec --- Dockerfile | 7 ++----- scripts/docker-entrypoint.sh | 13 ++++--------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index a1fd9733..8dab155e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,17 +93,14 @@ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -RUN mkdir -p /workspace && \ - chown -R node:node /workspace -# Note: /app/data is a Docker volume, permissions are handled at runtime in entrypoint +RUN mkdir -p /workspace /app/data && \ + chown -R node:node /workspace /app/data EXPOSE 5003 5100 5101 5102 5103 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD curl -f http://localhost:5003/api/health || exit 1 -USER node - ENTRYPOINT ["/docker-entrypoint.sh"] CMD ["bun", "backend/src/index.ts"] diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 5fa2f6de..4d4b3865 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -75,14 +75,9 @@ if [ -z "$AUTH_SECRET" ]; then exit 1 fi -# Fix database directory permissions for Docker volumes -# Docker volumes are mounted at runtime and may have incorrect ownership -if [ -d "/app/data" ]; then - echo "🔧 Ensuring database directory permissions..." - chown -R node:node /app/data 2>/dev/null || true - chmod -R 755 /app/data 2>/dev/null || true - echo "✅ Database directory permissions set" -fi +mkdir -p /app/data +chown -R node:node /app/data +chmod -R 755 /app/data -exec "$@" +exec runuser -u node -- "$@" From 323ea56ab3c8ee591c566a65c35513722fe4dd9e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:09:52 -0400 Subject: [PATCH 04/19] Add cache and opencode directories to Docker setup --- Dockerfile | 4 ++-- scripts/docker-entrypoint.sh | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8dab155e..64785315 100644 --- a/Dockerfile +++ b/Dockerfile @@ -93,8 +93,8 @@ RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -RUN mkdir -p /workspace /app/data && \ - chown -R node:node /workspace /app/data +RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode && \ + chown -R node:node /workspace /app/data /home/node EXPOSE 5003 5100 5101 5102 5103 diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 4d4b3865..cec66ca7 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -75,9 +75,8 @@ if [ -z "$AUTH_SECRET" ]; then exit 1 fi -mkdir -p /app/data -chown -R node:node /app/data -chmod -R 755 /app/data +mkdir -p /app/data /workspace /home/node/.cache /home/node/.opencode +chown -R node:node /app/data /workspace /home/node exec runuser -u node -- "$@" From 5acb5646a39ca50e7388fd5e2b38217da3c36aeb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:09:13 -0400 Subject: [PATCH 05/19] feat: add mobile bottom tab navigation and OpenCode authentication Major Features: - Add mobile bottom tab navigation with swipe gestures - Implement MobileTabBar component with navigation store - Add RepoQuickSwitchSheet and NotificationsSheet components - Add OpenCode server authentication with Basic Auth support - Add OpenCode models editor UI Improvements: - Add side-drawer and bottom-sheet UI components - Implement useMobileTabBar and useNavigationDirection hooks - Add useAutoPlayLastResponse hook for TTS - Improve OAuth dialog UX with copy buttons and toast notifications - Add floating TTS button with better state management - Add model fallback logic and validation Fixes: - Correctly handle modelIDs containing slashes - Fix OAuth dialog state management - Clean up stale models from recentModels - Fix SQLite readonly issues in Docker - Fix mobile repo controls event bubbling Refactoring: - Remove custom title generation, use OpenCode API - Merge RepoSelectionBar into RepoListControls - Replace radio buttons with Tabs in AddRepoDialog - Remove title.ts route and tests Infrastructure: - Add cache and opencode directories to Docker setup - Fix Docker volume permissions for SQLite database - Add migration for repo last accessed timestamp Testing: - Add comprehensive tests for mobile navigation components - Add tests for bottom-sheet, side-drawer, and MobileTabBar - Add model fallback tests - Add proxy service tests --- .env.example | 5 + backend/src/auth/middleware.ts | 58 -- backend/src/db/migration-runner.ts | 49 - .../db/migrations/011-repo-last-accessed.ts | 25 + backend/src/db/migrations/index.ts | 2 + backend/src/db/queries.ts | 32 +- backend/src/db/schedules.ts | 5 - backend/src/index.ts | 133 +-- backend/src/routes/mcp-oauth-proxy.ts | 4 +- backend/src/routes/memory.ts | 4 +- backend/src/routes/oauth.ts | 2 +- backend/src/routes/repo-git.ts | 32 +- backend/src/routes/repos.ts | 48 +- backend/src/routes/settings.ts | 230 ++++- backend/src/routes/title.ts | 228 ----- backend/src/services/opencode-import.ts | 241 +++++ backend/src/services/opencode-models.ts | 19 - .../src/services/opencode-single-server.ts | 126 ++- backend/src/services/proxy.ts | 260 +++++- backend/src/services/repo.ts | 128 ++- backend/src/services/settings.ts | 91 +- backend/src/types/repo.ts | 1 + backend/src/utils/git-auth.ts | 11 - backend/test/db/queries.test.ts | 33 +- backend/test/db/schedules.test.ts | 13 - backend/test/routes/repos.test.ts | 101 ++ backend/test/routes/settings.test.ts | 538 ++++++++++- backend/test/routes/title.test.ts | 150 --- backend/test/services/opencode-import.test.ts | 325 +++++++ backend/test/services/opencode-models.test.ts | 84 ++ .../services/opencode-single-server.test.ts | 55 ++ backend/test/services/proxy.test.ts | 445 +++++++++ backend/test/services/repo.test.ts | 72 ++ frontend/package.json | 3 +- frontend/src/App.tsx | 9 +- frontend/src/api/fetchWrapper.ts | 7 +- frontend/src/api/oauth.ts | 23 +- frontend/src/api/opencode-spec.json | 112 ++- frontend/src/api/opencode-types.ts | 21 + frontend/src/api/providers.ts | 10 +- frontend/src/api/repos.ts | 6 + frontend/src/api/settings.ts | 14 + frontend/src/api/types.ts | 1 + frontend/src/api/types/settings.ts | 37 +- .../components/file-browser/FileBrowser.tsx | 63 +- .../file-browser/FileBrowserSheet.test.tsx | 872 ++++++++++++++++++ .../file-browser/FileBrowserSheet.tsx | 19 +- .../file-browser/FilePreviewDialog.tsx | 1 + .../src/components/file-browser/FileTree.tsx | 12 +- .../file-browser/MobileFilePreviewModal.tsx | 38 +- .../message/FloatingTTSButton.test.tsx | 280 ++++++ .../components/message/FloatingTTSButton.tsx | 140 ++- .../components/message/MessagePart.test.tsx | 226 +++++ .../src/components/message/MessagePart.tsx | 13 +- .../src/components/message/PromptInput.tsx | 366 ++++++-- .../components/model/ModelSelectDialog.tsx | 6 +- .../navigation/MobileTabBar.test.tsx | 232 +++++ .../components/navigation/MobileTabBar.tsx | 125 +++ .../components/navigation/MoreDrawer.test.tsx | 193 ++++ .../src/components/navigation/MoreDrawer.tsx | 111 +++ .../navigation/NotificationsSheet.test.tsx | 214 +++++ .../navigation/NotificationsSheet.tsx | 108 +++ .../navigation/RepoQuickSwitchSheet.test.tsx | 121 +++ .../navigation/RepoQuickSwitchSheet.tsx | 87 ++ .../src/components/repo/AddRepoDialog.tsx | 92 +- .../src/components/repo/RepoCard.test.tsx | 133 +++ frontend/src/components/repo/RepoCard.tsx | 117 +-- frontend/src/components/repo/RepoList.tsx | 235 +++-- .../src/components/repo/RepoListControls.tsx | 235 +++++ .../src/components/repo/RepoRowActions.tsx | 210 +++++ .../components/repo/repo-list-state.test.ts | 374 ++++++++ .../src/components/repo/repo-list-state.ts | 258 ++++++ .../src/components/session/SessionList.tsx | 20 +- .../settings/OAuthAuthorizeDialog.tsx | 219 ++++- .../settings/OAuthCallbackDialog.tsx | 114 ++- .../settings/OpenCodeConfigEditor.tsx | 56 +- .../settings/OpenCodeConfigManager.tsx | 289 +++++- .../settings/OpenCodeModelDialog.tsx | 637 +++++++++++++ .../settings/OpenCodeModelsEditor.test.tsx | 339 +++++++ .../settings/OpenCodeModelsEditor.tsx | 252 +++++ .../components/settings/ProviderSettings.tsx | 10 +- .../components/settings/SettingsDialog.tsx | 4 +- .../src/components/settings/TTSSettings.tsx | 25 + frontend/src/components/ui/PageTransition.tsx | 68 ++ frontend/src/components/ui/back-button.tsx | 8 +- .../src/components/ui/bottom-sheet.test.tsx | 112 +++ frontend/src/components/ui/bottom-sheet.tsx | 126 +++ frontend/src/components/ui/copy-button.tsx | 6 +- frontend/src/components/ui/dialog.test.tsx | 93 +- frontend/src/components/ui/dialog.tsx | 46 +- frontend/src/components/ui/header.tsx | 44 +- .../src/components/ui/side-drawer.test.tsx | 132 +++ frontend/src/components/ui/side-drawer.tsx | 119 +++ frontend/src/contexts/TTSContext.tsx | 76 +- frontend/src/contexts/tts-context.ts | 2 + .../useAutoPlayLastResponse.test.tsx | 335 +++++++ frontend/src/hooks/useAutoPlayLastResponse.ts | 102 ++ frontend/src/hooks/useMobile.test.tsx | 191 ++++ frontend/src/hooks/useMobile.ts | 263 ++++-- frontend/src/hooks/useMobileTabBar.test.tsx | 79 ++ frontend/src/hooks/useMobileTabBar.ts | 39 + frontend/src/hooks/useNavigationDirection.ts | 42 + frontend/src/hooks/useOpenCode.ts | 92 +- frontend/src/hooks/useRepoActivity.ts | 18 + frontend/src/hooks/useSSE.ts | 25 +- frontend/src/index.css | 18 + frontend/src/lib/oauthErrors.ts | 6 - frontend/src/pages/GlobalSchedules.tsx | 2 +- frontend/src/pages/Memories.tsx | 3 + frontend/src/pages/RepoDetail.tsx | 21 +- frontend/src/pages/Repos.tsx | 17 +- frontend/src/pages/Schedules.tsx | 4 + frontend/src/pages/SessionDetail.tsx | 84 +- frontend/src/stores/modelStore.ts | 19 +- frontend/src/stores/navigationStore.ts | 28 + frontend/vite.config.ts | 4 + package.json | 2 +- shared/src/config/defaults.ts | 1 + shared/src/config/env.ts | 5 +- shared/src/schemas/auth.ts | 26 +- shared/src/schemas/repo.ts | 1 + shared/src/schemas/settings.ts | 97 +- shared/src/types/errors.ts | 20 +- shared/src/types/index.ts | 6 + 124 files changed, 11397 insertions(+), 1624 deletions(-) create mode 100644 backend/src/db/migrations/011-repo-last-accessed.ts delete mode 100644 backend/src/routes/title.ts create mode 100644 backend/src/services/opencode-import.ts create mode 100644 backend/test/routes/repos.test.ts delete mode 100644 backend/test/routes/title.test.ts create mode 100644 backend/test/services/opencode-import.test.ts create mode 100644 backend/test/services/proxy.test.ts create mode 100644 frontend/src/components/file-browser/FileBrowserSheet.test.tsx create mode 100644 frontend/src/components/message/FloatingTTSButton.test.tsx create mode 100644 frontend/src/components/message/MessagePart.test.tsx create mode 100644 frontend/src/components/navigation/MobileTabBar.test.tsx create mode 100644 frontend/src/components/navigation/MobileTabBar.tsx create mode 100644 frontend/src/components/navigation/MoreDrawer.test.tsx create mode 100644 frontend/src/components/navigation/MoreDrawer.tsx create mode 100644 frontend/src/components/navigation/NotificationsSheet.test.tsx create mode 100644 frontend/src/components/navigation/NotificationsSheet.tsx create mode 100644 frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx create mode 100644 frontend/src/components/navigation/RepoQuickSwitchSheet.tsx create mode 100644 frontend/src/components/repo/RepoCard.test.tsx create mode 100644 frontend/src/components/repo/RepoListControls.tsx create mode 100644 frontend/src/components/repo/RepoRowActions.tsx create mode 100644 frontend/src/components/repo/repo-list-state.test.ts create mode 100644 frontend/src/components/repo/repo-list-state.ts create mode 100644 frontend/src/components/settings/OpenCodeModelDialog.tsx create mode 100644 frontend/src/components/settings/OpenCodeModelsEditor.test.tsx create mode 100644 frontend/src/components/settings/OpenCodeModelsEditor.tsx create mode 100644 frontend/src/components/ui/PageTransition.tsx create mode 100644 frontend/src/components/ui/bottom-sheet.test.tsx create mode 100644 frontend/src/components/ui/bottom-sheet.tsx create mode 100644 frontend/src/components/ui/side-drawer.test.tsx create mode 100644 frontend/src/components/ui/side-drawer.tsx create mode 100644 frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx create mode 100644 frontend/src/hooks/useAutoPlayLastResponse.ts create mode 100644 frontend/src/hooks/useMobile.test.tsx create mode 100644 frontend/src/hooks/useMobileTabBar.test.tsx create mode 100644 frontend/src/hooks/useMobileTabBar.ts create mode 100644 frontend/src/hooks/useNavigationDirection.ts create mode 100644 frontend/src/hooks/useRepoActivity.ts create mode 100644 frontend/src/stores/navigationStore.ts diff --git a/.env.example b/.env.example index 193b2654..b6307452 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,11 @@ LOG_LEVEL=info OPENCODE_SERVER_PORT=5551 OPENCODE_HOST=127.0.0.1 +# Optional - bearer password required to talk to the spawned OpenCode server. +# When set, the backend spawns OpenCode with this password and attaches it to +# every proxied request. Leave unset to disable OpenCode-level auth. +# OPENCODE_SERVER_PASSWORD= + # Optional - import an existing standalone OpenCode install on first startup # Useful for Docker when your host OpenCode data is bind-mounted into the container # OPENCODE_IMPORT_CONFIG_PATH=/import/opencode-config/opencode.json diff --git a/backend/src/auth/middleware.ts b/backend/src/auth/middleware.ts index bb9c900a..544f8bdd 100644 --- a/backend/src/auth/middleware.ts +++ b/backend/src/auth/middleware.ts @@ -40,62 +40,4 @@ export function createAuthMiddleware(auth: AuthInstance) { }) } -export function createOptionalAuthMiddleware(auth: AuthInstance) { - return createMiddleware<{ - Variables: { - session: Session['session'] | null - user: Session['user'] | null - } - }>(async (c, next) => { - try { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }) - - if (session) { - c.set('session', session.session as Session['session']) - c.set('user', session.user as Session['user']) - } else { - c.set('session', null) - c.set('user', null) - } - } catch { - c.set('session', null) - c.set('user', null) - } - - await next() - }) -} -export function createAdminMiddleware(auth: AuthInstance) { - return createMiddleware<{ - Variables: { - session: Session['session'] - user: Session['user'] - } - }>(async (c, next) => { - let session - try { - session = await auth.api.getSession({ - headers: c.req.raw.headers, - }) - } catch (error) { - logger.error('Session lookup failed', { error }) - return c.json({ error: 'Internal Server Error' }, 500) - } - - if (!session) { - return c.json({ error: 'Unauthorized' }, 401) - } - - const user = session.user as Session['user'] - if (user.role !== 'admin') { - return c.json({ error: 'Forbidden: Admin access required' }, 403) - } - - c.set('session', session.session as Session['session']) - c.set('user', user) - await next() - }) -} diff --git a/backend/src/db/migration-runner.ts b/backend/src/db/migration-runner.ts index e2b928a0..71f737d4 100644 --- a/backend/src/db/migration-runner.ts +++ b/backend/src/db/migration-runner.ts @@ -34,10 +34,6 @@ function markApplied(db: Database, migration: Migration): void { .run(migration.version, migration.name, Date.now()) } -function markReverted(db: Database, version: number): void { - db.prepare('DELETE FROM schema_migrations WHERE version = ?').run(version) -} - export function migrate(db: Database, migrations: Migration[]): void { ensureMigrationsTable(db) @@ -70,49 +66,4 @@ export function migrate(db: Database, migrations: Migration[]): void { logger.info('All migrations applied successfully') } -export function rollback(db: Database, migrations: Migration[], targetVersion?: number): void { - ensureMigrationsTable(db) - - const applied = getAppliedVersions(db) - const sorted = [...migrations] - .filter(m => applied.has(m.version)) - .sort((a, b) => b.version - a.version) - - if (sorted.length === 0) { - logger.info('No migrations to rollback') - return - } - - const latest = sorted[0] - if (!latest) { - logger.info('No migrations to rollback') - return - } - const target = targetVersion ?? latest.version - 1 - - const toRevert = sorted.filter(m => m.version > target) - - if (toRevert.length === 0) { - logger.info('No migrations to rollback') - return - } - logger.info(`Rolling back ${toRevert.length} migration(s) to version ${target}`) - - for (const migration of toRevert) { - logger.info(`Reverting migration ${migration.version}: ${migration.name}`) - db.run('BEGIN TRANSACTION') - try { - migration.down(db) - markReverted(db, migration.version) - db.run('COMMIT') - logger.info(`Migration ${migration.version} reverted successfully`) - } catch (error) { - db.run('ROLLBACK') - logger.error(`Rollback of migration ${migration.version} failed:`, error) - throw error - } - } - - logger.info('Rollback completed successfully') -} diff --git a/backend/src/db/migrations/011-repo-last-accessed.ts b/backend/src/db/migrations/011-repo-last-accessed.ts new file mode 100644 index 00000000..8b440f45 --- /dev/null +++ b/backend/src/db/migrations/011-repo-last-accessed.ts @@ -0,0 +1,25 @@ +import type { Migration } from '../migration-runner' + +interface ColumnInfo { + name: string +} + +const migration: Migration = { + version: 11, + name: 'repo-last-accessed', + + up(db) { + const tableInfo = db.prepare('PRAGMA table_info(repos)').all() as ColumnInfo[] + const existing = new Set(tableInfo.map((column) => column.name)) + + if (!existing.has('last_accessed_at')) { + db.run('ALTER TABLE repos ADD COLUMN last_accessed_at INTEGER') + } + }, + + down(db) { + void db + }, +} + +export default migration \ No newline at end of file diff --git a/backend/src/db/migrations/index.ts b/backend/src/db/migrations/index.ts index 705443db..d2ae1e91 100644 --- a/backend/src/db/migrations/index.ts +++ b/backend/src/db/migrations/index.ts @@ -9,6 +9,7 @@ import migration007 from './007-schedules' import migration008 from './008-schedule-cron-support' import migration009 from './009-repo-source-path' import migration010 from './009-prompt-templates' +import migration011 from './011-repo-last-accessed' export const allMigrations: Migration[] = [ migration001, @@ -21,4 +22,5 @@ export const allMigrations: Migration[] = [ migration008, migration009, migration010, + migration011, ] diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index d626099f..26a5f7e0 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -4,7 +4,7 @@ import { getReposPath } from '@opencode-manager/shared/config/env' import { getErrorMessage } from '../utils/error-utils' import path from 'path' -export interface RepoRow { +interface RepoRow { id: number repo_url?: string local_path: string @@ -14,6 +14,7 @@ export interface RepoRow { clone_status: string cloned_at: number last_pulled?: number + last_accessed_at?: number opencode_config_name?: string is_worktree?: number is_local?: number @@ -33,12 +34,20 @@ function rowToRepo(row: RepoRow): Repo { cloneStatus: row.clone_status as Repo['cloneStatus'], clonedAt: row.cloned_at, lastPulled: row.last_pulled, + lastAccessedAt: row.last_accessed_at, openCodeConfigName: row.opencode_config_name, isWorktree: row.is_worktree ? Boolean(row.is_worktree) : undefined, isLocal: row.is_local ? Boolean(row.is_local) : undefined, } } +export function getRepoById(db: Database, id: number): Repo | null { + const stmt = db.prepare('SELECT * FROM repos WHERE id = ?') + const row = stmt.get(id) as RepoRow | undefined + + return row ? rowToRepo(row) : null +} + export function createRepo(db: Database, repo: CreateRepoInput): Repo { const normalizedPath = repo.localPath.trim().replace(/\/+$/, '') @@ -53,8 +62,8 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo { } const stmt = db.prepare(` - INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, is_worktree, is_local) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO repos (repo_url, local_path, source_path, branch, default_branch, clone_status, cloned_at, last_accessed_at, is_worktree, is_local) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) try { @@ -66,6 +75,7 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo { repo.defaultBranch, repo.cloneStatus, repo.clonedAt, + repo.clonedAt, repo.isWorktree ? 1 : 0, repo.isLocal ? 1 : 0 ) @@ -96,13 +106,6 @@ export function createRepo(db: Database, repo: CreateRepoInput): Repo { } } -export function getRepoById(db: Database, id: number): Repo | null { - const stmt = db.prepare('SELECT * FROM repos WHERE id = ?') - const row = stmt.get(id) as RepoRow | undefined - - return row ? rowToRepo(row) : null -} - export function getRepoByUrlAndBranch(db: Database, repoUrl: string, branch?: string): Repo | null { const query = branch ? 'SELECT * FROM repos WHERE repo_url = ? AND branch = ?' @@ -189,6 +192,14 @@ export function updateLastPulled(db: Database, id: number): void { } } +export function updateLastAccessed(db: Database, id: number): void { + const stmt = db.prepare('UPDATE repos SET last_accessed_at = ? WHERE id = ?') + const result = stmt.run(Date.now(), id) + if (result.changes === 0) { + throw new Error(`Repository with id ${id} not found`) + } +} + export function updateRepoBranch(db: Database, id: number, branch: string): void { const stmt = db.prepare('UPDATE repos SET branch = ? WHERE id = ?') const result = stmt.run(branch, id) @@ -201,3 +212,4 @@ export function deleteRepo(db: Database, id: number): void { const stmt = db.prepare('DELETE FROM repos WHERE id = ?') stmt.run(id) } + diff --git a/backend/src/db/schedules.ts b/backend/src/db/schedules.ts index f89a97a0..4791f906 100644 --- a/backend/src/db/schedules.ts +++ b/backend/src/db/schedules.ts @@ -204,11 +204,6 @@ export function deleteScheduleJob(db: Database, repoId: number, jobId: number): return result.changes > 0 } -export function reserveScheduleJobNextRun(db: Database, repoId: number, jobId: number, nextRunAt: number): void { - const stmt = db.prepare('UPDATE schedule_jobs SET next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') - stmt.run(nextRunAt, Date.now(), repoId, jobId) -} - export function updateScheduleJobRunState(db: Database, repoId: number, jobId: number, values: { lastRunAt: number; nextRunAt?: number | null }): void { const stmt = db.prepare('UPDATE schedule_jobs SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') stmt.run(values.lastRunAt, values.nextRunAt ?? null, Date.now(), repoId, jobId) diff --git a/backend/src/index.ts b/backend/src/index.ts index 8cdd399d..81df10fa 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,10 +2,7 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' import { cors } from 'hono/cors' import { serveStatic } from '@hono/node-server/serve-static' -import os from 'os' -import path from 'path' -import { cp, readdir, readFile, rm } from 'fs/promises' -import { Database as SQLiteDatabase } from 'bun:sqlite' +import { readFile } from 'fs/promises' import { initializeDatabase } from './db/schema' import { createRepoRoutes } from './routes/repos' import { createIPCServer, type IPCServer } from './ipc/ipcServer' @@ -29,7 +26,6 @@ async function getAppVersion(): Promise { } import { createProvidersRoutes } from './routes/providers' import { createOAuthRoutes } from './routes/oauth' -import { createTitleRoutes } from './routes/title' import { createSSERoutes } from './routes/sse' import { createSSHRoutes } from './routes/ssh' import { createNotificationRoutes } from './routes/notifications' @@ -47,6 +43,9 @@ import { proxyRequest, proxyMcpAuthStart, proxyMcpAuthAuthenticate } from './ser import { NotificationService } from './services/notification' import { ScheduleRunner, ScheduleService } from './services/schedules' import { migrateGlobalSkills } from './services/skills' +import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import' +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { parse as parseJsonc } from 'jsonc-parser' import { logger } from './utils/logger' import { @@ -58,8 +57,7 @@ import { getDatabasePath, ENV } from '@opencode-manager/shared/config/env' -import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' -import { parse as parseJsonc } from 'jsonc-parser' + const { PORT, HOST } = ENV.SERVER const DB_PATH = getDatabasePath() @@ -86,72 +84,6 @@ import { DEFAULT_AGENTS_MD } from './constants' let ipcServer: IPCServer | undefined const gitAuthService = new GitAuthService() -const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) - -function getImportPathCandidates(envKey: string, fallbackPath: string): string[] { - const candidates = [process.env[envKey], fallbackPath] - .filter((value): value is string => Boolean(value)) - .map((value) => path.resolve(value)) - - return Array.from(new Set(candidates)) -} - -async function getFirstExistingPath(paths: string[]): Promise { - for (const candidate of paths) { - if (await fileExists(candidate)) { - return candidate - } - } - - return undefined -} - -function escapeSqliteValue(value: string): string { - return value.replace(/'/g, "''") -} - -async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise { - const entries = await readdir(sourcePath, { withFileTypes: true }) - - for (const entry of entries) { - if (OPENCODE_STATE_DB_FILENAMES.has(entry.name)) { - continue - } - - await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), { - recursive: true, - force: false, - errorOnExist: false, - }) - } -} - -async function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): Promise { - await rm(targetPath, { force: true }) - - const database = new SQLiteDatabase(sourcePath) - - try { - database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`) - } finally { - database.close() - } -} - -async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise { - await ensureDirectoryExists(targetPath) - await copyOpenCodeStateFiles(sourcePath, targetPath) - - const sourceDbPath = path.join(sourcePath, 'opencode.db') - if (!await fileExists(sourceDbPath)) { - return - } - - await rm(path.join(targetPath, 'opencode.db-shm'), { force: true }) - await rm(path.join(targetPath, 'opencode.db-wal'), { force: true }) - await snapshotOpenCodeDatabase(sourceDbPath, path.join(targetPath, 'opencode.db')) -} - async function ensureDefaultConfigExists(): Promise { const settingsService = new SettingsService(db) const workspaceConfigPath = getOpenCodeConfigFilePath() @@ -188,36 +120,13 @@ async function ensureDefaultConfigExists(): Promise { } } - const importConfigPath = await getFirstExistingPath( - getImportPathCandidates( - 'OPENCODE_IMPORT_CONFIG_PATH', - path.join(os.homedir(), '.config/opencode/opencode.json') - ) - ) + const { configSourcePath: importConfigPath } = await getOpenCodeImportStatus() if (importConfigPath) { logger.info(`Found importable OpenCode config at ${importConfigPath}, importing...`) try { - const rawContent = await readFileContent(importConfigPath) - const parsed = parseJsonc(rawContent) - const validation = OpenCodeConfigSchema.safeParse(parsed) - - if (validation.success) { - const existingDefault = settingsService.getOpenCodeConfigByName('default') - if (existingDefault) { - settingsService.updateOpenCodeConfig('default', { - content: rawContent, - isDefault: true, - }) - } else { - settingsService.createOpenCodeConfig({ - name: 'default', - content: rawContent, - isDefault: true, - }) - } - - await writeFileContent(workspaceConfigPath, rawContent) + const result = await syncOpenCodeImport({ db, overwriteState: false }) + if (result.configImported) { logger.info(`Imported OpenCode config from ${importConfigPath} to workspace`) return } @@ -249,28 +158,19 @@ async function ensureDefaultConfigExists(): Promise { async function ensureHomeStateImported(): Promise { try { - const workspaceStateRoot = path.join(getWorkspacePath(), '.opencode', 'state') - const workspaceStatePath = path.join(workspaceStateRoot, 'opencode') - const workspaceStateDbPath = path.join(workspaceStatePath, 'opencode.db') - - if (await fileExists(workspaceStateDbPath)) { + const status = await getOpenCodeImportStatus() + if (status.workspaceStateExists) { return } - const importStatePath = await getFirstExistingPath( - getImportPathCandidates( - 'OPENCODE_IMPORT_STATE_PATH', - path.join(os.homedir(), '.local', 'share', 'opencode') - ) - ) - - if (!importStatePath) { + if (!status.stateSourcePath) { return } - await ensureDirectoryExists(workspaceStateRoot) - await importOpenCodeStateDirectory(importStatePath, workspaceStatePath) - logger.info(`Imported OpenCode state from ${importStatePath}`) + const result = await syncOpenCodeImport({ db, overwriteState: false }) + if (result.stateImported) { + logger.info(`Imported OpenCode state from ${status.stateSourcePath}`) + } } catch (error) { logger.warn('Failed to import OpenCode state, continuing without imported state', error) } @@ -359,13 +259,12 @@ const protectedApi = new Hono() protectedApi.use('/*', requireAuth) protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService)) -protectedApi.route('/settings', createSettingsRoutes(db)) +protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService)) protectedApi.route('/files', createFileRoutes()) protectedApi.route('/providers', createProvidersRoutes()) protectedApi.route('/oauth', createOAuthRoutes()) protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) -protectedApi.route('/generate-title', createTitleRoutes()) protectedApi.route('/sse', createSSERoutes()) protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) diff --git a/backend/src/routes/mcp-oauth-proxy.ts b/backend/src/routes/mcp-oauth-proxy.ts index 9850f6dd..a193a649 100644 --- a/backend/src/routes/mcp-oauth-proxy.ts +++ b/backend/src/routes/mcp-oauth-proxy.ts @@ -6,7 +6,7 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { storeMcpOAuthFlow, consumeMcpOAuthFlow, deleteMcpOAuthFlow, markMcpOAuthFlowCompleted, markMcpOAuthFlowFailed, getMcpOAuthFlowResult } from '../services/mcp-oauth-state' import { logger } from '../utils/logger' import { getWorkspacePath } from '@opencode-manager/shared/config/env' -import { OPENCODE_SERVER_URL } from '../services/proxy' +import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy' const StartSchema = z.object({ serverName: z.string(), @@ -300,11 +300,13 @@ export function createMcpOauthProxyRoutes(requireAuth?: any) { } await fetch(reconnectUrl, { method: 'POST', + headers: withOpenCodeAuth(), }) if (flow.directory) { const globalReconnectUrl = `${OPENCODE_SERVER_URL}/mcp/${encodeURIComponent(flow.serverName)}/connect` await fetch(globalReconnectUrl, { method: 'POST', + headers: withOpenCodeAuth(), }) } } catch { diff --git a/backend/src/routes/memory.ts b/backend/src/routes/memory.ts index e41fad0b..68f75576 100644 --- a/backend/src/routes/memory.ts +++ b/backend/src/routes/memory.ts @@ -8,7 +8,7 @@ import { resolveProjectId } from '../services/project-id-resolver' import { getRepoById } from '../db/queries' import { getWorkspacePath, getConfigPath } from '@opencode-manager/shared/config/env' import { parseJsonc } from '@opencode-manager/shared/utils' -import { OPENCODE_SERVER_URL } from '../services/proxy' +import { OPENCODE_SERVER_URL, withOpenCodeAuth } from '../services/proxy' import { CreateMemoryRequestSchema, UpdateMemoryRequestSchema, @@ -640,7 +640,7 @@ export function createMemoryRoutes(db: Database): Hono { try { const abortUrl = new URL(`${OPENCODE_SERVER_URL}/session/${state.sessionId}/abort`) abortUrl.searchParams.set('directory', repo.fullPath) - await fetch(abortUrl.toString(), { method: 'POST' }) + await fetch(abortUrl.toString(), { method: 'POST', headers: withOpenCodeAuth() }) } catch { // Session may already be idle } diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 9dd362d3..5b4d64d4 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -18,7 +18,7 @@ export function createOAuthRoutes() { const body = await c.req.json() const validated = OAuthAuthorizeRequestSchema.parse(body) - // Proxy to OpenCode server + // Proxy to OpenCode server - only method and inputs are supported const response = await proxyRequest( new Request( `${OPENCODE_SERVER_URL}/provider/${providerId}/oauth/authorize`, diff --git a/backend/src/routes/repo-git.ts b/backend/src/routes/repo-git.ts index 070f0094..beb9463b 100644 --- a/backend/src/routes/repo-git.ts +++ b/backend/src/routes/repo-git.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import type { ContentfulStatusCode } from 'hono/utils/http-status' -import * as db from '../db/queries' +import { getRepoById } from '../db/queries' import { logger } from '../utils/logger' import { parseGitError } from '../utils/git-errors' import { GitService } from '../services/git/GitService' @@ -17,7 +17,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.get('/:id/git/status', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -94,7 +94,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'path query parameter is required' }, 400) } - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -124,7 +124,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS return c.json({ error: 'path query parameter is required' }, 400) } - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) } @@ -144,7 +144,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/fetch', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -167,7 +167,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/pull', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -190,7 +190,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/commit', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -220,7 +220,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/push', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -246,7 +246,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/stage', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -276,7 +276,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/unstage', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -306,7 +306,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/discard', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -337,7 +337,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS try { const id = parseInt(c.req.param('id')) const hash = c.req.param('hash') - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -369,7 +369,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS const id = parseInt(c.req.param('id')) const hash = c.req.param('hash') const filePath = c.req.query('path') - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -398,7 +398,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.get('/:id/git/log', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -421,7 +421,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.post('/:id/git/reset', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -451,7 +451,7 @@ export function createRepoGitRoutes(database: Database, gitAuthService: GitAuthS app.get('/:id/git/branches', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index c3fb087d..e62d9944 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { Database } from 'bun:sqlite' import { DiscoverReposRequestSchema } from '@opencode-manager/shared/schemas' -import * as db from '../db/queries' +import { listRepos, getRepoById, updateLastAccessed, updateRepoConfigName } from '../db/queries' import * as repoService from '../services/repo' import * as archiveService from '../services/archive' import { SettingsService } from '../services/settings' @@ -61,7 +61,7 @@ export function createRepoRoutes(database: Database, gitAuthService: GitAuthServ if (configContent) { const openCodeConfigPath = getOpenCodeConfigFilePath() await writeFileContent(openCodeConfigPath, configContent) - db.updateRepoConfigName(database, repo.id, openCodeConfigName) + updateRepoConfigName(database, repo.id, openCodeConfigName) logger.info(`Applied config '${openCodeConfigName}' to: ${openCodeConfigPath}`) } } @@ -100,7 +100,7 @@ app.get('/', async (c) => { try { const settingsService = new SettingsService(database) const settings = settingsService.getSettings() - const repos = db.listRepos(database, settings.preferences.repoOrder) + const repos = listRepos(database, settings.preferences.repoOrder) const reposWithCurrentBranch = await Promise.all( repos.map(async (repo) => { @@ -139,7 +139,7 @@ app.get('/', async (c) => { app.get('/:id', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -153,11 +153,29 @@ app.get('/', async (c) => { return c.json({ error: getErrorMessage(error) }, 500) } }) + + app.post('/:id/access', async (c) => { + try { + const id = parseInt(c.req.param('id')) + const repo = getRepoById(database, id) + + if (!repo) { + return c.json({ error: 'Repo not found' }, 404) + } + + updateLastAccessed(database, id) + + return c.json({ success: true }) + } catch (error: unknown) { + logger.error('Failed to update repo access:', error) + return c.json({ error: getErrorMessage(error) }, 500) + } + }) app.delete('/:id', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -177,7 +195,7 @@ app.get('/', async (c) => { const id = parseInt(c.req.param('id')) await repoService.pullRepo(database, gitAuthService, id) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) return c.json(repo) } catch (error: unknown) { logger.error('Failed to pull repo:', error) @@ -188,7 +206,7 @@ app.get('/', async (c) => { app.post('/:id/config/switch', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -212,7 +230,7 @@ app.get('/', async (c) => { await writeFileContent(openCodeConfigPath, configContent) - db.updateRepoConfigName(database, id, configName) + updateRepoConfigName(database, id, configName) logger.info(`Switched config for repo ${id} to '${configName}'`) logger.info(`Updated OpenCode config: ${openCodeConfigPath}`) @@ -221,7 +239,7 @@ app.get('/', async (c) => { await opencodeServerManager.stop() await opencodeServerManager.start() - const updatedRepo = db.getRepoById(database, id) + const updatedRepo = getRepoById(database, id) return c.json(updatedRepo) } catch (error: unknown) { logger.error('Failed to switch repo config:', error) @@ -232,7 +250,7 @@ app.get('/', async (c) => { app.post('/:id/branch/switch', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -247,7 +265,7 @@ app.get('/', async (c) => { await repoService.switchBranch(database, gitAuthService, id, branch) - const updatedRepo = db.getRepoById(database, id) + const updatedRepo = getRepoById(database, id) const currentBranch = await repoService.getCurrentBranch(updatedRepo!, gitAuthService.getGitEnvironment()) return c.json({ ...updatedRepo, currentBranch }) @@ -260,7 +278,7 @@ app.get('/', async (c) => { app.post('/:id/branch/create', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -275,7 +293,7 @@ app.get('/', async (c) => { await repoService.createBranch(database, gitAuthService, id, branch) - const updatedRepo = db.getRepoById(database, id) + const updatedRepo = getRepoById(database, id) const currentBranch = await repoService.getCurrentBranch(updatedRepo!, gitAuthService.getGitEnvironment()) return c.json({ ...updatedRepo, currentBranch }) @@ -288,7 +306,7 @@ app.get('/', async (c) => { app.get('/:id/download', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) @@ -335,7 +353,7 @@ app.get('/', async (c) => { app.post('/:id/reset-permissions', async (c) => { try { const id = parseInt(c.req.param('id')) - const repo = db.getRepoById(database, id) + const repo = getRepoById(database, id) if (!repo) { return c.json({ error: 'Repo not found' }, 404) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 437421d3..72fa8154 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite' import { SettingsService } from '../services/settings' import { writeFileContent, readFileContent, fileExists } from '../services/file-operations' import { patchOpenCodeConfig, proxyToOpenCodeWithDirectory } from '../services/proxy' -import { getOpenCodeConfigFilePath, getAgentsMdPath, ENV } from '@opencode-manager/shared/config/env' +import { getOpenCodeConfigFilePath, getAgentsMdPath } from '@opencode-manager/shared/config/env' import { UserPreferencesSchema, OpenCodeConfigSchema, @@ -19,11 +19,14 @@ import { SkillScopeSchema, } from '@opencode-manager/shared' import { logger } from '../utils/logger' -import { opencodeServerManager } from '../services/opencode-single-server' +import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' +import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' import { validateSSHPrivateKey } from '../utils/ssh-validation' import { encryptSecret } from '../utils/crypto' import { compareVersions, isValidVersion } from '../utils/version-utils' +import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../services/opencode-import' +import { relinkReposFromSessionDirectories } from '../services/repo' import { listManagedSkills, getSkill, @@ -53,6 +56,18 @@ function getOpenCodeInstallMethod(): string { return 'curl' } +function getOpenCodeConfigContentToWrite( + rawContent: string, + appliedConfig?: Record, + removedFields?: string[] +): string { + if (!appliedConfig || !removedFields || removedFields.length === 0) { + return rawContent + } + + return JSON.stringify(appliedConfig, null, 2) +} + function execWithTimeout( command: string | [executable: string, ...args: string[]], timeoutMs: number, @@ -153,6 +168,10 @@ const TestSSHConnectionSchema = z.object({ passphrase: z.string().optional(), }) +const SyncOpenCodeImportSchema = z.object({ + overwriteState: z.boolean().optional(), +}) + async function extractOpenCodeError(response: Response, defaultError: string): Promise { const errorObj = await response.json().catch(() => null) @@ -161,7 +180,7 @@ async function extractOpenCodeError(response: Response, defaultError: string): P : defaultError } -export function createSettingsRoutes(db: Database) { +export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService) { const app = new Hono() const settingsService = new SettingsService(db) @@ -181,7 +200,7 @@ export function createSettingsRoutes(db: Database) { const userId = c.req.query('userId') || 'default' const configs = settingsService.getOpenCodeConfigs(userId) const defaultConfig = configs.configs.find((cfg: { isDefault: boolean }) => cfg.isDefault) - const isEnabled = defaultConfig?.content?.plugin?.includes('@opencode-manager/memory') ?? false + const isEnabled = ((defaultConfig?.content?.plugin as string[] | undefined) ?? []).includes('@opencode-manager/memory') return c.json({ memoryPluginEnabled: isEnabled }) } catch (error) { logger.error('Failed to get memory plugin status:', error) @@ -284,20 +303,54 @@ export function createSettingsRoutes(db: Database) { const userId = c.req.query('userId') || 'default' const body = await c.req.json() const validated = CreateOpenCodeConfigSchema.parse(body) - - const config = settingsService.createOpenCodeConfig(validated, userId) - - if (config.isDefault) { + + if (validated.isDefault) { + settingsService.saveLastKnownGoodConfig(userId) + + const provisionalConfig = settingsService.createOpenCodeConfig( + { ...validated, isDefault: false }, + userId, + { suppressAutoDefault: true } + ) + + const patchResult = await patchOpenCodeConfig(provisionalConfig.content) + if (!patchResult.success) { + settingsService.deleteOpenCodeConfig(provisionalConfig.name, userId) + return c.json({ + error: 'Config validation failed', + details: patchResult.error, + validationIssues: patchResult.details, + removedFields: patchResult.removedFields + }, 400) + } + + const contentToWrite = getOpenCodeConfigContentToWrite( + provisionalConfig.rawContent, + patchResult.appliedConfig, + patchResult.removedFields + ) + const config = settingsService.updateOpenCodeConfig(provisionalConfig.name, { + content: contentToWrite, + isDefault: true, + }, userId) + + if (!config) { + return c.json({ error: 'Failed to finalize OpenCode config creation' }, 500) + } + const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, config.rawContent) + await writeFileContent(configPath, contentToWrite) logger.info(`Wrote default config to: ${configPath}`) - - const patchResult = await patchOpenCodeConfig(config.content) - if (!patchResult.success) { - return c.json({ error: 'Config saved but failed to apply', details: patchResult.error }, 500) + + if (patchResult.removedFields && patchResult.removedFields.length > 0) { + logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) + return c.json({ ...config, removedFields: patchResult.removedFields }) } + + return c.json(config) } - + + const config = settingsService.createOpenCodeConfig(validated, userId) return c.json(config) } catch (error) { logger.error('Failed to create OpenCode config:', error) @@ -327,10 +380,6 @@ export function createSettingsRoutes(db: Database) { } if (config.isDefault) { - const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, config.rawContent) - logger.info(`Wrote default config to: ${configPath}`) - const newAgents = config.content?.agent const agentsChanged = JSON.stringify(existingAgents) !== JSON.stringify(newAgents) @@ -340,7 +389,25 @@ export function createSettingsRoutes(db: Database) { } else { const patchResult = await patchOpenCodeConfig(config.content) if (!patchResult.success) { - return c.json({ error: 'Config saved but failed to apply', details: patchResult.error }, 500) + return c.json({ + error: 'Config saved but failed to apply', + details: patchResult.error, + validationIssues: patchResult.details, + removedFields: patchResult.removedFields + }, 500) + } + + const configPath = getOpenCodeConfigFilePath() + const contentToWrite = patchResult.removedFields && patchResult.removedFields.length > 0 + ? JSON.stringify(config.content, null, 2) + : config.rawContent + + await writeFileContent(configPath, contentToWrite) + logger.info(`Wrote default config to: ${configPath}`) + + if (patchResult.removedFields && patchResult.removedFields.length > 0) { + logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) + return c.json({ ...config, removedFields: patchResult.removedFields }) } } } @@ -379,18 +446,46 @@ export function createSettingsRoutes(db: Database) { settingsService.saveLastKnownGoodConfig(userId) + const existingConfig = settingsService.getOpenCodeConfigByName(configName, userId) + if (!existingConfig) { + return c.json({ error: 'Config not found' }, 404) + } + + const patchResult = await patchOpenCodeConfig(existingConfig.content) + if (!patchResult.success) { + return c.json({ + error: 'Config validation failed', + details: patchResult.error, + validationIssues: patchResult.details, + removedFields: patchResult.removedFields + }, 400) + } + + const contentToWrite = getOpenCodeConfigContentToWrite( + existingConfig.rawContent, + patchResult.appliedConfig, + patchResult.removedFields + ) + const updatedConfig = settingsService.updateOpenCodeConfig(configName, { + content: contentToWrite, + }, userId) + + if (!updatedConfig) { + return c.json({ error: 'Failed to update OpenCode config' }, 500) + } + const config = settingsService.setDefaultOpenCodeConfig(configName, userId) if (!config) { return c.json({ error: 'Config not found' }, 404) } const configPath = getOpenCodeConfigFilePath() - await writeFileContent(configPath, config.rawContent) + await writeFileContent(configPath, contentToWrite) logger.info(`Wrote default config '${configName}' to: ${configPath}`) - const patchResult = await patchOpenCodeConfig(config.content) - if (!patchResult.success) { - return c.json({ error: 'Config saved but failed to apply', details: patchResult.error }, 500) + if (patchResult.removedFields && patchResult.removedFields.length > 0) { + logger.info(`Config applied with auto-removed fields: ${patchResult.removedFields.join(', ')}`) + return c.json({ ...config, removedFields: patchResult.removedFields }) } return c.json(config) @@ -432,16 +527,99 @@ export function createSettingsRoutes(db: Database) { } }) + app.get('/opencode-import/status', async (c) => { + try { + return c.json(await getOpenCodeImportStatus()) + } catch (error) { + logger.error('Failed to get OpenCode import status:', error) + return c.json({ + error: 'Failed to get OpenCode import status', + details: error instanceof Error ? error.message : 'Unknown error' + }, 500) + } + }) + + app.post('/opencode-import', async (c) => { + try { + const userId = c.req.query('userId') || 'default' + const rawBody = c.req.header('content-type')?.includes('application/json') ? await c.req.json() : {} + const body = SyncOpenCodeImportSchema.parse(rawBody) + const result = await syncOpenCodeImport({ + db, + userId, + overwriteState: body.overwriteState ?? false, + protectExistingState: true, + }) + + if (!result.configImported && !result.stateImported) { + return c.json({ + error: 'No importable OpenCode host data found', + ...result, + }, 404) + } + + let relinkedRepos + if (result.stateImported) { + const importedSessions = await getImportedSessionDirectories(result.workspaceStatePath) + relinkedRepos = await relinkReposFromSessionDirectories(db, gitAuthService, importedSessions.directories) + } else { + relinkedRepos = { + repos: [], + relinkedCount: 0, + existingCount: 0, + nonRepoPathCount: 0, + duplicatePathCount: 0, + errors: [], + } + } + + opencodeServerManager.clearStartupError() + await opencodeServerManager.restart() + + return c.json({ + success: true, + message: 'Imported existing OpenCode host data and restarted the server', + serverRestarted: true, + relinkedRepos, + ...result, + }) + } catch (error) { + logger.error('Failed to import existing OpenCode host data:', error) + if (error instanceof z.ZodError) { + return c.json({ error: 'Invalid OpenCode import request', details: error.issues }, 400) + } + if (error instanceof OpenCodeImportProtectionError) { + return c.json({ + error: error.message, + code: error.code, + detail: error.detail, + }, 409) + } + return c.json({ + error: 'Failed to import existing OpenCode host data', + details: error instanceof Error ? error.message : 'Unknown error' + }, 500) + } + }) + app.post('/opencode-reload', async (c) => { try { logger.info('OpenCode configuration reload requested') - await fetch(`http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}/config`, { - method: 'GET' - }) await opencodeServerManager.reloadConfig() return c.json({ success: true, message: 'OpenCode configuration reloaded successfully' }) } catch (error) { logger.error('Failed to reload OpenCode config:', error) + if (error instanceof ConfigReloadError) { + const details = error.validationIssues.length > 0 + ? error.validationIssues.map((issue) => `${issue.path}: ${issue.message}`).join('; ') + : error.message + return c.json({ + error: error.message, + details, + validationIssues: error.validationIssues, + removedFields: error.removedFields + }, 500) + } return c.json({ error: 'Failed to reload OpenCode configuration', details: error instanceof Error ? error.message : 'Unknown error' diff --git a/backend/src/routes/title.ts b/backend/src/routes/title.ts deleted file mode 100644 index 25c17f10..00000000 --- a/backend/src/routes/title.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Hono } from 'hono' -import { z } from 'zod' -import { logger } from '../utils/logger' -import { resolveOpenCodeModel } from '../services/opencode-models' -import { OPENCODE_SERVER_URL } from '../services/proxy' - -const TitleRequestSchema = z.object({ - text: z.string().min(1).max(5000), - sessionID: z.string().min(1) -}) -const TITLE_POLL_INTERVAL_MS = 1_000 -const TITLE_POLL_TIMEOUT_MS = 30_000 - -interface PromptResponse { - parts?: Array<{ type?: string; text?: string }> -} - -interface SessionMessage { - info?: { - role?: string - time?: { - completed?: number - } - error?: { - name?: string - data?: { - message?: string - } - } - } - parts?: Array<{ type?: string; text?: string }> -} - -function buildUrl(path: string, directory?: string): string { - const url = `${OPENCODE_SERVER_URL}${path}` - return directory ? `${url}${url.includes('?') ? '&' : '?'}directory=${encodeURIComponent(directory)}` : url -} - -function parsePromptResponse(responseText: string): PromptResponse | null { - if (!responseText.trim()) { - return null - } - - try { - return JSON.parse(responseText) as PromptResponse - } catch { - return null - } -} - -function extractText(parts: Array<{ type?: string; text?: string }> | undefined): string { - return (parts ?? []) - .filter((part) => part.type === 'text' && typeof part.text === 'string') - .map((part) => part.text?.replace(/[\s\S]*?<\/think>\s*/g, '').trim() ?? '') - .filter(Boolean) - .join('\n') -} - -function extractTitle(result: PromptResponse | SessionMessage): string { - const text = extractText(result.parts) - const title = text - .split('\n') - .map((line) => line.trim()) - .find((line) => line.length > 0) || '' - - if (title.length > 100) { - return title.substring(0, 97) + '...' - } - - return title -} - -async function waitForTitleResponse(sessionID: string, directory: string): Promise { - const startedAt = Date.now() - - while (Date.now() - startedAt < TITLE_POLL_TIMEOUT_MS) { - const messagesResponse = await fetch(buildUrl(`/session/${sessionID}/message`, directory)) - - if (!messagesResponse.ok) { - const errorText = await messagesResponse.text() - throw new Error(errorText || 'Failed to fetch title generation messages') - } - - const messages = await messagesResponse.json() as SessionMessage[] - const assistantMessage = [...messages] - .reverse() - .find((message) => message.info?.role === 'assistant') - - if (assistantMessage) { - const errorText = assistantMessage.info?.error?.data?.message ?? assistantMessage.info?.error?.name - if (errorText) { - throw new Error(errorText) - } - - if (assistantMessage.info?.time?.completed) { - return assistantMessage - } - } - - await Bun.sleep(TITLE_POLL_INTERVAL_MS) - } - - throw new Error('Timed out waiting for title generation') -} - -const TITLE_PROMPT = `You are a title generator. You output ONLY a thread title. Nothing else. - - -Generate a brief title that would help the user find this conversation later. - -Follow all rules in -Use the so you know what a good title looks like. -Your output must be: -- A single line -- ≤50 characters -- No explanations - - - -- Focus on the main topic or question the user needs to retrieve -- Use -ing verbs for actions (Debugging, Implementing, Analyzing) -- Keep exact: technical terms, numbers, filenames, HTTP codes -- Remove: the, this, my, a, an -- Never assume tech stack -- Never use tools -- NEVER respond to questions, just generate a title for the conversation -- The title should NEVER include "summarizing" or "generating" when generating a title -- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT -- Always output something meaningful, even if the input is minimal. -- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): - → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) - - - -"debug 500 errors in production" → Debugging production 500 errors -"refactor user service" → Refactoring user service -"why is app.js failing" → Analyzing app.js failure -"implement rate limiting" → Implementing rate limiting -"how do I connect postgres to my API" → Connecting Postgres to API -"best practices for React hooks" → React hooks best practices -` - -export function createTitleRoutes() { - const app = new Hono() - - app.post('/', async (c) => { - try { - const body = await c.req.json() - const { text, sessionID } = TitleRequestSchema.parse(body) - const directory = c.req.header('directory') || '' - - logger.info('Generating session title via LLM', { sessionID, textLength: text.length }) - - const model = await resolveOpenCodeModel(directory || undefined, { - preferSmallModel: true, - }) - - const titleSessionResponse = await fetch(buildUrl('/session', directory), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: 'Title Generation' }) - }) - - if (!titleSessionResponse.ok) { - logger.error('Failed to create title generation session') - return c.json({ error: 'Failed to create session' }, 500) - } - - const titleSession = await titleSessionResponse.json() as { id: string } - const titleSessionID = titleSession.id - - try { - const promptResponse = await fetch(buildUrl(`/session/${titleSessionID}/message`, directory), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - parts: [ - { - type: 'text', - text: `${TITLE_PROMPT}\n\nGenerate a title for this conversation:\n\n${text.substring(0, 2000)}\n` - } - ], - model: { - providerID: model.providerID, - modelID: model.modelID, - } - }) - }) - - if (!promptResponse.ok) { - const errorText = await promptResponse.text() - logger.error('Failed to generate title via LLM', { error: errorText }) - return c.json({ error: 'LLM request failed' }, 500) - } - - const promptBody = await promptResponse.text() - const promptResult = parsePromptResponse(promptBody) - const title = extractTitle(promptResult ?? await waitForTitleResponse(titleSessionID, directory)) - - if (title) { - const updateResponse = await fetch(buildUrl(`/session/${sessionID}`, directory), { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title }) - }) - - if (!updateResponse.ok) { - logger.error('Failed to update session title') - } - } - - logger.info('Session title generated', { sessionID, title }) - return c.json({ title }) - - } finally { - fetch(buildUrl(`/session/${titleSessionID}`, directory), { - method: 'DELETE' - }).catch(() => {}) - } - - } catch (error) { - logger.error('Failed to generate session title:', error) - return c.json({ error: error instanceof Error ? error.message : 'Failed to generate title' }, 500) - } - }) - - return app -} diff --git a/backend/src/services/opencode-import.ts b/backend/src/services/opencode-import.ts new file mode 100644 index 00000000..a224a97e --- /dev/null +++ b/backend/src/services/opencode-import.ts @@ -0,0 +1,241 @@ +import os from 'os' +import path from 'path' +import { cp, mkdtemp, readdir, rename, rm } from 'fs/promises' +import { Database as SQLiteDatabase, type Database } from 'bun:sqlite' +import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' +import { getOpenCodeConfigFilePath, getWorkspacePath } from '@opencode-manager/shared/config/env' +import { parse as parseJsonc } from 'jsonc-parser' +import { SettingsService } from './settings' +import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent } from './file-operations' + +const OPENCODE_STATE_DB_FILENAMES = new Set(['opencode.db', 'opencode.db-shm', 'opencode.db-wal']) + +export interface OpenCodeImportStatus { + configSourcePath: string | null + stateSourcePath: string | null + workspaceConfigPath: string + workspaceStatePath: string + workspaceStateExists: boolean +} + +export interface SyncOpenCodeImportOptions { + db: Database + userId?: string + overwriteState?: boolean + protectExistingState?: boolean +} + +export interface SyncOpenCodeImportResult extends OpenCodeImportStatus { + configImported: boolean + stateImported: boolean +} + +export class OpenCodeImportProtectionError extends Error { + code = 'OPENCODE_IMPORT_PROTECTED' + detail: string + + constructor(detail: string) { + super('OpenCode host import was blocked to protect existing workspace state') + this.name = 'OpenCodeImportProtectionError' + this.detail = detail + } +} + +export interface ImportedSessionDirectorySummary { + directories: string[] +} + +export function getImportPathCandidates(envKey: string, fallbackPath: string): string[] { + const candidates = [process.env[envKey], fallbackPath] + .filter((value): value is string => Boolean(value)) + .map((value) => path.resolve(value)) + + return Array.from(new Set(candidates)) +} + +export async function getFirstExistingPath(paths: string[]): Promise { + for (const candidate of paths) { + if (await fileExists(candidate)) { + return candidate + } + } + + return null +} + +async function getFirstExistingPathWithDatabase(paths: string[]): Promise { + for (const candidate of paths) { + if (await fileExists(candidate) && await fileExists(path.join(candidate, 'opencode.db'))) { + return candidate + } + } + + return null +} + +function escapeSqliteValue(value: string): string { + return value.replace(/'/g, "''") +} + +async function copyOpenCodeStateFiles(sourcePath: string, targetPath: string): Promise { + const entries = await readdir(sourcePath, { withFileTypes: true }) + + for (const entry of entries) { + if (OPENCODE_STATE_DB_FILENAMES.has(entry.name)) { + continue + } + + await cp(path.join(sourcePath, entry.name), path.join(targetPath, entry.name), { + recursive: true, + force: true, + errorOnExist: false, + }) + } +} + +function snapshotOpenCodeDatabase(sourcePath: string, targetPath: string): void { + const database = new SQLiteDatabase(sourcePath) + + try { + database.exec(`VACUUM INTO '${escapeSqliteValue(targetPath)}'`) + } finally { + database.close() + } +} + +export async function importOpenCodeStateDirectory(sourcePath: string, targetPath: string): Promise { + const resolvedSourcePath = path.resolve(sourcePath) + const resolvedTargetPath = path.resolve(targetPath) + const sourceDbPath = path.join(resolvedSourcePath, 'opencode.db') + const targetParentPath = path.dirname(resolvedTargetPath) + const targetDirectoryName = path.basename(resolvedTargetPath) + + if (resolvedSourcePath === resolvedTargetPath) { + return false + } + + if (!await fileExists(sourceDbPath)) { + return false + } + + await ensureDirectoryExists(targetParentPath) + + const stagedTargetPath = await mkdtemp(path.join(targetParentPath, `${targetDirectoryName}-import-`)) + + try { + await copyOpenCodeStateFiles(resolvedSourcePath, stagedTargetPath) + snapshotOpenCodeDatabase(sourceDbPath, path.join(stagedTargetPath, 'opencode.db')) + + await rm(resolvedTargetPath, { recursive: true, force: true }) + await rename(stagedTargetPath, resolvedTargetPath) + return true + } catch (error) { + await rm(stagedTargetPath, { recursive: true, force: true }) + throw error + } +} + +export async function getOpenCodeImportStatus(): Promise { + const workspaceConfigPath = getOpenCodeConfigFilePath() + const workspaceStatePath = path.join(getWorkspacePath(), '.opencode', 'state', 'opencode') + const workspaceStateExists = await fileExists(path.join(workspaceStatePath, 'opencode.db')) + + const configSourcePath = await getFirstExistingPath( + getImportPathCandidates('OPENCODE_IMPORT_CONFIG_PATH', path.join(os.homedir(), '.config', 'opencode', 'opencode.json')) + ) + const stateSourcePath = await getFirstExistingPathWithDatabase( + getImportPathCandidates('OPENCODE_IMPORT_STATE_PATH', path.join(os.homedir(), '.local', 'share', 'opencode')) + ) + + return { + configSourcePath, + stateSourcePath, + workspaceConfigPath, + workspaceStatePath, + workspaceStateExists, + } +} + +async function importOpenCodeConfigFromSource(db: Database, userId: string, sourcePath: string, workspaceConfigPath: string): Promise { + const rawContent = await readFileContent(sourcePath) + const parsed = parseJsonc(rawContent) + const validation = OpenCodeConfigSchema.safeParse(parsed) + + if (!validation.success) { + throw new Error('Importable OpenCode config is invalid') + } + + const settingsService = new SettingsService(db) + const existingDefault = settingsService.getOpenCodeConfigByName('default', userId) + + if (existingDefault) { + settingsService.updateOpenCodeConfig('default', { + content: rawContent, + isDefault: true, + }, userId) + } else { + settingsService.createOpenCodeConfig({ + name: 'default', + content: rawContent, + isDefault: true, + }, userId) + } + + await writeFileContent(workspaceConfigPath, rawContent) + return true +} + +export async function syncOpenCodeImport(options: SyncOpenCodeImportOptions): Promise { + const initialStatus = await getOpenCodeImportStatus() + const userId = options.userId || 'default' + const overwriteState = options.overwriteState === true + let configImported = false + let stateImported = false + + if (options.protectExistingState && initialStatus.stateSourcePath && initialStatus.workspaceStateExists && !overwriteState) { + throw new OpenCodeImportProtectionError( + `Import was blocked because workspace state already exists at ${initialStatus.workspaceStatePath}. Clear the workspace state first if you want to replace it with host state.` + ) + } + + if (initialStatus.configSourcePath) { + configImported = await importOpenCodeConfigFromSource(options.db, userId, initialStatus.configSourcePath, initialStatus.workspaceConfigPath) + } + + if (initialStatus.stateSourcePath && (overwriteState || !initialStatus.workspaceStateExists)) { + stateImported = await importOpenCodeStateDirectory(initialStatus.stateSourcePath, initialStatus.workspaceStatePath) + } + + const finalStatus = await getOpenCodeImportStatus() + + return { + ...finalStatus, + configImported, + stateImported, + } +} + +export async function getImportedSessionDirectories(workspaceStatePath?: string): Promise { + const statePath = workspaceStatePath || path.join(getWorkspacePath(), '.opencode', 'state', 'opencode') + const stateDbPath = path.join(statePath, 'opencode.db') + + if (!await fileExists(stateDbPath)) { + return { directories: [] } + } + + const database = new SQLiteDatabase(stateDbPath, { readonly: true }) + + try { + const rows = database + .query("SELECT DISTINCT directory FROM session WHERE directory IS NOT NULL AND TRIM(directory) != '' ORDER BY directory") + .all() as Array<{ directory: string }> + + return { + directories: rows + .map((row) => row.directory.trim()) + .filter(Boolean), + } + } finally { + database.close() + } +} diff --git a/backend/src/services/opencode-models.ts b/backend/src/services/opencode-models.ts index 406a20d0..524fa863 100644 --- a/backend/src/services/opencode-models.ts +++ b/backend/src/services/opencode-models.ts @@ -111,25 +111,6 @@ export async function resolveOpenCodeModel( return parsedCandidate } } - - const parsedCandidate = parseModel(candidate) - if (!parsedCandidate) { - continue - } - - const providerDefaultModel = defaultModels[parsedCandidate.providerID] - if (!providerDefaultModel) { - continue - } - - const providerDefault = `${parsedCandidate.providerID}/${providerDefaultModel}` - if (availableModels.has(providerDefault)) { - return { - providerID: parsedCandidate.providerID, - modelID: providerDefaultModel, - model: providerDefault, - } - } } for (const [providerID, modelID] of Object.entries(defaultModels)) { diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 79c2e00b..c87af2fd 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -19,12 +19,73 @@ import { SettingsService } from './settings' import { getWorkspacePath, getOpenCodeConfigFilePath, ENV } from '@opencode-manager/shared/config/env' import type { Database } from 'bun:sqlite' import { compareVersions } from '../utils/version-utils' +import { patchOpenCodeConfig } from './proxy' const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT const OPENCODE_SERVER_HOST = ENV.OPENCODE.HOST +const OPENCODE_SERVER_PUBLIC_URL = ENV.OPENCODE.PUBLIC_URL +const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD +const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME +const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD + ? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}` + : '' const MIN_OPENCODE_VERSION = '1.0.137' const MAX_STDERR_SIZE = 10240 +type StartupValidationIssue = { + path: string + message: string +} + +export class ConfigReloadError extends Error { + validationIssues: StartupValidationIssue[] + removedFields: string[] + + constructor(message: string, validationIssues: StartupValidationIssue[] = [], removedFields: string[] = []) { + super(message) + this.name = 'ConfigReloadError' + this.validationIssues = validationIssues + this.removedFields = removedFields + } +} + +function parseStartupValidationIssues(stderrOutput: string): StartupValidationIssue[] { + const match = stderrOutput.match(/ZodError:\s*(\[[\s\S]*?\])(?:\n\s+at |$)/) + if (!match?.[1]) { + return [] + } + + try { + const parsed = JSON.parse(match[1]) as Array<{ path?: unknown; message?: unknown }> + return parsed + .map((issue) => ({ + path: Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : 'root', + message: typeof issue.message === 'string' ? issue.message : 'Invalid value', + })) + .filter((issue) => issue.message) + } catch { + return [] + } +} + +function formatStartupError(stderrOutput: string, fallback: string): string { + const validationIssues = parseStartupValidationIssues(stderrOutput) + if (validationIssues.length === 0) { + return fallback + } + + const summary = validationIssues + .slice(0, 8) + .map((issue) => `${issue.path}: ${issue.message}`) + .join('; ') + + const remainder = validationIssues.length > 8 + ? ` (${validationIssues.length - 8} more issue${validationIssues.length - 8 === 1 ? '' : 's'})` + : '' + + return `OpenCode config validation failed: ${summary}${remainder}` +} + // Helper getters to ensure values are computed at runtime (not module load time) // This allows proper mocking in tests const getOpenCodeServerDirectory = () => getWorkspacePath() @@ -178,6 +239,13 @@ class OpenCodeServerManager { let stderrOutput = '' + const cleanEnv = { ...process.env } + delete cleanEnv.OPENCODE_SERVER_PASSWORD + delete cleanEnv.OPENCODE_RUN_ID + delete cleanEnv.OPENCODE_PROCESS_ROLE + delete cleanEnv.OPENCODE_PID + delete cleanEnv.OPENCODE + this.serverProcess = spawn( 'opencode', ['serve', '--port', OPENCODE_SERVER_PORT.toString(), '--hostname', OPENCODE_SERVER_HOST], @@ -186,12 +254,19 @@ class OpenCodeServerManager { detached: !isDevelopment, stdio: isDevelopment ? 'inherit' : ['ignore', 'pipe', 'pipe'], env: { - ...process.env, + ...cleanEnv, ...gitEnv, ...gitIdentityEnv, GIT_SSH_COMMAND: gitSshCommand, XDG_DATA_HOME: path.join(openCodeServerDirectory, '.opencode/state'), XDG_CONFIG_HOME: path.join(openCodeServerDirectory, '.config'), + ...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}), + ...(OPENCODE_SERVER_PASSWORD + ? { + OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME, + } + : {}), OPENCODE_CONFIG: openCodeConfigPath, } } @@ -208,7 +283,8 @@ class OpenCodeServerManager { this.serverProcess.on('exit', (code, signal) => { if (code !== null && code !== 0) { - this.lastStartupError = `Server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}` + const fallback = `Server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}` + this.lastStartupError = formatStartupError(stderrOutput, fallback) logger.error('OpenCode server process exited:', this.lastStartupError) } else if (signal) { this.lastStartupError = `Server terminated by signal ${signal}` @@ -222,7 +298,8 @@ class OpenCodeServerManager { const healthy = await this.waitForHealth(30000) if (!healthy) { - this.lastStartupError = `Server failed to become healthy after 30s${stderrOutput ? `. Last error: ${stderrOutput.slice(-500)}` : ''}` + const fallback = `Server failed to become healthy after 30s${stderrOutput ? `. Last error: ${stderrOutput.slice(-500)}` : ''}` + this.lastStartupError = formatStartupError(stderrOutput, fallback) throw new Error('OpenCode server failed to become healthy') } @@ -327,24 +404,24 @@ class OpenCodeServerManager { async reloadConfig(): Promise { logger.info('Reloading OpenCode configuration (via API)') try { - const response = await fetch(`http://${OPENCODE_SERVER_HOST}:${OPENCODE_SERVER_PORT}/config`, { - method: 'GET' - }) - - if (!response.ok) { - throw new Error(`Failed to get current config: ${response.status}`) - } - - const currentConfig = await response.json() - logger.info('Triggering OpenCode config reload via PATCH') - const patchResponse = await fetch(`http://${OPENCODE_SERVER_HOST}:${OPENCODE_SERVER_PORT}/config`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(currentConfig) - }) - - if (!patchResponse.ok) { - throw new Error(`Failed to reload config: ${patchResponse.status}`) + const configPath = getOpenCodeConfigFilePath() + const fileContent = await fs.readFile(configPath, 'utf-8') + const fileConfig = JSON.parse(fileContent) as Record + logger.info(`Read config from file for reload: ${configPath}`) + + const patchResult = await patchOpenCodeConfig(fileConfig) + if (!patchResult.success) { + const errorMessage = patchResult.error || 'Failed to reload config' + const validationIssues = patchResult.details || [] + const removedFields = patchResult.removedFields || [] + if (validationIssues.length > 0) { + const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') + logger.error(`Config reload validation errors: ${issueSummary}`) + } + if (removedFields.length > 0) { + logger.info(`Removed fields during config reload: ${removedFields.join(', ')}`) + } + throw new ConfigReloadError(errorMessage, validationIssues, removedFields) } logger.info('OpenCode configuration reloaded successfully') @@ -391,8 +468,13 @@ class OpenCodeServerManager { async checkHealth(): Promise { try { + const headers: Record = {} + if (OPENCODE_BASIC_AUTH) { + headers.Authorization = OPENCODE_BASIC_AUTH + } const response = await fetch(`http://${OPENCODE_SERVER_HOST}:${OPENCODE_SERVER_PORT}/doc`, { - signal: AbortSignal.timeout(3000) + signal: AbortSignal.timeout(3000), + headers }) return response.ok } catch { diff --git a/backend/src/services/proxy.ts b/backend/src/services/proxy.ts index bad413bb..1cc3c63b 100644 --- a/backend/src/services/proxy.ts +++ b/backend/src/services/proxy.ts @@ -1,13 +1,27 @@ import { logger } from '../utils/logger' import { ENV } from '@opencode-manager/shared/config/env' +import { parseJsonc } from '@opencode-manager/shared/utils' export const OPENCODE_SERVER_URL = `http://${ENV.OPENCODE.HOST}:${ENV.OPENCODE.PORT}` +const OPENCODE_SERVER_PASSWORD = ENV.OPENCODE.SERVER_PASSWORD +const OPENCODE_SERVER_USERNAME = ENV.OPENCODE.SERVER_USERNAME + +const OPENCODE_BASIC_AUTH = OPENCODE_SERVER_PASSWORD + ? `Basic ${Buffer.from(`${OPENCODE_SERVER_USERNAME}:${OPENCODE_SERVER_PASSWORD}`).toString('base64')}` + : '' + +export function withOpenCodeAuth(headers: Record = {}): Record { + if (OPENCODE_BASIC_AUTH) { + return { ...headers, Authorization: OPENCODE_BASIC_AUTH } + } + return headers +} export async function setOpenCodeAuth(providerId: string, apiKey: string): Promise { try { const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), body: JSON.stringify({ type: 'api', key: apiKey }), }) @@ -28,6 +42,7 @@ export async function deleteOpenCodeAuth(providerId: string): Promise { try { const response = await fetch(`${OPENCODE_SERVER_URL}/auth/${providerId}`, { method: 'DELETE', + headers: withOpenCodeAuth(), }) if (response.ok) { @@ -43,48 +58,228 @@ export async function deleteOpenCodeAuth(providerId: string): Promise { } } +export type PatchConfigValidationIssue = { + path: string + message: string +} + export type PatchConfigResult = { success: boolean error?: string + details?: PatchConfigValidationIssue[] + removedFields?: string[] + appliedConfig?: Record +} + +function getIssuePath(value: unknown): string { + if (Array.isArray(value)) { + const path = value + .map((part) => typeof part === 'string' || typeof part === 'number' ? String(part) : '') + .filter(Boolean) + .join('.') + return path || 'root' + } + + if (typeof value === 'string' && value.length > 0) { + if (value.startsWith('/')) { + const pointerPath = value + .split('/') + .filter(Boolean) + .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')) + .join('.') + return pointerPath || 'root' + } + + return value + } + + if (typeof value === 'number') { + return String(value) + } + + return 'root' +} + +function getIssueMessage(value: unknown): string { + if (typeof value === 'string' && value.length > 0) { + return value + } + + return 'Validation error' +} + +function extractValidationIssues(value: unknown): PatchConfigValidationIssue[] { + if (!Array.isArray(value)) { + return [] + } + + return value.flatMap((item) => { + if (!item || typeof item !== 'object') { + return [] + } + + const issue = item as Record + const nestedIssues = extractValidationIssues(issue.issues ?? issue.errors) + if (nestedIssues.length > 0) { + return nestedIssues + } + + if ( + typeof issue.message === 'string' + || typeof issue.path === 'string' + || Array.isArray(issue.path) + || typeof issue.instancePath === 'string' + || Array.isArray(issue.instancePath) + ) { + return [{ + path: getIssuePath(issue.path ?? issue.instancePath), + message: getIssueMessage(issue.message), + }] + } + + return [] + }) +} + +function removeFieldFromConfig(config: Record, path: string): Record { + const result = JSON.parse(JSON.stringify(config)) as Record + const parts = path.split('.') + + let current: Record = result + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i] + if (!part || !current[part] || typeof current[part] !== 'object') { + return result + } + current = current[part] as Record + } + + const lastPart = parts[parts.length - 1] + if (lastPart) { + delete current[lastPart] + } + + return result +} + +function parseErrorResponse(responseText: string): { details: PatchConfigValidationIssue[]; errorMessage: string } { + const details: PatchConfigValidationIssue[] = [] + let errorMessage = 'Unknown error' + + try { + const errorBody = parseJsonc(responseText) as Record + const structuredIssues = extractValidationIssues( + errorBody?.errors + ?? errorBody?.issues + ?? (errorBody?.data && typeof errorBody.data === 'object' + ? (errorBody.data as Record).errors ?? (errorBody.data as Record).issues + : undefined) + ) + + if (structuredIssues.length > 0) { + details.push(...structuredIssues) + errorMessage = details.map((d) => `${d.path}: ${d.message}`).join('; ') + } else if (errorBody?.name === 'ConfigInvalidError' && errorBody?.data) { + const data = errorBody.data as { issues?: Array<{ message: string; path?: string[] }> } + if (data.issues) { + for (const issue of data.issues) { + const path = issue.path ? issue.path.join('.') : 'root' + details.push({ path, message: issue.message }) + } + errorMessage = details.map((d) => `${d.path}: ${d.message}`).join('; ') + } + } else if (typeof errorBody?.error === 'string') { + errorMessage = errorBody.error + } else if (typeof errorBody?.message === 'string') { + errorMessage = errorBody.message + } else if (typeof errorBody?.success === 'boolean' && errorBody.success === false && errorBody?.data) { + errorMessage = 'Config validation failed' + } else { + const snippet = responseText.slice(0, 300) + errorMessage = `Request failed (${snippet.length < responseText.length ? 'truncated' : 'raw'} response): ${snippet}` + } + } catch { + const snippet = responseText.slice(0, 300) + errorMessage = `Parse error: ${snippet}` + } + + return { details, errorMessage } } export async function patchOpenCodeConfig(config: Record): Promise { try { const response = await fetch(`${OPENCODE_SERVER_URL}/config`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), body: JSON.stringify(config), }) - + if (response.ok) { logger.info('Patched OpenCode config via API') - return { success: true } + return { success: true, appliedConfig: config } } - - let errorMessage = `${response.status} ${response.statusText}` - try { - const errorBody = await response.json() as Record - if (errorBody?.name === 'ConfigInvalidError' && errorBody?.data) { - const data = errorBody.data as { issues?: Array<{ message: string; path?: string[] }> } - if (data.issues) { - const issues = data.issues - .map((issue) => - issue.path ? `${issue.path.join('.')}: ${issue.message}` : issue.message - ) - .join('; ') - errorMessage = `Invalid config: ${issues}` - } - } else if (typeof errorBody?.error === 'string') { - errorMessage = errorBody.error - } else if (typeof errorBody?.message === 'string') { - errorMessage = errorBody.message + + const responseText = await response.text() + logger.warn(`OpenCode PATCH response (${response.status}): ${responseText.slice(0, 500)}`) + const { details, errorMessage: initialError } = parseErrorResponse(responseText) + + if (details.length === 0) { + logger.error(`Failed to patch OpenCode config: ${initialError}`) + return { success: false, error: initialError, details } + } + + logger.warn(`OpenCode rejected config with validation errors: ${initialError}`) + + const problematicPaths = [...new Set(details.map((d) => d.path))] + const removablePaths = problematicPaths.filter((path) => path !== 'root' && path.split('.').length <= 3) + const nonRemovablePaths = problematicPaths.filter((path) => path === 'root' || path.split('.').length > 3) + + if (nonRemovablePaths.length > 0) { + logger.error(`Failed to patch OpenCode config: ${initialError}`) + return { success: false, error: initialError, details } + } + + if (removablePaths.length === 0) { + logger.error(`Failed to patch OpenCode config: ${initialError}`) + return { success: false, error: initialError, details } + } + + let cleanedConfig = config + const removedFields: string[] = [] + + for (const path of removablePaths) { + cleanedConfig = removeFieldFromConfig(cleanedConfig, path) + removedFields.push(path) + logger.info(`Removed problematic field from config: ${path}`) + } + + logger.info(`Retrying config patch after removing ${removedFields.length} problematic field(s): ${removedFields.join(', ')}`) + const retryResponse = await fetch(`${OPENCODE_SERVER_URL}/config`, { + method: 'PATCH', + headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), + body: JSON.stringify(cleanedConfig), + }) + + if (retryResponse.ok) { + logger.info('Patched OpenCode config via API after removing invalid fields') + return { + success: true, + appliedConfig: cleanedConfig, + removedFields, + details } - } catch { - // Use default error message if we can't parse response body } - - logger.error(`Failed to patch OpenCode config: ${errorMessage}`) - return { success: false, error: errorMessage } + + const retryResponseText = await retryResponse.text() + const { details: retryDetails, errorMessage } = parseErrorResponse(retryResponseText) + logger.error(`Failed to patch OpenCode config even after removing invalid fields: ${errorMessage}`) + + return { + success: false, + error: errorMessage, + details: retryDetails.length > 0 ? retryDetails : details, + removedFields + } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error' logger.error('Failed to patch OpenCode config:', error) @@ -106,14 +301,14 @@ export async function proxyRequest(request: Request) { try { const headers: Record = {} request.headers.forEach((value, key) => { - if (!['host', 'connection'].includes(key.toLowerCase())) { + if (!['host', 'connection', 'authorization'].includes(key.toLowerCase())) { headers[key] = value } }) const response = await fetch(targetUrl, { method: request.method, - headers, + headers: withOpenCodeAuth(headers), body: request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : undefined, }) @@ -156,7 +351,7 @@ export async function proxyToOpenCodeWithDirectory( try { const response = await fetch(url.toString(), { method, - headers: headers || { 'Content-Type': 'application/json' }, + headers: withOpenCodeAuth(headers || { 'Content-Type': 'application/json' }), body, }) @@ -197,7 +392,7 @@ export async function proxyMcpAuthStart( try { const response = await fetch(url.toString(), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), }) const responseBody = await response.text() @@ -228,7 +423,7 @@ export async function proxyMcpAuthAuthenticate( try { const response = await fetch(url.toString(), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: withOpenCodeAuth({ 'Content-Type': 'application/json' }), }) const responseBody = await response.text() @@ -244,4 +439,3 @@ export async function proxyMcpAuthAuthenticate( }) } } - diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 86343d4c..1bd6f7fc 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -2,7 +2,7 @@ import fs from 'fs/promises' import { existsSync, rmSync } from 'node:fs' import { executeCommand } from '../utils/process' import { ensureDirectoryExists } from './file-operations' -import * as db from '../db/queries' +import { createRepo, getRepoByLocalPath, getRepoBySourcePath, getRepoById, updateRepoStatus, updateRepoBranch, updateLastPulled, deleteRepo, getRepoByUrlAndBranch } from '../db/queries' import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' @@ -186,14 +186,14 @@ async function createWorkspaceLink(alias: string, sourcePath: string): Promise { - const existingRepo = db.getRepoBySourcePath(database, sourcePath) + const existingRepo = getRepoBySourcePath(database, sourcePath) if (existingRepo) { return existingRepo.localPath } const candidates = buildWorkspaceAliasCandidates(sourcePath, rootPath) for (const candidate of candidates) { - const existingByLocalPath = db.getRepoByLocalPath(database, candidate) + const existingByLocalPath = getRepoByLocalPath(database, candidate) if (!existingByLocalPath && await isWorkspaceAliasAvailable(candidate, sourcePath)) { return candidate } @@ -203,7 +203,7 @@ async function pickWorkspaceAlias(database: Database, sourcePath: string, rootPa let suffix = 2 while (true) { const candidate = `${baseCandidate}-${suffix}` - const existingByLocalPath = db.getRepoByLocalPath(database, candidate) + const existingByLocalPath = getRepoByLocalPath(database, candidate) if (!existingByLocalPath && await isWorkspaceAliasAvailable(candidate, sourcePath)) { return candidate } @@ -230,6 +230,16 @@ async function safeGetCurrentBranch(repoPath: string, env: Record): Promise { + try { + const resolvedPath = normalizeAbsolutePath(targetPath) + const repoRoot = await executeCommand(['git', '-C', resolvedPath, 'rev-parse', '--show-toplevel'], { env, silent: true }) + return normalizeAbsolutePath(repoRoot.trim()) + } catch { + return null + } +} + async function registerExistingLocalRepo( database: Database, gitAuthService: GitAuthService, @@ -239,7 +249,7 @@ async function registerExistingLocalRepo( ): Promise<{ repo: Repo; existed: boolean }> { const normalizedSourcePath = normalizeAbsolutePath(sourcePath) const env = gitAuthService.getGitEnvironment() - const existingBySourcePath = db.getRepoBySourcePath(database, normalizedSourcePath) + const existingBySourcePath = getRepoBySourcePath(database, normalizedSourcePath) if (existingBySourcePath) { logger.info(`Local repo already exists in database: ${normalizedSourcePath}`) @@ -267,7 +277,7 @@ async function registerExistingLocalRepo( const workspaceLocalPath = getWorkspaceLocalPathForRepo(normalizedSourcePath) if (workspaceLocalPath) { - const existingByLocalPath = db.getRepoByLocalPath(database, workspaceLocalPath) + const existingByLocalPath = getRepoByLocalPath(database, workspaceLocalPath) if (existingByLocalPath) { logger.info(`Workspace repo already exists in database: ${workspaceLocalPath}`) return { repo: existingByLocalPath, existed: true } @@ -279,7 +289,7 @@ async function registerExistingLocalRepo( await createWorkspaceLink(repoLocalPath, normalizedSourcePath) } - const repo = db.createRepo(database, { + const repo = createRepo(database, { localPath: repoLocalPath, sourcePath: workspaceLocalPath ? undefined : normalizedSourcePath, branch: branch || currentBranch || undefined, @@ -375,6 +385,76 @@ export async function discoverLocalRepos( } } +export async function relinkReposFromSessionDirectories( + database: Database, + gitAuthService: GitAuthService, + directories: string[] +): Promise<{ + repos: Repo[] + relinkedCount: number + existingCount: number + nonRepoPathCount: number + duplicatePathCount: number + errors: Array<{ path: string; error: string }> +}> { + const env = gitAuthService.getGitEnvironment() + const errors: Array<{ path: string; error: string }> = [] + const uniqueRepoRoots = new Set() + let nonRepoPathCount = 0 + let duplicatePathCount = 0 + + for (const directory of directories) { + const normalizedDirectory = normalizeInputPath(directory) + if (!normalizedDirectory) { + nonRepoPathCount += 1 + continue + } + + const repoRoot = await findGitRepoRoot(normalizedDirectory, env) + if (!repoRoot) { + nonRepoPathCount += 1 + continue + } + + if (uniqueRepoRoots.has(repoRoot)) { + duplicatePathCount += 1 + continue + } + + uniqueRepoRoots.add(repoRoot) + } + + const repos: Repo[] = [] + let relinkedCount = 0 + let existingCount = 0 + + for (const repoRoot of Array.from(uniqueRepoRoots).sort((left, right) => left.localeCompare(right))) { + try { + const result = await registerExistingLocalRepo(database, gitAuthService, repoRoot) + repos.push(result.repo) + if (result.existed) { + existingCount += 1 + } else { + relinkedCount += 1 + } + } catch (error: unknown) { + errors.push({ + path: repoRoot, + error: getErrorMessage(error), + }) + } + } + + return { + repos, + relinkedCount, + existingCount, + nonRepoPathCount, + duplicatePathCount, + errors, + } +} + async function checkoutBranchSafely(repoPath: string, branch: string, env: Record): Promise { const sanitizedBranch = branch .replace(/^refs\/heads\//, '') @@ -424,7 +504,7 @@ export async function initLocalRepo( const repoLocalPath = normalizedInputPath const targetPath = path.join(getReposPath(), repoLocalPath) - const existing = db.getRepoByLocalPath(database, repoLocalPath) + const existing = getRepoByLocalPath(database, repoLocalPath) if (existing) { logger.info(`Local repo already exists in database: ${repoLocalPath}`) return existing @@ -443,7 +523,7 @@ export async function initLocalRepo( let directoryCreated = false try { - repo = db.createRepo(database, createRepoInput) + repo = createRepo(database, createRepoInput) logger.info(`Created database record for local repo: ${repoLocalPath} (id: ${repo.id})`) } catch (error: unknown) { logger.error(`Failed to create database record for local repo: ${repoLocalPath}`, error) @@ -470,14 +550,14 @@ export async function initLocalRepo( throw new Error(`Git initialization failed - directory exists but is not a valid git repository`) } - db.updateRepoStatus(database, repo.id, 'ready') + updateRepoStatus(database, repo.id, 'ready') logger.info(`Local git repo ready: ${repoLocalPath}`) return { ...repo, cloneStatus: 'ready' } } catch (error: unknown) { logger.error(`Failed to initialize local repo, rolling back: ${repoLocalPath}`, error) try { - db.deleteRepo(database, repo.id) + deleteRepo(database, repo.id) logger.info(`Rolled back database record for repo id: ${repo.id}`) } catch (dbError: unknown) { logger.error(`Failed to rollback database record for repo id ${repo.id}:`, getErrorMessage(dbError)) @@ -514,7 +594,7 @@ export async function cloneRepo( const worktreeDirName = branch && useWorktree ? `${repoName}-${branch.replace(/[\\/]/g, '-')}` : repoName const localPath = worktreeDirName - const existing = db.getRepoByUrlAndBranch(database, normalizedRepoUrl, branch) + const existing = getRepoByUrlAndBranch(database, normalizedRepoUrl, branch) if (existing) { logger.info(`Repo branch already exists: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}`) @@ -542,7 +622,7 @@ export async function cloneRepo( createRepoInput.isWorktree = true } - const repo = db.createRepo(database, createRepoInput) + const repo = createRepo(database, createRepoInput) try { const env = { @@ -661,7 +741,7 @@ export async function cloneRepo( } } - db.updateRepoStatus(database, repo.id, 'ready') + updateRepoStatus(database, repo.id, 'ready') return { ...repo, cloneStatus: 'ready' } } else { logger.warn(`Invalid repository directory found, removing and recloning: ${baseRepoDirName}`) @@ -725,12 +805,12 @@ export async function cloneRepo( } } - db.updateRepoStatus(database, repo.id, 'ready') + updateRepoStatus(database, repo.id, 'ready') logger.info(`Repo ready: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}${shouldUseWorktree ? ' (worktree)' : ''}`) return { ...repo, cloneStatus: 'ready' } } catch (error: unknown) { logger.error(`Failed to create repo: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}`, error) - db.deleteRepo(database, repo.id) + deleteRepo(database, repo.id) throw error } finally { await gitAuthService.cleanupSSHKey() @@ -749,7 +829,7 @@ export async function switchBranch( repoId: number, branch: string ): Promise { - const repo = db.getRepoById(database, repoId) + const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -771,7 +851,7 @@ export async function switchBranch( logger.info(`Successfully switched to branch: ${sanitizedBranch}`) - db.updateRepoBranch(database, repoId, sanitizedBranch) + updateRepoBranch(database, repoId, sanitizedBranch) } catch (error: unknown) { logger.error(`Failed to switch branch for repo ${repoId}:`, error) throw error @@ -779,7 +859,7 @@ export async function switchBranch( } export async function createBranch(database: Database, gitAuthService: GitAuthService, repoId: number, branch: string): Promise { - const repo = db.getRepoById(database, repoId) + const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -797,7 +877,7 @@ export async function createBranch(database: Database, gitAuthService: GitAuthSe await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch], { env }) logger.info(`Successfully created and switched to branch: ${sanitizedBranch}`) - db.updateRepoBranch(database, repoId, sanitizedBranch) + updateRepoBranch(database, repoId, sanitizedBranch) } catch (error: unknown) { logger.error(`Failed to create branch for repo ${repoId}:`, error) throw error @@ -809,7 +889,7 @@ export async function pullRepo( gitAuthService: GitAuthService, repoId: number ): Promise { - const repo = db.getRepoById(database, repoId) + const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -825,7 +905,7 @@ export async function pullRepo( logger.info(`Pulling repo: ${repo.repoUrl}`) await executeCommand(['git', '-C', path.resolve(repo.fullPath), 'pull'], { env }) - db.updateLastPulled(database, repoId) + updateLastPulled(database, repoId) logger.info(`Repo pulled successfully: ${repo.repoUrl}`) } catch (error: unknown) { logger.error(`Failed to pull repo: ${repo.repoUrl}`, error) @@ -834,7 +914,7 @@ export async function pullRepo( } export async function deleteRepoFiles(database: Database, repoId: number): Promise { - const repo = db.getRepoById(database, repoId) + const repo = getRepoById(database, repoId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -855,7 +935,7 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi } await executeCommand(['rm', '-rf', repo.localPath], getReposPath()) - db.deleteRepo(database, repoId) + deleteRepo(database, repoId) } function normalizeRepoUrl(url: string, preserveSSH: boolean = false): { url: string; name: string } { diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index 54890997..8a8a4678 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -3,10 +3,10 @@ import { unlinkSync, existsSync } from 'fs' import { getOpenCodeConfigFilePath } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' import { parseJsonc } from '@opencode-manager/shared/utils' +import { z } from 'zod' import type { UserPreferences, SettingsResponse, - OpenCodeConfig, CreateOpenCodeConfigRequest, UpdateOpenCodeConfigRequest } from '../types/settings' @@ -16,8 +16,21 @@ import { DEFAULT_USER_PREFERENCES, } from '../types/settings' -interface OpenCodeConfigWithRaw extends OpenCodeConfig { +interface OpenCodeConfigValidationIssue { + path: string + message: string +} + +interface OpenCodeConfigWithRaw { + id: number + name: string + content: Record rawContent: string + validationIssues?: OpenCodeConfigValidationIssue[] + isValid: boolean + isDefault: boolean + createdAt: number + updatedAt: number } interface OpenCodeConfigResponseWithRaw { @@ -25,12 +38,47 @@ interface OpenCodeConfigResponseWithRaw { defaultConfig: OpenCodeConfigWithRaw | null } +interface CreateOpenCodeConfigOptions { + suppressAutoDefault?: boolean +} + export class SettingsService { private static lastKnownGoodConfigContent: string | null = null constructor(private db: Database) {} + private getValidationIssues(error: z.ZodError): OpenCodeConfigValidationIssue[] { + return error.issues.map((issue) => ({ + path: issue.path.length > 0 ? issue.path.join('.') : 'root', + message: issue.message, + })) + } + + private parseStoredConfig(rawContent: string, configName: string): { content: Record; validationIssues?: OpenCodeConfigValidationIssue[]; isValid: boolean } { + const parsed = parseJsonc(rawContent) + const content = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {} + + const validated = OpenCodeConfigSchema.safeParse(parsed) + if (validated.success) { + return { + content: validated.data as Record, + isValid: true, + } + } + + const validationIssues = this.getValidationIssues(validated.error) + logger.error(`Failed to validate config ${configName}: ${validationIssues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`) + + return { + content, + validationIssues, + isValid: false, + } + } + initializeLastKnownGoodConfig(userId: string = 'default'): void { const settings = this.getSettings(userId) if (settings.preferences.lastKnownGoodConfig) { @@ -140,14 +188,15 @@ export class SettingsService { for (const row of rows) { try { const rawContent = row.config_content - const content = parseJsonc(rawContent) - const validated = OpenCodeConfigSchema.parse(content) + const parsedConfig = this.parseStoredConfig(rawContent, row.config_name) const config: OpenCodeConfigWithRaw = { id: row.id, name: row.config_name, - content: validated, + content: parsedConfig.content, rawContent: rawContent, + validationIssues: parsedConfig.validationIssues, + isValid: parsedConfig.isValid, isDefault: Boolean(row.is_default), createdAt: row.created_at, updatedAt: row.updated_at, @@ -171,7 +220,8 @@ export class SettingsService { createOpenCodeConfig( request: CreateOpenCodeConfigRequest, - userId: string = 'default' + userId: string = 'default', + options: CreateOpenCodeConfigOptions = {} ): OpenCodeConfigWithRaw { // Check for existing config with the same name const existing = this.getOpenCodeConfigByName(request.name, userId) @@ -194,7 +244,7 @@ export class SettingsService { .query('SELECT COUNT(*) as count FROM opencode_configs WHERE user_id = ?') .get(userId) as { count: number } - const shouldBeDefault = request.isDefault || existingCount.count === 0 + const shouldBeDefault = request.isDefault || (!options.suppressAutoDefault && existingCount.count === 0) if (shouldBeDefault) { this.db @@ -219,8 +269,9 @@ export class SettingsService { const config: OpenCodeConfigWithRaw = { id: result.lastInsertRowid as number, name: request.name, - content: contentValidated, + content: contentValidated as Record, rawContent: rawContent, + isValid: true, isDefault: shouldBeDefault, createdAt: now, updatedAt: now, @@ -282,8 +333,9 @@ export class SettingsService { const config: OpenCodeConfigWithRaw = { id: existing.id, name: configName, - content: contentValidated, + content: contentValidated as Record, rawContent: rawContent, + isValid: true, isDefault: request.isDefault !== undefined ? request.isDefault : existing.is_default, createdAt: existing.created_at, updatedAt: now, @@ -335,14 +387,15 @@ export class SettingsService { try { const rawContent = existing.config_content - const content = parseJsonc(rawContent) - const validated = OpenCodeConfigSchema.parse(content) + const parsedConfig = this.parseStoredConfig(rawContent, configName) const config: OpenCodeConfigWithRaw = { id: existing.id, name: configName, - content: validated, + content: parsedConfig.content, rawContent: rawContent, + validationIssues: parsedConfig.validationIssues, + isValid: parsedConfig.isValid, isDefault: true, createdAt: existing.created_at, updatedAt: now, @@ -373,14 +426,15 @@ export class SettingsService { try { const rawContent = row.config_content - const content = parseJsonc(rawContent) - const validated = OpenCodeConfigSchema.parse(content) + const parsedConfig = this.parseStoredConfig(rawContent, row.config_name) return { id: row.id, name: row.config_name, - content: validated, + content: parsedConfig.content, rawContent: rawContent, + validationIssues: parsedConfig.validationIssues, + isValid: parsedConfig.isValid, isDefault: true, createdAt: row.created_at, updatedAt: row.updated_at, @@ -409,14 +463,15 @@ export class SettingsService { try { const rawContent = row.config_content - const content = parseJsonc(rawContent) - const validated = OpenCodeConfigSchema.parse(content) + const parsedConfig = this.parseStoredConfig(rawContent, configName) return { id: row.id, name: row.config_name, - content: validated, + content: parsedConfig.content, rawContent: rawContent, + validationIssues: parsedConfig.validationIssues, + isValid: parsedConfig.isValid, isDefault: Boolean(row.is_default), createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/backend/src/types/repo.ts b/backend/src/types/repo.ts index 81736697..07977809 100644 --- a/backend/src/types/repo.ts +++ b/backend/src/types/repo.ts @@ -4,6 +4,7 @@ export * from '../../../shared/src/schemas/repo' export interface Repo extends BaseRepo { isWorktree?: boolean + lastAccessedAt?: number } interface CreateRepoInputBase { diff --git a/backend/src/utils/git-auth.ts b/backend/src/utils/git-auth.ts index 3a7f338b..7f470fb9 100644 --- a/backend/src/utils/git-auth.ts +++ b/backend/src/utils/git-auth.ts @@ -116,17 +116,6 @@ export function findGitHubCredential(credentials: GitCredential[]): GitCredentia }) || null } -export function getCredentialForHost(credentials: GitCredential[], host: string): GitCredential | undefined { - return credentials.find(cred => { - try { - const parsed = new URL(cred.host) - return parsed.hostname.toLowerCase() === host.toLowerCase() - } catch { - return false - } - }) -} - export function getSSHCredentialsForHost(credentials: GitCredential[], host: string): GitCredential[] { return credentials.filter(cred => { if (cred.type !== 'ssh') return false diff --git a/backend/test/db/queries.test.ts b/backend/test/db/queries.test.ts index 5fc66f18..d909817d 100644 --- a/backend/test/db/queries.test.ts +++ b/backend/test/db/queries.test.ts @@ -21,6 +21,7 @@ describe('Database Queries', () => { describe('createRepo', () => { it('should insert new repo record', () => { + const clonedAt = Date.now() const repo = { repoUrl: 'https://github.com/test/repo', localPath: 'repos/test-repo', @@ -28,7 +29,7 @@ describe('Database Queries', () => { branch: 'main', defaultBranch: 'main', cloneStatus: 'ready' as const, - clonedAt: Date.now(), + clonedAt, isWorktree: false, isLocal: true, } @@ -55,6 +56,7 @@ describe('Database Queries', () => { default_branch: repo.defaultBranch, clone_status: repo.cloneStatus, cloned_at: repo.clonedAt, + last_accessed_at: clonedAt, is_worktree: 0 }) } @@ -76,6 +78,7 @@ describe('Database Queries', () => { repo.defaultBranch, repo.cloneStatus, repo.clonedAt, + clonedAt, repo.isWorktree ? 1 : 0, 1 ) @@ -86,6 +89,7 @@ describe('Database Queries', () => { describe('getRepoById', () => { it('should retrieve repo by ID', () => { const clonedAt = Date.now() + const lastAccessedAt = Date.now() const repoRow = { id: 1, repo_url: 'https://github.com/test/repo', @@ -96,6 +100,7 @@ describe('Database Queries', () => { clone_status: 'ready', cloned_at: clonedAt, last_pulled: null, + last_accessed_at: lastAccessedAt, opencode_config_name: null, is_worktree: 0, is_local: 0 @@ -119,6 +124,7 @@ describe('Database Queries', () => { cloneStatus: 'ready', clonedAt: clonedAt, lastPulled: null, + lastAccessedAt: lastAccessedAt, openCodeConfigName: null, isWorktree: undefined, isLocal: undefined @@ -226,6 +232,31 @@ describe('Database Queries', () => { }) }) + describe('updateLastAccessed', () => { + it('should update repo last accessed timestamp', () => { + const stmt = { + run: vi.fn().mockReturnValue({ changes: 1 }) + } + mockDb.prepare.mockReturnValue(stmt) + + db.updateLastAccessed(mockDb, 1) + + expect(mockDb.prepare).toHaveBeenCalledWith( + 'UPDATE repos SET last_accessed_at = ? WHERE id = ?' + ) + expect(stmt.run).toHaveBeenCalledWith(expect.any(Number), 1) + }) + + it('should throw error when repo not found', () => { + const stmt = { + run: vi.fn().mockReturnValue({ changes: 0 }) + } + mockDb.prepare.mockReturnValue(stmt) + + expect(() => db.updateLastAccessed(mockDb, 999)).toThrow('Repository with id 999 not found') + }) + }) + describe('deleteRepo', () => { it('should delete repo by ID', () => { const stmt = { diff --git a/backend/test/db/schedules.test.ts b/backend/test/db/schedules.test.ts index db0eda22..6371765b 100644 --- a/backend/test/db/schedules.test.ts +++ b/backend/test/db/schedules.test.ts @@ -354,19 +354,6 @@ describe('schedule database queries', () => { expect(result).toBe(false) }) - it('reserves the next run time for a schedule job', () => { - const nextRunAt = Date.now() + 3600000 - const stmt = { - run: vi.fn(), - } - mockDb.prepare.mockReturnValue(stmt) - - schedulesDb.reserveScheduleJobNextRun(mockDb, 42, 7, nextRunAt) - - expect(mockDb.prepare).toHaveBeenCalledWith('UPDATE schedule_jobs SET next_run_at = ?, updated_at = ? WHERE repo_id = ? AND id = ?') - expect(stmt.run).toHaveBeenCalledWith(nextRunAt, expect.any(Number), 42, 7) - }) - it('updates the run state of a schedule job', () => { const lastRunAt = Date.now() const nextRunAt = Date.now() + 3600000 diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts new file mode 100644 index 00000000..466c664d --- /dev/null +++ b/backend/test/routes/repos.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { Database } from 'bun:sqlite' + +const mockDb = { + prepare: vi.fn(), + exec: vi.fn(), + close: vi.fn(), + transaction: vi.fn() +} as unknown as Database + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(() => mockDb) +})) + +vi.mock('../../src/db/queries', () => ({ + getRepoById: vi.fn(), + updateLastAccessed: vi.fn() +})) + +vi.mock('../../src/services/repo', () => ({ + getCurrentBranch: vi.fn() +})) + +import * as db from '../../src/db/queries' +import { createRepoRoutes } from '../../src/routes/repos' +import type { GitAuthService } from '../../src/services/git-auth' +import type { ScheduleService } from '../../src/services/schedules' + +const mockGitAuthService = { + getGitEnvironment: vi.fn().mockReturnValue({}) +} as unknown as GitAuthService + +const mockScheduleService = {} as ScheduleService + +describe('Repo Routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('POST /:id/access', () => { + it('should return 404 when repo not found', async () => { + vi.mocked(db.getRepoById).mockReturnValue(null) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const res = await app.request('/1/access', { method: 'POST' }) + + expect(res.status).toBe(404) + const body = await res.json() as { error: string } + expect(body.error).toBe('Repo not found') + }) + + it('should return 200 and call updateLastAccessed when repo exists', async () => { + const mockRepo = { + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: 'repos/test-repo', + fullPath: '/Users/test/repos/test-repo', + sourcePath: '/Users/test/repos/test-repo', + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now(), + lastAccessedAt: Date.now() + } + vi.mocked(db.getRepoById).mockReturnValue(mockRepo) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const res = await app.request('/1/access', { method: 'POST' }) + + expect(res.status).toBe(200) + const body = await res.json() as { success: boolean } + expect(body.success).toBe(true) + expect(db.updateLastAccessed).toHaveBeenCalledWith(mockDb, 1) + }) + + it('should return 500 when updateLastAccessed throws', async () => { + const mockRepo = { + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: 'repos/test-repo', + fullPath: '/Users/test/repos/test-repo', + sourcePath: '/Users/test/repos/test-repo', + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready' as const, + clonedAt: Date.now() + } + vi.mocked(db.getRepoById).mockReturnValue(mockRepo) + vi.mocked(db.updateLastAccessed).mockImplementation(() => { + throw new Error('Database error') + }) + + const app = createRepoRoutes(mockDb, mockGitAuthService, mockScheduleService) + const res = await app.request('/1/access', { method: 'POST' }) + + expect(res.status).toBe(500) + const body = await res.json() as { error: string } + expect(body.error).toBe('Database error') + }) + }) +}) \ No newline at end of file diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index 4a0b70b2..e9aa3753 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { execSync, spawnSync } from 'child_process' +const mockGetSettings = vi.fn() +const mockUpdateSettings = vi.fn() +const mockSaveLastKnownGoodConfig = vi.fn() +const mockCreateOpenCodeConfig = vi.fn() +const mockUpdateOpenCodeConfig = vi.fn() +const mockDeleteOpenCodeConfig = vi.fn() +const mockGetOpenCodeConfigByName = vi.fn() +const mockSetDefaultOpenCodeConfig = vi.fn() + vi.mock('fs', () => ({ existsSync: vi.fn(() => false), promises: { @@ -36,8 +45,14 @@ vi.mock('../../src/constants', () => ({ vi.mock('../../src/services/settings', () => ({ SettingsService: vi.fn().mockImplementation(() => ({ - getSettings: vi.fn(), - updateSettings: vi.fn(), + getSettings: mockGetSettings, + updateSettings: mockUpdateSettings, + saveLastKnownGoodConfig: mockSaveLastKnownGoodConfig, + createOpenCodeConfig: mockCreateOpenCodeConfig, + updateOpenCodeConfig: mockUpdateOpenCodeConfig, + deleteOpenCodeConfig: mockDeleteOpenCodeConfig, + getOpenCodeConfigByName: mockGetOpenCodeConfigByName, + setDefaultOpenCodeConfig: mockSetDefaultOpenCodeConfig, })), })) @@ -52,17 +67,54 @@ vi.mock('../../src/services/proxy', () => ({ proxyToOpenCodeWithDirectory: vi.fn(), })) -vi.mock('../../src/services/opencode-single-server', () => ({ - opencodeServerManager: { - getVersion: vi.fn(), - fetchVersion: vi.fn(), - reloadConfig: vi.fn(), - restart: vi.fn(), - clearStartupError: vi.fn(), - getLastStartupError: vi.fn(), - setDatabase: vi.fn(), - reinitializeBinDirectory: vi.fn(), +vi.mock('../../src/services/opencode-single-server', async (importOriginal) => { + const actual = await importOriginal() + + class MockConfigReloadError extends Error { + validationIssues: Array<{ path: string; message: string }> + removedFields: string[] + + constructor(message: string, validationIssues: Array<{ path: string; message: string }> = [], removedFields: string[] = []) { + super(message) + this.name = 'ConfigReloadError' + this.validationIssues = validationIssues + this.removedFields = removedFields + } + } + + return { + ...actual, + opencodeServerManager: { + getVersion: vi.fn(), + fetchVersion: vi.fn(), + reloadConfig: vi.fn(), + restart: vi.fn(), + clearStartupError: vi.fn(), + getLastStartupError: vi.fn(), + setDatabase: vi.fn(), + reinitializeBinDirectory: vi.fn(), + }, + ConfigReloadError: MockConfigReloadError, + } +}) + +vi.mock('../../src/services/opencode-import', () => ({ + OpenCodeImportProtectionError: class OpenCodeImportProtectionError extends Error { + code = 'OPENCODE_IMPORT_PROTECTED' + detail: string + + constructor(detail: string) { + super('OpenCode host import was blocked to protect existing workspace state') + this.detail = detail + } }, + getOpenCodeImportStatus: vi.fn(), + syncOpenCodeImport: vi.fn(), + getImportedSessionDirectories: vi.fn(), +})) + +vi.mock('../../src/services/repo', () => ({ + relinkReposFromSessionDirectories: vi.fn(), })) vi.mock('@opencode-manager/shared/config/env', () => ({ @@ -90,7 +142,11 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ })) import { createSettingsRoutes } from '../../src/routes/settings' -import { opencodeServerManager } from '../../src/services/opencode-single-server' +import { writeFileContent } from '../../src/services/file-operations' +import { getImportedSessionDirectories, getOpenCodeImportStatus, OpenCodeImportProtectionError, syncOpenCodeImport } from '../../src/services/opencode-import' +import { relinkReposFromSessionDirectories } from '../../src/services/repo' +import { opencodeServerManager, ConfigReloadError } from '../../src/services/opencode-single-server' +import { patchOpenCodeConfig } from '../../src/services/proxy' const mockExecSync = execSync as ReturnType const mockSpawnSync = spawnSync as ReturnType @@ -99,6 +155,12 @@ const mockFetchVersion = opencodeServerManager.fetchVersion as ReturnType const mockRestart = opencodeServerManager.restart as ReturnType const mockClearStartupError = opencodeServerManager.clearStartupError as ReturnType +const mockGetOpenCodeImportStatus = getOpenCodeImportStatus as ReturnType +const mockSyncOpenCodeImport = syncOpenCodeImport as ReturnType +const mockGetImportedSessionDirectories = getImportedSessionDirectories as ReturnType +const mockRelinkReposFromSessionDirectories = relinkReposFromSessionDirectories as ReturnType +const mockWriteFileContent = writeFileContent as ReturnType +const mockPatchOpenCodeConfig = patchOpenCodeConfig as ReturnType describe('Settings Routes - OpenCode Upgrade', () => { let settingsApp: ReturnType @@ -112,13 +174,379 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockReloadConfig.mockReset() mockRestart.mockReset() mockClearStartupError.mockReset() + mockGetSettings.mockReset() + mockUpdateSettings.mockReset() + mockSaveLastKnownGoodConfig.mockReset() + mockCreateOpenCodeConfig.mockReset() + mockUpdateOpenCodeConfig.mockReset() + mockDeleteOpenCodeConfig.mockReset() + mockGetOpenCodeConfigByName.mockReset() + mockSetDefaultOpenCodeConfig.mockReset() + mockGetOpenCodeImportStatus.mockReset() + mockSyncOpenCodeImport.mockReset() + mockGetImportedSessionDirectories.mockReset() + mockRelinkReposFromSessionDirectories.mockReset() + mockWriteFileContent.mockReset() + mockPatchOpenCodeConfig.mockReset() testDb = {} as any - settingsApp = createSettingsRoutes(testDb) + settingsApp = createSettingsRoutes(testDb, { getGitEnvironment: vi.fn().mockReturnValue({}) } as any) mockReloadConfig.mockResolvedValue(undefined) mockRestart.mockResolvedValue(undefined) mockClearStartupError.mockReturnValue(undefined) + mockPatchOpenCodeConfig.mockResolvedValue({ success: true, appliedConfig: { $schema: 'https://opencode.ai/config.json' } }) + mockWriteFileContent.mockResolvedValue(undefined) + mockGetOpenCodeImportStatus.mockResolvedValue({ + configSourcePath: null, + stateSourcePath: null, + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: false, + }) + mockGetImportedSessionDirectories.mockResolvedValue({ + directories: ['/Users/test/project-a', '/Users/test/project-b/apps/web'], + }) + mockRelinkReposFromSessionDirectories.mockResolvedValue({ + repos: [], + relinkedCount: 0, + existingCount: 0, + nonRepoPathCount: 0, + duplicatePathCount: 0, + errors: [], + }) + }) + + describe('OpenCode config routes', () => { + it('should reject create-as-default when runtime validation fails', async () => { + mockCreateOpenCodeConfig.mockReturnValue({ + id: 1, + name: 'broken', + content: { command: { review: true } }, + rawContent: '{"command":{"review":true}}', + isValid: true, + isDefault: false, + createdAt: 1, + updatedAt: 1, + }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ + success: false, + error: 'command.review: Invalid field', + details: [{ path: 'command.review', message: 'Invalid field' }], + }) + + const req = new Request('http://localhost/opencode-configs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'broken', + content: '{"command":{"review":true}}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(400) + expect(json.error).toBe('Config validation failed') + expect(mockSaveLastKnownGoodConfig).toHaveBeenCalledWith('default') + expect(mockCreateOpenCodeConfig).toHaveBeenCalledWith( + { + name: 'broken', + content: '{"command":{"review":true}}', + isDefault: false, + }, + 'default', + { suppressAutoDefault: true } + ) + expect(mockDeleteOpenCodeConfig).toHaveBeenCalledWith('broken', 'default') + expect(mockSetDefaultOpenCodeConfig).not.toHaveBeenCalled() + expect(mockWriteFileContent).not.toHaveBeenCalled() + }) + + it('should persist sanitized content before marking a new config as default', async () => { + mockCreateOpenCodeConfig.mockReturnValue({ + id: 1, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: false, + createdAt: 1, + updatedAt: 1, + }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + details: [{ path: 'command.review', message: 'Invalid field' }], + }) + mockUpdateOpenCodeConfig.mockReturnValue({ + id: 1, + name: 'cleaned', + content: { theme: 'dark' }, + rawContent: '{\n "theme": "dark"\n}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 2, + }) + + const req = new Request('http://localhost/opencode-configs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'cleaned', + content: '{"command":{"review":true},"theme":"dark"}', + isDefault: true, + }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith( + 'cleaned', + { + content: '{\n "theme": "dark"\n}', + isDefault: true, + }, + 'default' + ) + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/test-workspace/.config/opencode.json', + '{\n "theme": "dark"\n}' + ) + expect(json.removedFields).toEqual(['command.review']) + }) + + it('should reject set-default when runtime validation fails', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'broken', + content: { command: { review: true } }, + rawContent: '{"command":{"review":true}}', + isValid: true, + isDefault: false, + createdAt: 1, + updatedAt: 1, + }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ + success: false, + error: 'command.review: Invalid field', + details: [{ path: 'command.review', message: 'Invalid field' }], + }) + + const req = new Request('http://localhost/opencode-configs/broken/set-default', { + method: 'POST', + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(400) + expect(json.error).toBe('Config validation failed') + expect(mockSetDefaultOpenCodeConfig).not.toHaveBeenCalled() + expect(mockWriteFileContent).not.toHaveBeenCalled() + }) + + it('should sanitize existing config content before switching the default flag', async () => { + mockGetOpenCodeConfigByName.mockReturnValue({ + id: 2, + name: 'cleaned', + content: { command: { review: true }, theme: 'dark' }, + rawContent: '{"command":{"review":true},"theme":"dark"}', + isValid: true, + isDefault: false, + createdAt: 1, + updatedAt: 1, + }) + mockPatchOpenCodeConfig.mockResolvedValueOnce({ + success: true, + appliedConfig: { theme: 'dark' }, + removedFields: ['command.review'], + details: [{ path: 'command.review', message: 'Invalid field' }], + }) + mockUpdateOpenCodeConfig.mockReturnValue({ + id: 2, + name: 'cleaned', + content: { theme: 'dark' }, + rawContent: '{\n "theme": "dark"\n}', + isValid: true, + isDefault: false, + createdAt: 1, + updatedAt: 2, + }) + mockSetDefaultOpenCodeConfig.mockReturnValue({ + id: 2, + name: 'cleaned', + content: { theme: 'dark' }, + rawContent: '{\n "theme": "dark"\n}', + isValid: true, + isDefault: true, + createdAt: 1, + updatedAt: 3, + }) + + const req = new Request('http://localhost/opencode-configs/cleaned/set-default', { + method: 'POST', + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + const updateCallOrder = mockUpdateOpenCodeConfig.mock.invocationCallOrder[0] + const setDefaultCallOrder = mockSetDefaultOpenCodeConfig.mock.invocationCallOrder[0] + + expect(res.status).toBe(200) + expect(mockUpdateOpenCodeConfig).toHaveBeenCalledWith( + 'cleaned', + { content: '{\n "theme": "dark"\n}' }, + 'default' + ) + expect(updateCallOrder).toBeDefined() + expect(setDefaultCallOrder).toBeDefined() + expect(updateCallOrder ?? 0).toBeLessThan(setDefaultCallOrder ?? 0) + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/test-workspace/.config/opencode.json', + '{\n "theme": "dark"\n}' + ) + expect(json.removedFields).toEqual(['command.review']) + }) + }) + + describe('OpenCode import routes', () => { + it('should return import status', async () => { + mockGetOpenCodeImportStatus.mockResolvedValueOnce({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + }) + + const req = new Request('http://localhost/opencode-import/status') + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.configSourcePath).toBe('/import/opencode-config/opencode.json') + expect(json.stateSourcePath).toBe('/import/opencode-state') + expect(mockGetOpenCodeImportStatus).toHaveBeenCalled() + }) + + it('should import host OpenCode data and restart the server', async () => { + mockSyncOpenCodeImport.mockResolvedValueOnce({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + configImported: true, + stateImported: true, + }) + + const req = new Request('http://localhost/opencode-import?userId=default', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState: true }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.serverRestarted).toBe(true) + expect(mockSyncOpenCodeImport).toHaveBeenCalledWith({ + db: testDb, + userId: 'default', + overwriteState: true, + protectExistingState: true, + }) + expect(mockGetImportedSessionDirectories).toHaveBeenCalledWith('/tmp/test-workspace/.opencode/state/opencode') + expect(mockRelinkReposFromSessionDirectories).toHaveBeenCalled() + expect(mockClearStartupError).toHaveBeenCalled() + expect(mockRestart).toHaveBeenCalled() + }) + + it('should return 404 when no importable host data exists', async () => { + mockSyncOpenCodeImport.mockResolvedValueOnce({ + configSourcePath: null, + stateSourcePath: null, + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: true, + configImported: false, + stateImported: false, + }) + + const req = new Request('http://localhost/opencode-import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState: true }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(404) + expect(json.error).toBe('No importable OpenCode host data found') + expect(mockRestart).not.toHaveBeenCalled() + }) + + it('should return 409 when import is blocked to protect workspace state', async () => { + mockSyncOpenCodeImport.mockRejectedValueOnce( + new OpenCodeImportProtectionError('Workspace state already exists and must be cleared before import') + ) + + const req = new Request('http://localhost/opencode-import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(409) + expect(json.error).toBe('OpenCode host import was blocked to protect existing workspace state') + expect(json.code).toBe('OPENCODE_IMPORT_PROTECTED') + expect(json.detail).toBe('Workspace state already exists and must be cleared before import') + expect(mockRestart).not.toHaveBeenCalled() + }) + + it('should not call relink functions when only config is imported (stateImported: false)', async () => { + mockSyncOpenCodeImport.mockResolvedValueOnce({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/test-workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/test-workspace/.opencode/state/opencode', + workspaceStateExists: false, + configImported: true, + stateImported: false, + }) + + const req = new Request('http://localhost/opencode-import?userId=default', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState: true }), + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.serverRestarted).toBe(true) + expect(json.configImported).toBe(true) + expect(json.stateImported).toBe(false) + expect(mockGetImportedSessionDirectories).not.toHaveBeenCalled() + expect(mockRelinkReposFromSessionDirectories).not.toHaveBeenCalled() + expect(mockClearStartupError).toHaveBeenCalled() + expect(mockRestart).toHaveBeenCalled() + expect(json.relinkedRepos).toEqual({ + repos: [], + relinkedCount: 0, + existingCount: 0, + nonRepoPathCount: 0, + duplicatePathCount: 0, + errors: [], + }) + }) }) describe('POST /opencode-upgrade', () => { @@ -507,4 +935,86 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.recovered).toBe(true) }) }) + + describe('POST /opencode-reload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockReloadConfig.mockReset() + mockRestart.mockReset() + mockClearStartupError.mockReset() + mockReloadConfig.mockResolvedValue(undefined) + mockRestart.mockResolvedValue(undefined) + mockClearStartupError.mockReturnValue(undefined) + }) + + it('should return success when reload succeeds', async () => { + mockReloadConfig.mockResolvedValueOnce(undefined) + + const req = new Request('http://localhost/opencode-reload', { + method: 'POST' + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(200) + expect(json.success).toBe(true) + expect(json.message).toBe('OpenCode configuration reloaded successfully') + }) + + it('should propagate validationIssues and removedFields when ConfigReloadError is thrown', async () => { + const validationIssues = [ + { path: 'command.review', message: 'Invalid field' }, + { path: 'agent.temperature', message: 'Temperature out of range' } + ] + const removedFields = ['command.review'] + + mockReloadConfig.mockRejectedValueOnce( + new ConfigReloadError('Config validation failed', validationIssues, removedFields) + ) + + const req = new Request('http://localhost/opencode-reload', { + method: 'POST' + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(500) + expect(json.error).toBe('Config validation failed') + expect(json.details).toBe('command.review: Invalid field; agent.temperature: Temperature out of range') + expect(json.validationIssues).toEqual(validationIssues) + expect(json.removedFields).toEqual(removedFields) + }) + + it('should return generic error when non-ConfigReloadError is thrown', async () => { + mockReloadConfig.mockRejectedValueOnce(new Error('Some other error')) + + const req = new Request('http://localhost/opencode-reload', { + method: 'POST' + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(500) + expect(json.error).toBe('Failed to reload OpenCode configuration') + expect(json.details).toBe('Some other error') + }) + + it('should propagate empty arrays when ConfigReloadError has no issues', async () => { + mockReloadConfig.mockRejectedValueOnce( + new ConfigReloadError('Reload failed', [], []) + ) + + const req = new Request('http://localhost/opencode-reload', { + method: 'POST' + }) + const res = await settingsApp.fetch(req) + const json = await res.json() as Record + + expect(res.status).toBe(500) + expect(json.error).toBe('Reload failed') + expect(json.details).toBe('Reload failed') + expect(json.validationIssues).toEqual([]) + expect(json.removedFields).toEqual([]) + }) + }) }) diff --git a/backend/test/routes/title.test.ts b/backend/test/routes/title.test.ts deleted file mode 100644 index 4bcc4761..00000000 --- a/backend/test/routes/title.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - resolveOpenCodeModel: vi.fn(), - fetch: vi.fn(), - sleep: vi.fn(), -})) - -vi.mock('../../src/services/opencode-models', () => ({ - resolveOpenCodeModel: mocks.resolveOpenCodeModel, -})) - -vi.mock('../../src/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - ENV: { - OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, - }, -})) - -import { createTitleRoutes } from '../../src/routes/title' - -function jsonResponse(body: unknown, status: number = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }) -} - -function textResponse(body: string, status: number = 200): Response { - return new Response(body, { status }) -} - -describe('Title Routes', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.resolveOpenCodeModel.mockResolvedValue({ providerID: 'openai', modelID: 'gpt-5-mini' }) - vi.stubGlobal('fetch', mocks.fetch) - vi.stubGlobal('Bun', { sleep: mocks.sleep }) - mocks.sleep.mockResolvedValue(undefined) - }) - - it('updates the session title from an immediate JSON prompt response', async () => { - const app = createTitleRoutes() - - mocks.fetch - .mockResolvedValueOnce(jsonResponse({ id: 'title-session-1' })) - .mockResolvedValueOnce(textResponse(JSON.stringify({ - parts: [{ type: 'text', text: 'Refactoring background jobs' }], - }))) - .mockResolvedValueOnce(textResponse('', 200)) - .mockResolvedValueOnce(textResponse('', 200)) - - const response = await app.request('http://localhost/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - directory: '/workspace/repos/sample-project', - }, - body: JSON.stringify({ - text: 'Refactor recurring background jobs and improve observability.', - sessionID: 'session-main-1', - }), - }) - const body = await response.json() as { title: string } - - expect(response.status).toBe(200) - expect(body.title).toBe('Refactoring background jobs') - expect(mocks.fetch).toHaveBeenCalledWith( - 'http://127.0.0.1:5551/session/session-main-1?directory=%2Fworkspace%2Frepos%2Fsample-project', - expect.objectContaining({ method: 'PATCH' }), - ) - }) - - it('polls session messages when the prompt endpoint returns an empty body', async () => { - const app = createTitleRoutes() - - mocks.fetch - .mockResolvedValueOnce(jsonResponse({ id: 'title-session-2' })) - .mockResolvedValueOnce(textResponse('')) - .mockResolvedValueOnce(jsonResponse([ - { - info: { role: 'assistant', time: { completed: Date.now() } }, - parts: [{ type: 'text', text: 'Analyzing recurring job setup' }], - }, - ])) - .mockResolvedValueOnce(textResponse('', 200)) - .mockResolvedValueOnce(textResponse('', 200)) - - const response = await app.request('http://localhost/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - directory: '/workspace/repos/sample-project', - }, - body: JSON.stringify({ - text: 'Why do recurring jobs stop after a restart?', - sessionID: 'session-main-2', - }), - }) - const body = await response.json() as { title: string } - - expect(response.status).toBe(200) - expect(body.title).toBe('Analyzing recurring job setup') - expect(mocks.fetch).toHaveBeenNthCalledWith( - 3, - 'http://127.0.0.1:5551/session/title-session-2/message?directory=%2Fworkspace%2Frepos%2Fsample-project', - ) - }) - - it('returns a 500 when the polled assistant message reports an error', async () => { - const app = createTitleRoutes() - - mocks.fetch - .mockResolvedValueOnce(jsonResponse({ id: 'title-session-3' })) - .mockResolvedValueOnce(textResponse('')) - .mockResolvedValueOnce(jsonResponse([ - { - info: { - role: 'assistant', - error: { data: { message: 'Model unavailable' } }, - }, - parts: [], - }, - ])) - .mockResolvedValueOnce(textResponse('', 200)) - - const response = await app.request('http://localhost/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - directory: '/workspace/repos/sample-project', - }, - body: JSON.stringify({ - text: 'Generate a title for this broken run.', - sessionID: 'session-main-3', - }), - }) - const body = await response.json() as { error: string } - - expect(response.status).toBe(500) - expect(body.error).toBe('Model unavailable') - }) -}) diff --git a/backend/test/services/opencode-import.test.ts b/backend/test/services/opencode-import.test.ts new file mode 100644 index 00000000..47952e9a --- /dev/null +++ b/backend/test/services/opencode-import.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Database } from 'bun:sqlite' + +vi.mock('fs/promises', () => ({ + cp: vi.fn(), + mkdtemp: vi.fn(), + readdir: vi.fn(), + rename: vi.fn(), + rm: vi.fn(), +})) + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn().mockImplementation(() => ({ + exec: vi.fn(), + close: vi.fn(), + })), +})) + +vi.mock('../../src/services/file-operations', () => ({ + ensureDirectoryExists: vi.fn(), + fileExists: vi.fn(), + readFileContent: vi.fn(), + writeFileContent: vi.fn(), +})) + +vi.mock('../../src/services/settings', () => ({ + SettingsService: vi.fn(), +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getOpenCodeConfigFilePath: vi.fn(() => '/tmp/workspace/.config/opencode/opencode.json'), + getWorkspacePath: vi.fn(() => '/tmp/workspace'), +})) + +import path from 'path' +import { readdir, rm, cp, mkdtemp, rename } from 'fs/promises' +import { Database as SQLiteDatabase } from 'bun:sqlite' +import { ensureDirectoryExists, fileExists, readFileContent, writeFileContent } from '../../src/services/file-operations' +import { SettingsService } from '../../src/services/settings' +import { getOpenCodeImportStatus, syncOpenCodeImport } from '../../src/services/opencode-import' + +const mockReaddir = readdir as unknown as ReturnType +const mockFileExists = fileExists as ReturnType +const mockReadFileContent = readFileContent as ReturnType +const mockWriteFileContent = writeFileContent as ReturnType +const mockEnsureDirectoryExists = ensureDirectoryExists as ReturnType +const MockSettingsService = SettingsService as unknown as ReturnType +const MockSQLiteDatabase = SQLiteDatabase as unknown as ReturnType +const mockMkdtemp = mkdtemp as unknown as ReturnType +const mockRename = rename as unknown as ReturnType + +describe('opencode-import service', () => { + const mockDb = {} as unknown as Database + const settingsService = { + getOpenCodeConfigByName: vi.fn(), + updateOpenCodeConfig: vi.fn(), + createOpenCodeConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + MockSettingsService.mockImplementation(() => settingsService) + mockReadFileContent.mockResolvedValue('{"$schema":"https://opencode.ai/config.json"}') + mockReaddir.mockResolvedValue([]) + mockMkdtemp.mockResolvedValue('/tmp/workspace/.opencode/state/opencode-import-123') + mockRename.mockResolvedValue(undefined) + }) + + it('detects importable host config and state paths with opencode.db', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + if (candidate === '/import/opencode-config/opencode.json') { + return true + } + if (candidate === '/import/opencode-state') { + return true + } + if (candidate === '/import/opencode-state/opencode.db') { + return true + } + if (candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db') { + return true + } + return false + }) + + const status = await getOpenCodeImportStatus() + + expect(status).toEqual({ + configSourcePath: '/import/opencode-config/opencode.json', + stateSourcePath: '/import/opencode-state', + workspaceConfigPath: '/tmp/workspace/.config/opencode/opencode.json', + workspaceStatePath: '/tmp/workspace/.opencode/state/opencode', + workspaceStateExists: true, + }) + }) + + it('imports host config and state into the workspace', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + || candidate === '/import/opencode-state/opencode.db' + || candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db' + }) + + settingsService.getOpenCodeConfigByName.mockReturnValue({ name: 'default' }) + + const result = await syncOpenCodeImport({ + db: mockDb, + userId: 'default', + overwriteState: true, + }) + + expect(result.configImported).toBe(true) + expect(result.stateImported).toBe(true) + expect(result.workspaceStateExists).toBe(true) + expect(settingsService.updateOpenCodeConfig).toHaveBeenCalledWith('default', { + content: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + }, 'default') + expect(mockWriteFileContent).toHaveBeenCalledWith( + '/tmp/workspace/.config/opencode/opencode.json', + '{"$schema":"https://opencode.ai/config.json"}' + ) + expect(mockEnsureDirectoryExists).toHaveBeenCalledWith('/tmp/workspace/.opencode/state') + expect(MockSQLiteDatabase).toHaveBeenCalledWith('/import/opencode-state/opencode.db') + expect(mockRename).toHaveBeenCalledWith( + '/tmp/workspace/.opencode/state/opencode-import-123', + '/tmp/workspace/.opencode/state/opencode' + ) + }) + + it('does not report state imported when source db is missing', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + }) + + const result = await syncOpenCodeImport({ + db: mockDb, + userId: 'default', + overwriteState: true, + }) + + expect(result.configImported).toBe(true) + expect(result.stateImported).toBe(false) + expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() + }) + + it('reads distinct session directories from imported workspace state', async () => { + mockFileExists.mockImplementation(async (candidate: string) => candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db') + + const readonlyDatabase = { + query: vi.fn().mockReturnValue({ + all: vi.fn().mockReturnValue([ + { directory: '/Users/test/project-a' }, + { directory: ' /Users/test/project-b/apps/web ' }, + ]), + }), + close: vi.fn(), + } + + MockSQLiteDatabase.mockImplementationOnce(() => readonlyDatabase) + + const { getImportedSessionDirectories } = await import('../../src/services/opencode-import') + const result = await getImportedSessionDirectories('/tmp/workspace/.opencode/state/opencode') + + expect(result.directories).toEqual([ + '/Users/test/project-a', + '/Users/test/project-b/apps/web', + ]) + expect(readonlyDatabase.close).toHaveBeenCalled() + }) + + it('reports stateSourcePath as null when candidate directory exists but lacks opencode.db', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + if (candidate === '/import/opencode-config/opencode.json') { + return true + } + if (candidate === '/import/opencode-state') { + return true + } + if (candidate === '/import/opencode-state/opencode.db') { + return false + } + if (candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db') { + return false + } + return false + }) + + const status = await getOpenCodeImportStatus() + + expect(status.configSourcePath).toBe('/import/opencode-config/opencode.json') + expect(status.stateSourcePath).toBeNull() + expect(status.workspaceStateExists).toBe(false) + }) + + it('prevents destructive self-import when source and target resolve to same directory', async () => { + const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') + + const samePath = '/shared/opencode-state' + mockFileExists.mockResolvedValue(true) + mockEnsureDirectoryExists.mockResolvedValue(undefined) + mockReaddir.mockResolvedValue([]) + + const mockRm = vi.mocked(rm) + mockRm.mockResolvedValue(undefined) + + const result = await importOpenCodeStateDirectory(samePath, samePath) + + expect(result).toBe(false) + expect(mockRm).not.toHaveBeenCalled() + expect(MockSQLiteDatabase).not.toHaveBeenCalled() + }) + + it('blocks import when workspace state exists and overwriteState is not enabled', async () => { + process.env.OPENCODE_IMPORT_CONFIG_PATH = '/import/opencode-config/opencode.json' + process.env.OPENCODE_IMPORT_STATE_PATH = '/import/opencode-state' + + mockFileExists.mockImplementation(async (candidate: string) => { + return candidate === '/import/opencode-config/opencode.json' + || candidate === '/import/opencode-state' + || candidate === '/import/opencode-state/opencode.db' + || candidate === '/tmp/workspace/.opencode/state/opencode/opencode.db' + }) + + await expect(syncOpenCodeImport({ + db: mockDb, + userId: 'default', + overwriteState: false, + protectExistingState: true, + })).rejects.toThrow('OpenCode host import was blocked to protect existing workspace state') + + expect(settingsService.updateOpenCodeConfig).not.toHaveBeenCalled() + expect(mockEnsureDirectoryExists).not.toHaveBeenCalled() + }) + + it('stages imported state before replacing the target directory', async () => { + const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') + + const sourcePath = '/import/opencode-state' + const targetPath = '/workspace/.opencode/state/opencode' + const stagedPath = '/workspace/.opencode/state/opencode-import-123' + + mockFileExists.mockImplementation(async (candidate: string) => { + if (candidate === path.join(sourcePath, 'opencode.db')) { + return true + } + return false + }) + + mockEnsureDirectoryExists.mockResolvedValue(undefined) + mockReaddir.mockResolvedValue([ + { name: 'stale-file.txt', isDirectory: () => false }, + { name: 'opencode.db', isDirectory: () => false }, + ] as any) + const mockRm = vi.mocked(rm) + mockRm.mockResolvedValue(undefined) + mockMkdtemp.mockResolvedValue(stagedPath) + + const mockCp = vi.mocked(cp) + mockCp.mockResolvedValue(undefined) + + const mockExec = vi.fn() + const mockClose = vi.fn() + MockSQLiteDatabase.mockImplementationOnce(() => ({ + exec: mockExec, + close: mockClose, + })) + + const result = await importOpenCodeStateDirectory(sourcePath, targetPath) + + expect(result).toBe(true) + expect(mockCp).toHaveBeenCalledWith( + path.join(sourcePath, 'stale-file.txt'), + path.join(stagedPath, 'stale-file.txt'), + expect.any(Object) + ) + expect(mockExec).toHaveBeenCalled() + expect(mockRm).toHaveBeenCalledWith(targetPath, { recursive: true, force: true }) + expect(mockRename).toHaveBeenCalledWith(stagedPath, targetPath) + }) + + it('cleans up the staged import directory when snapshotting fails', async () => { + const { importOpenCodeStateDirectory } = await import('../../src/services/opencode-import') + + const sourcePath = '/import/opencode-state' + const targetPath = '/workspace/.opencode/state/opencode' + const stagedPath = '/workspace/.opencode/state/opencode-import-456' + + mockFileExists.mockImplementation(async (candidate: string) => candidate === path.join(sourcePath, 'opencode.db')) + mockEnsureDirectoryExists.mockResolvedValue(undefined) + mockMkdtemp.mockResolvedValue(stagedPath) + + const mockExec = vi.fn(() => { + throw new Error('snapshot failed') + }) + const mockClose = vi.fn() + MockSQLiteDatabase.mockImplementationOnce(() => ({ + exec: mockExec, + close: mockClose, + })) + + const mockRm = vi.mocked(rm) + mockRm.mockResolvedValue(undefined) + + await expect(importOpenCodeStateDirectory(sourcePath, targetPath)).rejects.toThrow('snapshot failed') + + expect(mockRm).toHaveBeenCalledWith(stagedPath, { recursive: true, force: true }) + expect(mockRm).not.toHaveBeenCalledWith(targetPath, { recursive: true, force: true }) + expect(mockRename).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/opencode-models.test.ts b/backend/test/services/opencode-models.test.ts index 1eb4088c..c56eb1a7 100644 --- a/backend/test/services/opencode-models.test.ts +++ b/backend/test/services/opencode-models.test.ts @@ -100,6 +100,90 @@ describe('resolveOpenCodeModel', () => { }) }) + it('falls back to config.model when small_model is unavailable', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ + model: 'openai/gpt-5', + small_model: 'openai/gpt-5-unavailable', + })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5': {}, 'gpt-5-mini': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferSmallModel: true, + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5', + model: 'openai/gpt-5', + }) + }) + + it('falls back to provider default only after all configured candidates fail', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ + model: 'openai/gpt-5-configured', + small_model: 'openai/gpt-5-small-unavailable', + })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {}, 'gpt-5-configured': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferSmallModel: true, + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5-configured', + model: 'openai/gpt-5-configured', + }) + }) + + it('falls back to provider default when both small_model and model are unavailable', async () => { + proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { + if (path === '/config') { + return Promise.resolve(jsonResponse({ + model: 'openai/gpt-5-unavailable', + small_model: 'openai/gpt-5-also-unavailable', + })) + } + + return Promise.resolve(jsonResponse({ + providers: [ + { id: 'openai', models: { 'gpt-5-mini': {}, 'gpt-5-turbo': {} } }, + ], + default: { openai: 'gpt-5-mini' }, + })) + }) + + const result = await resolveOpenCodeModel('/workspace/repos/sample-project', { + preferSmallModel: true, + }) + + expect(result).toEqual({ + providerID: 'openai', + modelID: 'gpt-5-mini', + model: 'openai/gpt-5-mini', + }) + }) + it('falls back to the first available model when defaults are missing', async () => { proxyToOpenCodeWithDirectory.mockImplementation((path: string) => { if (path === '/config') { diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index 808926bc..c291caf1 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -46,13 +46,19 @@ vi.mock('child_process', () => ({ execSync: vi.fn(), })) +vi.mock('../../src/services/proxy', () => ({ + patchOpenCodeConfig: vi.fn(), +})) + import { promises as fs } from 'fs' import { execSync } from 'child_process' +import { ConfigReloadError } from '../../src/services/opencode-single-server' vi.mock('../../src/utils/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), + warn: vi.fn(), }, })) @@ -210,3 +216,52 @@ describe('OpenCodeServerManager - reinitializeBinDirectory', () => { }) }) }) + +describe('ConfigReloadError', () => { + it('should create error with validation issues and removed fields', () => { + const issues = [{ path: 'command.review', message: 'Invalid' }] + const removed = ['command.review'] + const error = new ConfigReloadError('Test error', issues, removed) + + expect(error.name).toBe('ConfigReloadError') + expect(error.message).toBe('Test error') + expect(error.validationIssues).toEqual(issues) + expect(error.removedFields).toEqual(removed) + }) + + it('should default to empty arrays for issues and removed fields', () => { + const error = new ConfigReloadError('Test error') + + expect(error.validationIssues).toEqual([]) + expect(error.removedFields).toEqual([]) + }) +}) + +describe('OpenCodeServerManager - reloadConfig', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('should read config from file before patching', async () => { + const mockReadFile = vi.fn().mockResolvedValue(JSON.stringify({ command: { review: 'test' } })) + fs.readFile = mockReadFile + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const mockPatchResult = { success: true } + vi.mocked(patchOpenCodeConfig).mockResolvedValue(mockPatchResult) + + const { opencodeServerManager } = await import('../../src/services/opencode-single-server') + + await opencodeServerManager.reloadConfig() + + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining('.config/opencode.json'), + 'utf-8' + ) + expect(patchOpenCodeConfig).toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/proxy.test.ts b/backend/test/services/proxy.test.ts new file mode 100644 index 00000000..74093bb7 --- /dev/null +++ b/backend/test/services/proxy.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn(), +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getWorkspacePath: vi.fn(() => '/test/workspace'), + getOpenCodeConfigFilePath: vi.fn(() => '/test/workspace/.config/opencode.json'), + getReposPath: vi.fn(() => '/test/workspace/repos'), + getAgentsMdPath: vi.fn(() => '/test/workspace/AGENTS.md'), + getDatabasePath: vi.fn(() => ':memory:'), + getConfigPath: vi.fn(() => '/test/workspace/config'), + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, + WORKSPACE: { BASE_PATH: '/test/workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, + OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, + DATABASE: { PATH: ':memory:' }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, + }, + FILE_LIMITS: { + MAX_SIZE_BYTES: 1024 * 1024, + MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, + }, +})) + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + rm: vi.fn(), + readdir: vi.fn(), + }, +})) + +vi.mock('child_process', () => ({ + execSync: vi.fn(), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +const createMockFetch = (response: Response) => { + return vi.fn().mockResolvedValue(response) as unknown as typeof fetch +} + +describe('proxy service', () => { + describe('patchOpenCodeConfig', () => { + const originalFetch = global.fetch + + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + describe('when OpenCode returns 400 with structured errors', () => { + it('should parse errors array and extract path and message', async () => { + const mockResponse = { + success: false, + data: { command: { review: 'some value' } }, + errors: [ + { path: ['command', 'review'], message: 'Invalid command review field' }, + { path: ['agent', 'temperature'], message: 'Temperature must be between 0 and 2' } + ] + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(mockResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.details).toHaveLength(2) + expect(result.details?.[0]).toEqual({ path: 'command.review', message: 'Invalid command review field' }) + expect(result.details?.[1]).toEqual({ path: 'agent.temperature', message: 'Temperature must be between 0 and 2' }) + expect(result.error).toBe('command.review: Invalid command review field; agent.temperature: Temperature must be between 0 and 2') + }) + + it('should handle errors with string path format', async () => { + const mockResponse = { + success: false, + data: {}, + errors: [ + { path: 'command.review', message: 'Invalid field' } + ] + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(mockResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.details).toHaveLength(1) + expect(result.details?.[0]).toEqual({ path: 'command.review', message: 'Invalid field' }) + }) + + it('should handle errors with missing path (defaults to root)', async () => { + const mockResponse = { + success: false, + data: {}, + errors: [ + { message: 'Configuration is invalid' } + ] + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(mockResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({}) + + expect(result.success).toBe(false) + expect(result.details).toHaveLength(1) + expect(result.details?.[0]).toEqual({ path: 'root', message: 'Configuration is invalid' }) + }) + + it('should parse nested data.issues from runtime validation payloads', async () => { + const mockResponse = { + success: false, + data: { + issues: [ + { path: ['command', 'review'], message: 'Invalid review command' }, + { path: ['provider', 'openai', 'models'], message: 'Expected object' } + ] + } + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(mockResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.details).toEqual([ + { path: 'command.review', message: 'Invalid review command' }, + { path: 'provider.openai.models', message: 'Expected object' } + ]) + expect(result.error).toBe('command.review: Invalid review command; provider.openai.models: Expected object') + }) + }) + + describe('when OpenCode returns 400 with only data (no errors)', () => { + it('should not use data as error message source', async () => { + const mockResponse = { + success: false, + data: { command: { review: 'some long value that should not be the error message' } } + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(mockResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.error).not.toContain('some long value') + expect(result.details).toEqual([]) + }) + }) + + describe('when OpenCode returns 400 with unstructured text', () => { + it('should create bounded fallback message without giant config blobs', async () => { + const longConfig = 'x'.repeat(1000) + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => longConfig + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ config: longConfig }) + + expect(result.success).toBe(false) + expect(result.error?.length).toBeLessThan(400) + expect(result.details).toEqual([]) + }) + + it('should handle JSON parse errors gracefully', async () => { + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => 'not valid json at all' + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({}) + + expect(result.success).toBe(false) + expect(result.error).toContain('Parse error') + }) + }) + + describe('retry logic with removable paths', () => { + it('should retry after removing a valid nested path like command.review', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Invalid field' } + ] + } + + let callCount = 0 + global.fetch = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { + ok: false, + status: 400, + text: async () => JSON.stringify(errorResponse) + } as unknown as Response + } + return { + ok: true, + text: async () => '{}' + } as unknown as Response + }) as unknown as typeof fetch + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ + command: { review: 'test', other: 'value' }, + agent: { name: 'test' } + }) + + expect(result.success).toBe(true) + expect(result.removedFields).toContain('command.review') + expect(result.details).toHaveLength(1) + }) + + it('should not retry if any path is non-removable (root level)', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['root'], message: 'Invalid configuration' } + ] + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(errorResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ invalid: 'config' }) + + expect(result.success).toBe(false) + expect(result.removedFields).toEqual(undefined) + expect(result.error).toContain('Invalid configuration') + }) + + it('should not retry if any path exceeds depth limit', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['a', 'b', 'c', 'd'], message: 'Too deep' } + ] + } + + global.fetch = createMockFetch({ + ok: false, + status: 400, + text: async () => JSON.stringify(errorResponse) + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ a: { b: { c: { d: 'value' } } } }) + + expect(result.success).toBe(false) + expect(result.removedFields).toEqual(undefined) + }) + }) + + describe('retry failure handling', () => { + it('should return retry-specific details when retry fails with structured errors', async () => { + const initialError = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Initial error' } + ] + } + + const retryError = { + success: false, + data: {}, + errors: [ + { path: ['agent'], message: 'Retry error - agent invalid' } + ] + } + + let callCount = 0 + global.fetch = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { + ok: false, + status: 400, + text: async () => JSON.stringify(initialError) + } as unknown as Response + } + return { + ok: false, + status: 400, + text: async () => JSON.stringify(retryError) + } as unknown as Response + }) as unknown as typeof fetch + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.removedFields).toContain('command.review') + expect(result.details).toHaveLength(1) + expect(result.details?.[0]?.message).toBe('Retry error - agent invalid') + expect(result.error).toContain('Retry error') + }) + + it('should fall back to initial details if retry response is unstructured', async () => { + const initialError = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Initial error' } + ] + } + + let callCount = 0 + global.fetch = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { + ok: false, + status: 400, + text: async () => JSON.stringify(initialError) + } as unknown as Response + } + return { + ok: false, + status: 500, + text: async () => 'Internal Server Error' + } as unknown as Response + }) as unknown as typeof fetch + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(false) + expect(result.removedFields).toContain('command.review') + expect(result.details?.[0]?.message).toBe('Initial error') + }) + }) + + describe('path deduplication', () => { + it('should deduplicate paths before removal', async () => { + const errorResponse = { + success: false, + data: {}, + errors: [ + { path: ['command', 'review'], message: 'Error 1' }, + { path: ['command', 'review'], message: 'Error 2' } + ] + } + + let callCount = 0 + global.fetch = vi.fn().mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { + ok: false, + status: 400, + text: async () => JSON.stringify(errorResponse) + } as unknown as Response + } + return { + ok: true, + text: async () => '{}' + } as unknown as Response + }) as unknown as typeof fetch + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(true) + expect(result.removedFields).toHaveLength(1) + expect(result.removedFields?.[0]).toBe('command.review') + }) + }) + + describe('successful patch without errors', () => { + it('should return success without details', async () => { + global.fetch = createMockFetch({ + ok: true, + text: async () => '{}' + } as Response) + + const { patchOpenCodeConfig } = await import('../../src/services/proxy') + const result = await patchOpenCodeConfig({ command: { review: 'test' } }) + + expect(result.success).toBe(true) + expect(result.error).toBeUndefined() + expect(result.details).toBeUndefined() + expect(result.removedFields).toBeUndefined() + }) + }) + }) +}) diff --git a/backend/test/services/repo.test.ts b/backend/test/services/repo.test.ts index 89a17177..8cde2b36 100644 --- a/backend/test/services/repo.test.ts +++ b/backend/test/services/repo.test.ts @@ -347,4 +347,76 @@ describe('repo service', () => { }, ]) }) + + it('relinks imported session directories to nearest git repo roots', async () => { + const { relinkReposFromSessionDirectories } = await import('../../src/services/repo') + const database = {} as never + const repoRoot = '/Users/test/projects/app-one' + const aliasPath = path.join(getReposPath(), 'app-one') + + getRepoByLocalPath.mockReturnValue(null) + getRepoBySourcePath.mockReturnValue(null) + createRepo.mockImplementation((_, input) => ({ + id: 5, + localPath: input.localPath, + sourcePath: input.sourcePath, + fullPath: input.sourcePath ?? path.join(getReposPath(), input.localPath), + branch: input.branch, + defaultBranch: input.defaultBranch, + cloneStatus: input.cloneStatus, + clonedAt: input.clonedAt, + isLocal: true, + isWorktree: input.isWorktree, + })) + + lstat.mockImplementation(async (targetPath: string) => { + if (targetPath === repoRoot || targetPath === path.join(repoRoot, '.git')) { + return createDirectoryStat() + } + + if (targetPath === aliasPath) { + throw createEnoentError(targetPath) + } + + throw createEnoentError(targetPath) + }) + + executeCommand.mockImplementation(async (args: string[]) => { + if (args.includes('--show-toplevel')) { + if (args[2] === '/Users/test/projects/not-a-repo') { + throw new Error('not a git repository') + } + return `${repoRoot}\n` + } + + if (args.includes('--git-dir')) { + return '.git' + } + + if (args.includes('HEAD') && !args.includes('--abbrev-ref')) { + return 'abc123' + } + + if (args.includes('--abbrev-ref')) { + return 'main' + } + + return '' + }) + + const result = await relinkReposFromSessionDirectories(database, mockGitAuthService, [ + '/Users/test/projects/app-one/apps/web', + '/Users/test/projects/app-one/packages/api', + '/Users/test/projects/not-a-repo', + ]) + + expect(result.relinkedCount).toBe(1) + expect(result.existingCount).toBe(0) + expect(result.nonRepoPathCount).toBe(1) + expect(result.duplicatePathCount).toBe(1) + expect(result.errors).toEqual([]) + expect(result.repos).toHaveLength(1) + expect(result.repos[0]?.fullPath).toBe(repoRoot) + expect(createRepo).toHaveBeenCalledTimes(1) + }) }) diff --git a/frontend/package.json b/frontend/package.json index 809c8e4b..0e6ef3a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,8 @@ "typecheck": "tsc --noEmit", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:run": "vitest run" }, "dependencies": { "@better-auth/passkey": "^1.4.17", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0957f7c..08ab738b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,12 +15,15 @@ import { Setup } from './pages/Setup' import { SettingsDialog } from './components/settings/SettingsDialog' import { VersionNotifier } from './components/VersionNotifier' import { PwaUpdatePrompt } from '@/components/PwaUpdatePrompt' +import { MobileTabBar } from '@/components/navigation/MobileTabBar' import { useTheme } from './hooks/useTheme' import { TTSProvider } from './contexts/TTSContext' import { AuthProvider } from './contexts/AuthContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' import { PermissionRequestDialog } from './components/session/PermissionRequestDialog' import { SSHHostKeyDialog } from './components/ssh/SSHHostKeyDialog' +import { PageTransition } from './components/ui/PageTransition' +import { useNavigationDirection } from './hooks/useNavigationDirection' import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib/auth-loaders' const queryClient = new QueryClient({ @@ -69,6 +72,7 @@ function PermissionDialogWrapper() { function AppShell() { const navigate = useNavigate() useTheme() + useNavigationDirection() useEffect(() => { const channel = new BroadcastChannel('notification-click') @@ -84,7 +88,10 @@ function AppShell() { return ( - + + + + diff --git a/frontend/src/api/fetchWrapper.ts b/frontend/src/api/fetchWrapper.ts index cfbf83dd..98e2d1ec 100644 --- a/frontend/src/api/fetchWrapper.ts +++ b/frontend/src/api/fetchWrapper.ts @@ -35,7 +35,12 @@ async function handleResponse(response: Response): Promise { data.error || 'Request failed', response.status, data.code, - detail + detail, + { + details: data.details, + validationIssues: data.validationIssues, + removedFields: data.removedFields, + } ) } diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index 6c389c7d..f0e62249 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -1,21 +1,14 @@ import { API_BASE_URL } from "@/config" +import type { components, operations } from "./opencode-types" import { fetchWrapper, FetchError } from "./fetchWrapper" -export interface OAuthAuthorizeResponse { - url: string - method: "code" - instructions: string -} +type OpenCodeAuthorizeRequest = NonNullable["content"]["application/json"] -export interface OAuthCallbackRequest { - method: number - code?: string -} +export type OAuthAuthorizeResponse = components["schemas"]["ProviderAuthAuthorization"] -export interface ProviderAuthMethod { - type: "oauth" | "api" - label: string -} +export type OAuthCallbackRequest = NonNullable["content"]["application/json"] + +export type ProviderAuthMethod = components["schemas"]["ProviderAuthMethod"] export interface ProviderAuthMethods { [providerId: string]: ProviderAuthMethod[] @@ -29,12 +22,12 @@ function handleApiError(error: unknown, context: string): never { } export const oauthApi = { - authorize: async (providerId: string, method: number): Promise => { + authorize: async (providerId: string, method: number, inputs?: OpenCodeAuthorizeRequest["inputs"]): Promise => { try { return await fetchWrapper(`${API_BASE_URL}/api/oauth/${providerId}/oauth/authorize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ method }), + body: JSON.stringify({ method, inputs }), }) } catch (error) { handleApiError(error, "OAuth authorization failed") diff --git a/frontend/src/api/opencode-spec.json b/frontend/src/api/opencode-spec.json index e6c39b59..c378eeb3 100644 --- a/frontend/src/api/opencode-spec.json +++ b/frontend/src/api/opencode-spec.json @@ -3552,16 +3552,22 @@ "requestBody": { "content": { "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - } - }, - "required": [ - "method" + "schema": { + "type": "object", + "properties": { + "method": { + "description": "Auth method index", + "type": "number" + }, + "inputs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "method" ] } } @@ -10399,6 +10405,90 @@ }, "label": { "type": "string" + }, + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + } + }, + "required": [ + "type", + "key", + "message" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "select" + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "required": [ + "type", + "key", + "message", + "options" + ] + } + ] + } } }, "required": [ @@ -10932,4 +11022,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/src/api/opencode-types.ts b/frontend/src/api/opencode-types.ts index 40884796..ff9bcca1 100644 --- a/frontend/src/api/opencode-types.ts +++ b/frontend/src/api/opencode-types.ts @@ -3379,6 +3379,24 @@ export interface components { ProviderAuthMethod: { type: "oauth" | "api"; label: string; + prompts?: ({ + type: "text"; + key: string; + message: string; + placeholder?: string; + } | { + type: "select"; + key: string; + message: string; + options: { + label: string; + value: string; + }[]; + when?: { + key: string; + value: string; + }; + })[]; }; ProviderAuthAuthorization: { url: string; @@ -5698,6 +5716,9 @@ export interface operations { "application/json": { /** @description Auth method index */ method: number; + inputs?: { + [key: string]: string; + }; }; }; }; diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index d43a0310..e374dcb3 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -62,6 +62,7 @@ export interface OpenCodeProvider { export interface Model { id: string; + key?: string; name: string; release_date?: string; attachment?: boolean; @@ -162,7 +163,8 @@ async function getProvidersFromOpenCodeServer(): Promise<{ providers: Provider[] Object.entries(openCodeProvider.models).forEach(([modelId, openCodeModel]) => { models[modelId] = { - id: modelId, + id: openCodeModel.api.id || modelId, + key: modelId, name: openCodeModel.name, attachment: openCodeModel.capabilities.attachment, reasoning: openCodeModel.capabilities.reasoning, @@ -235,7 +237,8 @@ async function getConfiguredProviders(connectedIds: Set): Promise { .map((provider) => { const models = Object.entries(provider.models || {}).map(([id, model]) => ({ ...model, - id: id, + id: model.id || id, + key: id, name: model.name || id, })); return { diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index df7f5f43..88a4d218 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -157,3 +157,9 @@ export async function resetRepoPermissions(id: number): Promise { method: 'POST', }) } + +export async function touchRepoActivity(id: number): Promise { + return fetchWrapperVoid(`${API_BASE_URL}/api/repos/${id}/access`, { + method: 'POST', + }) +} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index bcda5bd3..1a84d4f8 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -5,6 +5,8 @@ import type { OpenCodeConfigResponse, CreateOpenCodeConfigRequest, UpdateOpenCodeConfigRequest, + OpenCodeImportStatus, + SyncOpenCodeImportResponse, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, @@ -145,6 +147,18 @@ export const settingsApi = { }) }, + getOpenCodeImportStatus: async (): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-import/status`) + }, + + syncOpenCodeImport: async (overwriteState = false): Promise => { + return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overwriteState }), + }) + }, + getOpenCodeVersions: async (): Promise<{ versions: Array<{ version: string diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 35284640..d58bbdd1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -10,6 +10,7 @@ export interface Repo { cloneStatus: 'cloning' | 'ready' | 'error' clonedAt: number lastPulled?: number + lastAccessedAt?: number openCodeConfigName?: string isWorktree?: boolean isLocal?: boolean diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 1e342325..c3dc0cd6 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -7,6 +7,8 @@ import { type TTSConfig, type STTConfig, type OpenCodeConfigContent, + type ModelConfig, + type ProviderConfig, type SkillFileInfo, type CreateSkillRequest, type UpdateSkillRequest, @@ -14,7 +16,7 @@ import { } from '@opencode-manager/shared' import type { NotificationPreferences } from '@opencode-manager/shared/types' -export type { TTSConfig, STTConfig, OpenCodeConfigContent, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope } +export type { TTSConfig, STTConfig, OpenCodeConfigContent, ModelConfig, ProviderConfig, NotificationPreferences, SkillFileInfo, CreateSkillRequest, UpdateSkillRequest, SkillScope } export { DEFAULT_TTS_CONFIG, DEFAULT_STT_CONFIG, DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_USER_PREFERENCES, DEFAULT_LEADER_KEY } export interface CustomCommand { @@ -59,6 +61,7 @@ export interface UserPreferences { stt?: STTConfig notifications?: NotificationPreferences repoOrder?: number[] + repoSortMode?: 'recent' | 'manual' | 'name' memoryDedupThreshold?: number } @@ -76,8 +79,14 @@ export interface UpdateSettingsRequest { export interface OpenCodeConfig { id: number name: string - content: OpenCodeConfigContent + content: Record rawContent?: string + validationIssues?: Array<{ + path: string + message: string + }> + removedFields?: string[] + isValid: boolean isDefault: boolean createdAt: number updatedAt: number @@ -98,3 +107,27 @@ export interface OpenCodeConfigResponse { configs: OpenCodeConfig[] defaultConfig: OpenCodeConfig | null } + +export interface OpenCodeImportStatus { + configSourcePath: string | null + stateSourcePath: string | null + workspaceConfigPath: string + workspaceStatePath: string + workspaceStateExists: boolean +} + +export interface SyncOpenCodeImportResponse extends OpenCodeImportStatus { + success: boolean + message: string + serverRestarted: boolean + configImported: boolean + stateImported: boolean + relinkedRepos?: { + repos: Array> + relinkedCount: number + existingCount: number + nonRepoPathCount: number + duplicatePathCount: number + errors: Array<{ path: string; error: string }> + } +} diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index fe6f66d5..de1ed6b4 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react' +import { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react' import { FileTree } from './FileTree' import { FileOperations } from './FileOperations' import { FilePreview } from './FilePreview' @@ -14,6 +14,21 @@ import { API_BASE_URL } from '@/config' import { useMobile } from '@/hooks/useMobile' import { useFile } from '@/api/files' +export interface FileBrowserHandle { + goBack: () => void + canGoBack: () => boolean + getCurrentPath: () => string +} + +interface FileBrowserProps { + basePath?: string + onFileSelect?: (file: FileInfo) => void + embedded?: boolean + initialSelectedFile?: string + onDirectoryLoad?: (info: { workspaceRoot?: string; currentPath: string }) => void + onPreviewStateChange?: (isOpen: boolean) => void +} + interface UploadItem { file: File relativePath: string @@ -27,14 +42,6 @@ interface UploadProgress { cancelled: boolean } -interface FileBrowserProps { - basePath?: string - onFileSelect?: (file: FileInfo) => void - embedded?: boolean - initialSelectedFile?: string - onDirectoryLoad?: (info: { workspaceRoot?: string; currentPath: string }) => void -} - async function readFileEntry(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => { entry.file(resolve, reject) @@ -115,7 +122,7 @@ function getUploadItemsFromFileList(fileList: FileList): UploadItem[] { return items } -export function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad }: FileBrowserProps) { +export const FileBrowser = forwardRef(function FileBrowser({ basePath = '', onFileSelect, embedded = false, initialSelectedFile, onDirectoryLoad, onPreviewStateChange }, ref) { const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) const [selectedFile, setSelectedFile] = useState(null) @@ -137,9 +144,10 @@ useEffect(() => { setSelectedFile(initialFileData) if (isMobile) { setIsPreviewModalOpen(true) + onPreviewStateChange?.(true) } } -}, [initialFileData, isMobile]) +}, [initialFileData, isMobile, onPreviewStateChange]) useEffect(() => { if (initialFileError) { @@ -168,6 +176,27 @@ useEffect(() => { } }, [onDirectoryLoad]) + const getPathParts = useCallback((path: string) => path.split('/').filter(Boolean), []) + + const goToParentDirectory = useCallback(() => { + const pathParts = getPathParts(currentPath) + if (pathParts.length > 0) { + pathParts.pop() + const parentPath = pathParts.join('/') + loadFiles(parentPath || basePath) + } + }, [currentPath, basePath, loadFiles, getPathParts]) + + useImperativeHandle(ref, () => ({ + goBack: goToParentDirectory, + canGoBack: () => { + const pathParts = getPathParts(currentPath) + const joinedPath = pathParts.join('/') + return pathParts.length > 0 && joinedPath !== basePath + }, + getCurrentPath: () => currentPath, + }), [currentPath, basePath, goToParentDirectory, getPathParts]) + const handleFileSelect = useCallback(async (file: FileInfo) => { if (file.isDirectory) { setSelectedFile(null) @@ -189,6 +218,7 @@ useEffect(() => { // On mobile, open preview in modal if (isMobile) { setIsPreviewModalOpen(true) + onPreviewStateChange?.(true) } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load file') @@ -196,12 +226,13 @@ useEffect(() => { } finally { setLoading(false) } - }, [onFileSelect, isMobile]) + }, [onFileSelect, isMobile, onPreviewStateChange]) const handleCloseModal = useCallback(() => { setIsPreviewModalOpen(false) setSelectedFile(null) - }, []) + onPreviewStateChange?.(false) + }, [onPreviewStateChange]) const handleDirectoryClick = (path: string) => { loadFiles(path) @@ -404,7 +435,7 @@ useEffect(() => { const canDismissDialog = isUploadComplete || uploadProgress?.cancelled const uploadDialog = ( - { if (!open && canDismissDialog) setUploadProgress(null) }}> + { if (!open && canDismissDialog) setUploadProgress(null) }}> @@ -525,6 +556,7 @@ useEffect(() => { onRename={handleRename} currentPath={currentPath} basePath={basePath} + onNavigateUp={goToParentDirectory} /> )} @@ -624,6 +656,7 @@ useEffect(() => { onRename={handleRename} currentPath={currentPath} basePath={basePath} + onNavigateUp={goToParentDirectory} /> )} @@ -654,4 +687,4 @@ useEffect(() => { {uploadDialog} ) -} +}) diff --git a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx new file mode 100644 index 00000000..3dda39ef --- /dev/null +++ b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx @@ -0,0 +1,872 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import '@testing-library/jest-dom' +import { FileBrowserSheet } from './FileBrowserSheet' +import { FileBrowser } from './FileBrowser' +import type { FileBrowserHandle } from './FileBrowser' +import * as useMobile from '../../hooks/useMobile' + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) +} + +function createWrapper() { + const queryClient = createQueryClient() + return ({ children }: { children: React.ReactNode }) => ( + {children} + ) +} + +describe('FileBrowserSheet', () => { + const mockOnClose = vi.fn() + + beforeEach(() => { + mockOnClose.mockClear() + }) + + it('renders when isOpen is true', () => { + render( + , + { wrapper: createWrapper() } + ) + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('does not render when isOpen is false and shouldRender is false', () => { + const { container } = render( + , + { wrapper: createWrapper() } + ) + expect(container.firstChild).toBeNull() + }) + + it('calls onClose when close button is clicked', () => { + render( + , + { wrapper: createWrapper() } + ) + + const buttons = screen.getAllByRole('button') + const closeButton = buttons.find(btn => btn.querySelector('svg')) + if (closeButton) { + fireEvent.click(closeButton) + expect(mockOnClose).toHaveBeenCalled() + } + }) + + it('passes basePath to FileBrowser', () => { + const testBasePath = 'test/path' + render( + , + { wrapper: createWrapper() } + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) +}) + +describe('FileBrowser navigation', () => { + it('exposes imperative handle with goBack, canGoBack, and getCurrentPath', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current).toBeTruthy() + expect(typeof ref.current?.goBack).toBe('function') + expect(typeof ref.current?.canGoBack).toBe('function') + expect(typeof ref.current?.getCurrentPath).toBe('function') + }) + + it('canGoBack returns false when at base path', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('canGoBack returns true when deeper than base path', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + const pathParts = 'test/deep/path'.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + expect(joinedPath !== 'test').toBe(true) + }) + + it('getCurrentPath returns current path', () => { + const ref = { current: null as unknown as FileBrowserHandle } + const testPath = 'test/path' + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current?.getCurrentPath()).toBe(testPath) + }) + + it('goBack navigates to parent directory', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current?.getCurrentPath()).toBe('test') + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('goBack handles path segments correctly', () => { + const pathParts = 'test/deep/path'.split('/').filter(Boolean) + pathParts.pop() + const parentPath = pathParts.join('/') + + expect(parentPath).toBe('test/deep') + }) + + it('canGoBack logic correctly identifies nested paths', () => { + const basePath = 'test' + const nestedPath = 'test/deep' + const pathParts = nestedPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + + expect(joinedPath !== basePath).toBe(true) + expect(pathParts.length > 0).toBe(true) + }) +}) + +describe('FileBrowserSheet swipe behavior', () => { + const mockOnClose = vi.fn() + + it('disables swipe when preview modal is open', () => { + render( + , + { wrapper: createWrapper() } + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('FileBrowser ref exposes navigation imperative handle', () => { + const fileBrowserRef = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(fileBrowserRef.current).toBeFalsy() + }) + + it('swipe enabled state respects isEditing and isPreviewOpen flags', () => { + const queryClient = createQueryClient() + const { rerender } = render( + + + + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + + rerender( + + + + ) + }) +}) + +describe('FileBrowser navigation logic', () => { + it('computes parent path correctly for nested directories', () => { + const testCases = [ + { current: 'test/deep/path', expected: 'test/deep' }, + { current: 'test/deep', expected: 'test' }, + { current: 'test', expected: '' }, + ] + + testCases.forEach(({ current, expected }) => { + const pathParts = current.split('/').filter(Boolean) + pathParts.pop() + const parentPath = pathParts.join('/') + expect(parentPath).toBe(expected) + }) + }) + + it('canGoBack returns true for paths deeper than basePath', () => { + const basePath = 'test' + const nestedPaths = ['test/deep', 'test/deep/path', 'test/a/b/c'] + + nestedPaths.forEach(nestedPath => { + const pathParts = nestedPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + expect(canGoBack).toBe(true) + }) + }) + + it('canGoBack returns false when at basePath', () => { + const basePath = 'test' + const pathParts = basePath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + expect(canGoBack).toBe(false) + }) + + it('goBack loads parent path even when parent equals basePath', () => { + const basePath = 'test' + const currentPath = 'test/deep' + + const pathParts = currentPath.split('/').filter(Boolean) + pathParts.pop() + const parentPath = pathParts.join('/') || basePath + + expect(parentPath).toBe(basePath) + }) + + it('handles edge case where currentPath equals basePath', () => { + const basePath = 'test' + const currentPath = basePath + + const pathParts = currentPath.split('/').filter(Boolean) + const canGoBack = pathParts.length > 0 && currentPath !== basePath + + expect(canGoBack).toBe(false) + }) +}) + +describe('FileBrowser onPreviewStateChange', () => { + it('FileBrowser accepts onPreviewStateChange prop', () => { + const mockOnPreviewStateChange = vi.fn() + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current).toBeTruthy() + }) +}) + +describe('FileBrowserSheet swipe decision logic', () => { + const mockOnClose = vi.fn() + + beforeEach(() => { + mockOnClose.mockClear() + }) + + it('FileBrowser imperative handle exposes navigation methods', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + {}} + />, + { wrapper: createWrapper() } + ) + + expect(ref.current).toBeTruthy() + expect(typeof ref.current?.goBack).toBe('function') + expect(typeof ref.current?.canGoBack).toBe('function') + expect(typeof ref.current?.getCurrentPath).toBe('function') + }) + + it('FileBrowser canGoBack returns false at base path', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + {}} + />, + { wrapper: createWrapper() } + ) + + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('FileBrowser canGoBack returns true after navigating deeper', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + {}} + />, + { wrapper: createWrapper() } + ) + + expect(ref.current?.getCurrentPath()).toBe('test') + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('FileBrowserSheet resets isPreviewOpen when sheet closes', () => { + const queryClient = createQueryClient() + const { rerender } = render( + + + + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + + rerender( + + + + ) + + expect(mockOnClose).not.toHaveBeenCalled() + }) + + it('FileBrowserSheet swipe enabled state respects isPreviewOpen flag', () => { + render( + , + { wrapper: createWrapper() } + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('FileBrowserSheet swipe enabled state respects isEditing flag', () => { + render( + , + { wrapper: createWrapper() } + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('path computation: nested path can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep/path' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('path computation: base path cannot go back', () => { + const basePath = 'test' + const currentPath = basePath + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(false) + }) + + it('goBack computes correct parent path', () => { + const testCases = [ + { current: 'test/deep/path', expected: 'test/deep' }, + { current: 'test/deep', expected: 'test' }, + ] + + testCases.forEach(({ current, expected }) => { + const pathParts = current.split('/').filter(Boolean) + pathParts.pop() + const parentPath = pathParts.join('/') + expect(parentPath).toBe(expected) + }) + }) + + it('FileBrowserSheet swipe decision: nested path can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('FileBrowserSheet swipe decision: base path cannot go back', () => { + const basePath = 'test' + const currentPath = basePath + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== basePath + + expect(canGoBack).toBe(false) + }) +}) + +describe('FileBrowserSheet swipe decision integration', () => { + it('FileBrowserSheet renders with FileBrowser and passes ref', () => { + const mockOnClose = vi.fn() + const queryClient = createQueryClient() + + render( + + + + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('FileBrowser exposes imperative handle for navigation', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current).toBeTruthy() + expect(typeof ref.current?.goBack).toBe('function') + expect(typeof ref.current?.canGoBack).toBe('function') + expect(typeof ref.current?.getCurrentPath).toBe('function') + }) + + it('FileBrowser canGoBack returns false at base path', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('FileBrowser goBack navigates to parent directory', () => { + const ref = { current: null as unknown as FileBrowserHandle } + + render( + , + { wrapper: createWrapper() } + ) + + expect(ref.current?.getCurrentPath()).toBe('test') + expect(ref.current?.canGoBack()).toBe(false) + }) + + it('swipe completion decision logic: nested path can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep/path' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('swipe completion decision logic: base path cannot go back', () => { + const basePath = 'test' + const currentPath = basePath + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(false) + }) + + it('FileBrowserSheet swipe completion: nested path can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('FileBrowserSheet swipe completion: base path cannot go back', () => { + const basePath = 'test' + const currentPath = basePath + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== basePath + + expect(canGoBack).toBe(false) + }) + + it('FileBrowserSheet path computation: nested path can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep/nested' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('FileBrowserSheet path computation: single level deep can go back', () => { + const basePath = 'test' + const currentPath = 'test/deep' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('FileBrowserSheet swipe decision: uses canGoBack to decide back vs close', () => { + const basePath = 'test' + + const nestedPath = 'test/deep/nested' + const nestedPathParts = nestedPath.split('/').filter(Boolean) + const nestedJoinedPath = nestedPathParts.join('/') + const canGoBackNested = nestedPathParts.length > 0 && nestedJoinedPath !== basePath + expect(canGoBackNested).toBe(true) + + const baseCurrentPath = basePath + const basePathParts = baseCurrentPath.split('/').filter(Boolean) + const baseJoinedPath = basePathParts.join('/') + const canGoBackBase = basePathParts.length > 0 && baseJoinedPath !== basePath + expect(canGoBackBase).toBe(false) + }) + + it('FileBrowserSheet swipe completion: verifies FileBrowser ref is accessible', () => { + const mockOnClose = vi.fn() + + render( + , + { wrapper: createWrapper() } + ) + + expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument() + }) + + it('FileBrowserSheet swipe decision integration: callback chain verification', () => { + const basePath = 'test' + const nestedPath = 'test/deep' + + const pathParts = nestedPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + expect(joinedPath).toBe('test/deep') + + const parentPath = pathParts.slice(0, -1).join('/') + expect(parentPath).toBe('test') + }) + + it('FileBrowserSheet wires useSwipeToClose with canSwipeBack and onSwipeBack callbacks', () => { + const mockOnClose = vi.fn() + + const mockUseSwipeToClose = vi.spyOn(useMobile, 'useSwipeToClose') + mockUseSwipeToClose.mockReturnValue({ + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: { transform: undefined, transition: 'transform 0.2s ease-out' }, + }) + + render( + , + { wrapper: createWrapper() } + ) + + expect(mockUseSwipeToClose).toHaveBeenCalled() + + const callArgs = mockUseSwipeToClose.mock.calls[0] + expect(callArgs).toBeDefined() + expect(callArgs![0]).toBe(mockOnClose) + + const options = callArgs![1]! + expect(options.enabled).toBe(true) + expect(typeof options.canSwipeBack).toBe('function') + expect(typeof options.onSwipeBack).toBe('function') + + mockUseSwipeToClose.mockRestore() + }) + + it('FileBrowserSheet swipe completion: path decision logic for nested path', () => { + const basePath = 'test' + const currentPath = 'test/deep/nested' + + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(true) + }) + + it('FileBrowserSheet swipe completion: path decision logic for base path', () => { + const basePath = 'test' + const currentPath = basePath + + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = pathParts.length > 0 && joinedPath !== basePath + + expect(canGoBack).toBe(false) + }) + + it('FileBrowserSheet swipe completion: parent path computation', () => { + const currentPath = 'test/deep/nested' + const pathParts = currentPath.split('/').filter(Boolean) + pathParts.pop() + const parentPath = pathParts.join('/') + + expect(parentPath).toBe('test/deep') + }) + + it('FileBrowserSheet swipe completion calls onSwipeBack for nested path', () => { + const mockOnClose = vi.fn() + + const mockUseSwipeToClose = vi.spyOn(useMobile, 'useSwipeToClose') + mockUseSwipeToClose.mockImplementation((onClose, _options) => { + if (_options?.canSwipeBack && _options.canSwipeBack()) { + _options.onSwipeBack?.() + } + return { + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: { transform: undefined, transition: 'transform 0.2s ease-out' }, + } + }) + + render( + , + { wrapper: createWrapper() } + ) + + const testPath = 'test/deep' + const pathParts = testPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== 'test' + + expect(canGoBack).toBe(true) + expect(mockOnClose).not.toHaveBeenCalled() + + mockUseSwipeToClose.mockRestore() + }) + + it('FileBrowserSheet swipe completion calls onClose for base path', () => { + const mockOnClose = vi.fn() + const mockOnSwipeBack = vi.fn() + + const basePath = 'test' + const currentPath = basePath + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== basePath + + expect(canGoBack).toBe(false) + expect(mockOnClose).not.toHaveBeenCalled() + expect(mockOnSwipeBack).not.toHaveBeenCalled() + }) + + it('FileBrowserSheet back-vs-close outcome: verifies swipe completion calls correct action', () => { + const mockOnClose = vi.fn() + + const mockUseSwipeToClose = vi.spyOn(useMobile, 'useSwipeToClose') + mockUseSwipeToClose.mockReturnValue({ + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: { transform: undefined, transition: 'transform 0.2s ease-out' }, + }) + + render( + + + + ) + + expect(mockUseSwipeToClose).toHaveBeenCalled() + + const callArgs = mockUseSwipeToClose.mock.calls[0] + expect(callArgs).toBeDefined() + expect(callArgs![0]).toBe(mockOnClose) + + const options = callArgs![1]! + expect(options.enabled).toBe(true) + expect(options.canSwipeBack).toBeDefined() + expect(options.onSwipeBack).toBeDefined() + + const nestedPath = 'test/deep' + const nestedPathParts = nestedPath.split('/').filter(Boolean) + const nestedJoinedPath = nestedPathParts.join('/') + const canGoBackNested = nestedJoinedPath !== 'test' + + expect(canGoBackNested).toBe(true) + + const basePath = 'test' + const basePathParts = basePath.split('/').filter(Boolean) + const baseJoinedPath = basePathParts.join('/') + const canGoBackBase = baseJoinedPath !== 'test' + + expect(canGoBackBase).toBe(false) + + mockUseSwipeToClose.mockRestore() + }) + + it('FileBrowserSheet back-vs-close: swipe completion decision is wired correctly', () => { + const mockOnClose = vi.fn() + + const mockUseSwipeToClose = vi.spyOn(useMobile, 'useSwipeToClose') + mockUseSwipeToClose.mockImplementation((onClose, options) => { + const canBack = options?.canSwipeBack?.() + if (canBack) { + options?.onSwipeBack?.() + } else { + onClose() + } + return { + bind: vi.fn(), + swipeProgress: 0, + swipeStyles: { transform: undefined, transition: 'transform 0.2s ease-out' }, + } + }) + + render( + + + + ) + + const callArgs = mockUseSwipeToClose.mock.calls[0] + const options = callArgs![1]! + + expect(options.canSwipeBack).toBeDefined() + expect(options.onSwipeBack).toBeDefined() + + const currentPath = 'test/deep' + const pathParts = currentPath.split('/').filter(Boolean) + const joinedPath = pathParts.join('/') + const canGoBack = joinedPath !== 'test' + + expect(canGoBack).toBe(true) + + mockUseSwipeToClose.mockRestore() + }) +}) + + diff --git a/frontend/src/components/file-browser/FileBrowserSheet.tsx b/frontend/src/components/file-browser/FileBrowserSheet.tsx index f986cdcf..f4d08d29 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.tsx @@ -1,12 +1,12 @@ import { useEffect, useState, memo, useCallback, useRef } from 'react' -import { FileBrowser } from './FileBrowser' +import { FileBrowser, type FileBrowserHandle } from './FileBrowser' import { Button } from '@/components/ui/button' import { PathDisplay } from '@/components/ui/path-display' import { FullscreenSheet, FullscreenSheetHeader, FullscreenSheetContent } from '@/components/ui/fullscreen-sheet' import { DownloadDialog } from '@/components/ui/download-dialog' import { X, Download } from 'lucide-react' import { GPU_ACCELERATED_STYLE, MODAL_TRANSITION_MS } from '@/lib/utils' -import { useSwipeBack } from '@/hooks/useMobile' +import { useSwipeToClose } from '@/hooks/useMobile' import { downloadDirectoryAsZip } from '@/api/files' import { downloadRepo } from '@/api/repos' import { @@ -32,10 +32,14 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose const [shouldRender, setShouldRender] = useState(false) const [currentPath, setCurrentPath] = useState(basePath || '.') const [downloadDialog, setDownloadDialog] = useState<{ type: 'directory' | 'repository' } | null>(null) + const [isPreviewOpen, setIsPreviewOpen] = useState(false) const containerRef = useRef(null) + const fileBrowserRef = useRef(null) - const { bind, swipeStyles } = useSwipeBack(onClose, { - enabled: isOpen && !isEditing, + const { bind, swipeStyles } = useSwipeToClose(onClose, { + enabled: isOpen && !isEditing && !isPreviewOpen, + canSwipeBack: () => fileBrowserRef.current?.canGoBack() ?? false, + onSwipeBack: () => fileBrowserRef.current?.goBack(), }) useEffect(() => { @@ -46,6 +50,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose if (isOpen) { setShouldRender(true) } else { + setIsPreviewOpen(false) const timer = setTimeout(() => setShouldRender(false), MODAL_TRANSITION_MS) return () => clearTimeout(timer) } @@ -91,7 +96,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose useEffect(() => { const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === 'Escape' && isOpen && !isPreviewOpen) { onClose() } } @@ -111,7 +116,7 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose document.removeEventListener('editModeChange', handleEditModeChange as EventListener) document.body.style.overflow = 'unset' } - }, [isOpen, onClose]) + }, [isOpen, onClose, isPreviewOpen]) if (!isOpen && !shouldRender) return null @@ -176,10 +181,12 @@ export const FileBrowserSheet = memo(function FileBrowserSheet({ isOpen, onClose diff --git a/frontend/src/components/file-browser/FilePreviewDialog.tsx b/frontend/src/components/file-browser/FilePreviewDialog.tsx index 4d021cbd..333340ff 100644 --- a/frontend/src/components/file-browser/FilePreviewDialog.tsx +++ b/frontend/src/components/file-browser/FilePreviewDialog.tsx @@ -62,6 +62,7 @@ export function FilePreviewDialog({ isOpen, onClose, filePath, repoBasePath, onF className="w-screen h-screen max-w-none max-h-none p-0 bg-background border-0 flex flex-col" hideCloseButton fullscreen + mobileSwipeToClose >
{isLoading ? ( diff --git a/frontend/src/components/file-browser/FileTree.tsx b/frontend/src/components/file-browser/FileTree.tsx index 75012104..7aa2d218 100644 --- a/frontend/src/components/file-browser/FileTree.tsx +++ b/frontend/src/components/file-browser/FileTree.tsx @@ -32,6 +32,7 @@ interface FileTreeProps { onRename: (oldPath: string, newPath: string) => void currentPath?: string basePath?: string + onNavigateUp?: () => void } interface TreeNodeProps { @@ -238,18 +239,11 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o ) } -export const FileTree = memo(function FileTree({ files, onFileSelect, onDirectoryClick, selectedFile, onDelete, onRename, currentPath = '', basePath = '' }: FileTreeProps) { +export const FileTree = memo(function FileTree({ files, onFileSelect, onDirectoryClick, selectedFile, onDelete, onRename, currentPath = '', basePath = '', onNavigateUp }: FileTreeProps) { const handleGoUp = () => { - // If currentPath has content and is different from basePath, go up - if (currentPath !== basePath) { - const pathParts = currentPath.split('/').filter(p => p) - pathParts.pop() - const parentPath = pathParts.join('/') - onDirectoryClick(parentPath) - } + onNavigateUp?.() } - // Show ".." if we're not at the base path (empty string means root) const showGoUp = currentPath && currentPath !== basePath return ( diff --git a/frontend/src/components/file-browser/MobileFilePreviewModal.tsx b/frontend/src/components/file-browser/MobileFilePreviewModal.tsx index 705df0c3..d0b97473 100644 --- a/frontend/src/components/file-browser/MobileFilePreviewModal.tsx +++ b/frontend/src/components/file-browser/MobileFilePreviewModal.tsx @@ -3,7 +3,7 @@ import { FilePreview } from "./FilePreview"; import { FullscreenSheet } from "@/components/ui/fullscreen-sheet"; import type { FileInfo } from "@/types/files"; import { GPU_ACCELERATED_STYLE, MODAL_TRANSITION_MS } from "@/lib/utils"; -import { useSwipeBack } from "@/hooks/useMobile"; +import { useSwipeToClose } from "@/hooks/useMobile"; interface MobileFilePreviewModalProps { isOpen: boolean; @@ -22,7 +22,17 @@ export const MobileFilePreviewModal = memo(function MobileFilePreviewModal({ const isClosingRef = useRef(false); const containerRef = useRef(null); - const { bind, swipeStyles } = useSwipeBack(onClose, { + const handleClose = useCallback(() => { + if (isClosingRef.current) return; + isClosingRef.current = true; + onClose(); + setTimeout(() => { + setLocalFile(null); + isClosingRef.current = false; + }, MODAL_TRANSITION_MS); + }, [onClose]); + + const { bind, swipeStyles } = useSwipeToClose(handleClose, { enabled: isOpen, }); @@ -37,15 +47,21 @@ export const MobileFilePreviewModal = memo(function MobileFilePreviewModal({ } }, [isOpen, file]); - const handleClose = useCallback(() => { - if (isClosingRef.current) return; - isClosingRef.current = true; - onClose(); - setTimeout(() => { - setLocalFile(null); - isClosingRef.current = false; - }, MODAL_TRANSITION_MS); - }, [onClose]); + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + handleClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen, handleClose]); if (!isOpen || !localFile) { return null; diff --git a/frontend/src/components/message/FloatingTTSButton.test.tsx b/frontend/src/components/message/FloatingTTSButton.test.tsx new file mode 100644 index 00000000..b2d60d0f --- /dev/null +++ b/frontend/src/components/message/FloatingTTSButton.test.tsx @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { FloatingTTSButton } from './FloatingTTSButton' +import type { TTSConfig } from '@opencode-manager/shared' + +const mocks = vi.hoisted(() => ({ + useTTS: vi.fn(), + useSettings: vi.fn(), + showToastInfo: vi.fn(), +})) + +vi.mock('@/hooks/useTTS', () => ({ + useTTS: mocks.useTTS, +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: mocks.useSettings, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { + info: mocks.showToastInfo, + }, +})) + +interface MockTTSReturn { + speakMessage: ReturnType + stop: ReturnType + activeMessageId: string | null + isPlaying: boolean + isLoading: boolean + isEnabled: boolean +} + +describe('FloatingTTSButton', () => { + const mockSpeakMessage = vi.fn() + const mockStop = vi.fn() + const mockUpdateSettings = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockSpeakMessage.mockClear() + mockStop.mockClear() + mockUpdateSettings.mockClear() + mocks.showToastInfo.mockClear() + }) + + const setup = (options: { + ttsEnabled?: boolean + autoPlay?: boolean + activeMessageId?: string | null + isPlaying?: boolean + isLoading?: boolean + } = {}) => { + const mockTTS: MockTTSReturn = { + speakMessage: mockSpeakMessage, + stop: mockStop, + activeMessageId: options.activeMessageId ?? null, + isPlaying: options.isPlaying ?? false, + isLoading: options.isLoading ?? false, + isEnabled: options.ttsEnabled ?? true, + } + mocks.useTTS.mockReturnValue(mockTTS) + + mocks.useSettings.mockReturnValue({ + preferences: { + tts: { + enabled: options.ttsEnabled ?? true, + autoPlay: options.autoPlay ?? false, + provider: 'external', + endpoint: 'https://api.openai.com', + apiKey: 'test-key', + voice: 'alloy', + model: 'tts-1', + speed: 1.0, + } as TTSConfig, + }, + updateSettings: mockUpdateSettings, + }) + } + + const TEST_MESSAGE_ID = 'test-message-1' + const TEST_CONTENT = 'Test content' + + it('renders pill when content is empty but autoplay is enabled', () => { + setup({ autoPlay: true }) + render() + expect(screen.getByRole('button', { name: /hold to toggle auto-play/i })).toBeInTheDocument() + }) + + it('renders pill when content is whitespace but autoplay is enabled', () => { + setup({ autoPlay: true }) + render() + expect(screen.getByRole('button', { name: /hold to toggle auto-play/i })).toBeInTheDocument() + }) + + it('calls speakMessage on tap when idle', () => { + setup() + render() + + const button = screen.getByRole('button', { name: /play latest reply/i }) + fireEvent.pointerDown(button) + act(() => { + fireEvent.pointerUp(button) + }) + + expect(mockSpeakMessage).toHaveBeenCalledTimes(1) + expect(mockSpeakMessage).toHaveBeenCalledWith(TEST_MESSAGE_ID, TEST_CONTENT) + }) + + it('calls stop on tap when this message is active', () => { + setup({ activeMessageId: TEST_MESSAGE_ID, isPlaying: true }) + render() + + const button = screen.getByRole('button', { name: /stop playback/i }) + fireEvent.pointerDown(button) + act(() => { + fireEvent.pointerUp(button) + }) + + expect(mockStop).toHaveBeenCalledTimes(1) + expect(mockSpeakMessage).not.toHaveBeenCalled() + }) + + it('calls stop when any playback is active, even for a different messageId (stream conflict fix)', () => { + setup({ activeMessageId: 'other-message-id', isPlaying: true }) + render() + + const button = screen.getByRole('button', { name: /stop playback/i }) + fireEvent.pointerDown(button) + act(() => { + fireEvent.pointerUp(button) + }) + + expect(mockStop).toHaveBeenCalledTimes(1) + expect(mockSpeakMessage).not.toHaveBeenCalled() + }) + + it('toggles autoplay on long press', async () => { + vi.useFakeTimers() + setup({ autoPlay: false }) + render() + + const button = screen.getByRole('button', { name: /play latest reply/i }) + await act(async () => { + fireEvent.pointerDown(button) + await vi.advanceTimersByTimeAsync(500) + fireEvent.pointerUp(button) + }) + + expect(mockUpdateSettings).toHaveBeenCalledTimes(1) + expect(mockUpdateSettings).toHaveBeenCalledWith({ + tts: { + autoPlay: true, + enabled: true, + provider: 'external', + endpoint: 'https://api.openai.com', + apiKey: 'test-key', + voice: 'alloy', + model: 'tts-1', + speed: 1.0, + }, + }) + expect(mocks.showToastInfo).toHaveBeenCalledWith('Auto-play enabled', { + id: 'tts-autoplay-toggle', + duration: 1800, + }) + + vi.useRealTimers() + }) + + it('shows a toast when long press disables autoplay', async () => { + vi.useFakeTimers() + setup({ autoPlay: true }) + render() + + const button = screen.getByRole('button', { name: /auto-play enabled/i }) + await act(async () => { + fireEvent.pointerDown(button) + await vi.advanceTimersByTimeAsync(500) + fireEvent.pointerUp(button) + }) + + expect(mockUpdateSettings).toHaveBeenCalledWith( + expect.objectContaining({ tts: expect.objectContaining({ autoPlay: false }) }) + ) + expect(mocks.showToastInfo).toHaveBeenCalledWith('Auto-play disabled', { + id: 'tts-autoplay-toggle', + duration: 1800, + }) + + vi.useRealTimers() + }) + + it('does not call speakMessage after long press (race guard)', async () => { + vi.useFakeTimers() + setup() + render() + + const button = screen.getByRole('button', { name: /play latest reply/i }) + await act(async () => { + fireEvent.pointerDown(button) + await vi.advanceTimersByTimeAsync(500) + fireEvent.pointerUp(button) + }) + + expect(mockSpeakMessage).not.toHaveBeenCalled() + expect(mockStop).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('does not call speakMessage if pointer leaves before release after long press fires', async () => { + vi.useFakeTimers() + setup() + render() + + const button = screen.getByRole('button', { name: /play latest reply/i }) + await act(async () => { + fireEvent.pointerDown(button) + await vi.advanceTimersByTimeAsync(500) + fireEvent.pointerLeave(button) + }) + + expect(mockSpeakMessage).not.toHaveBeenCalled() + vi.useRealTimers() + }) + + it('exposes the gesture hint in the aria-label for accessibility', () => { + setup() + render() + + const button = screen.getByRole('button', { name: /hold to toggle auto-play/i }) + expect(button).toBeInTheDocument() + }) + + it('shows stop icon when this message is playing', () => { + setup({ autoPlay: true, activeMessageId: TEST_MESSAGE_ID, isPlaying: true }) + render() + + const button = screen.getByRole('button', { name: /stop playback/i }) + + expect(button).toHaveClass('from-red-600') + expect(button).toHaveClass('rounded-lg') + expect(button).not.toHaveClass('rounded-full') + expect(button).not.toHaveClass('hidden') + expect(screen.queryByText('Stop')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Autoplay enabled')).not.toBeInTheDocument() + }) + + it('shows the same stop icon while this message is loading', () => { + setup({ activeMessageId: TEST_MESSAGE_ID, isLoading: true }) + render() + + expect(screen.getByRole('button', { name: /stop playback/i })).toHaveClass('from-red-600') + }) + + it('shows autoplay state with button color when autoPlay is enabled', () => { + setup({ autoPlay: true }) + render() + + const button = screen.getByRole('button', { name: /auto-play enabled/i }) + + expect(button).toHaveClass('from-blue-500') + expect(button).toHaveClass('rounded-lg') + expect(screen.queryByText('Play')).not.toBeInTheDocument() + expect(screen.queryByText('Auto')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Autoplay enabled')).not.toBeInTheDocument() + }) + + it('uses the default play color when autoPlay is disabled', () => { + setup({ autoPlay: false }) + render() + + const button = screen.getByRole('button', { name: /play latest reply/i }) + + expect(button).toHaveClass('from-amber-500') + expect(button).not.toHaveClass('from-blue-500') + expect(screen.queryByText('Play')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/FloatingTTSButton.tsx b/frontend/src/components/message/FloatingTTSButton.tsx index 394f98ef..b129234a 100644 --- a/frontend/src/components/message/FloatingTTSButton.tsx +++ b/frontend/src/components/message/FloatingTTSButton.tsx @@ -1,53 +1,109 @@ -import { Volume2, VolumeX, Loader2 } from 'lucide-react' +import { useState, useCallback, useRef } from 'react' +import { Volume2, VolumeX } from 'lucide-react' import { useTTS } from '@/hooks/useTTS' -import { useMobile } from '@/hooks/useMobile' +import { useSettings } from '@/hooks/useSettings' +import { showToast } from '@/lib/toast' +import { DEFAULT_TTS_CONFIG } from '@opencode-manager/shared' interface FloatingTTSButtonProps { + messageId: string content: string } -export function FloatingTTSButton({ content }: FloatingTTSButtonProps) { - const { speak, stop, isEnabled, isPlaying, isLoading, originalText } = useTTS() - const isMobile = useMobile() - - if (!isEnabled || !content.trim()) { - return null - } - - const isThisPlaying = (isPlaying || isLoading) && originalText === content - - const handleClick = () => { - if (isThisPlaying) { +const LONG_PRESS_DURATION = 500 + +export function FloatingTTSButton({ messageId, content }: FloatingTTSButtonProps) { + const { speakMessage, stop, isPlaying, isLoading } = useTTS() + const { preferences, updateSettings } = useSettings() + + const autoPlay = preferences?.tts?.autoPlay ?? false + const [isLongPressVisual, setIsLongPressVisual] = useState(false) + const longPressTimerRef = useRef | null>(null) + const didLongPressFireRef = useRef(false) + + const isAnyPlaybackActive = isPlaying || isLoading + const hasContent = content.trim().length > 0 + + const clearLongPressTimer = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + }, []) + + const handlePointerDown = useCallback(() => { + clearLongPressTimer() + didLongPressFireRef.current = false + longPressTimerRef.current = setTimeout(() => { + const nextAutoPlay = !autoPlay + + didLongPressFireRef.current = true + setIsLongPressVisual(true) + updateSettings({ tts: { ...(preferences?.tts ?? DEFAULT_TTS_CONFIG), autoPlay: nextAutoPlay } }) + showToast.info(nextAutoPlay ? 'Auto-play enabled' : 'Auto-play disabled', { + id: 'tts-autoplay-toggle', + duration: 1800, + }) + }, LONG_PRESS_DURATION) + }, [autoPlay, updateSettings, preferences?.tts, clearLongPressTimer]) + + const handlePointerUp = useCallback(() => { + clearLongPressTimer() + + if (didLongPressFireRef.current) { + didLongPressFireRef.current = false + setIsLongPressVisual(false) + return + } + + if (isAnyPlaybackActive) { stop() - } else { - speak(content) + return + } + + if (hasContent) { + speakMessage(messageId, content) } - } - - const buttonPosition = isMobile ? 'bottom-24' : 'bottom-20' - + }, [clearLongPressTimer, isAnyPlaybackActive, stop, speakMessage, messageId, content, hasContent]) + + const handlePointerLeave = useCallback(() => { + clearLongPressTimer() + didLongPressFireRef.current = false + setIsLongPressVisual(false) + }, [clearLongPressTimer]) + + const showStop = isAnyPlaybackActive + const pillTitle = showStop + ? 'Stop playback (hold to toggle auto-play)' + : hasContent + ? autoPlay + ? 'Play latest reply (auto-play enabled; hold to toggle auto-play)' + : 'Play latest reply (hold to toggle auto-play)' + : autoPlay + ? 'Auto-play enabled (hold to toggle auto-play)' + : 'Hold to toggle auto-play' + const pillAriaLabel = pillTitle + const buttonToneClasses = showStop + ? 'justify-center px-3 py-1.5 rounded-lg bg-gradient-to-br from-red-600 to-red-700 border border-red-500/60 shadow-red-500/30 ring-red-500/20 hover:ring-red-500/40 text-white' + : autoPlay + ? 'justify-center px-3 py-1.5 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 border border-blue-400/60 shadow-blue-500/30 ring-blue-500/20 hover:ring-blue-500/40 text-white' + : 'justify-center px-3 py-1.5 rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 border border-amber-400/60 shadow-amber-500/30 ring-amber-500/20 hover:ring-amber-500/40 text-white' + return ( - + ) } diff --git a/frontend/src/components/message/MessagePart.test.tsx b/frontend/src/components/message/MessagePart.test.tsx new file mode 100644 index 00000000..99f5b5d5 --- /dev/null +++ b/frontend/src/components/message/MessagePart.test.tsx @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MessagePart } from './MessagePart' +import type { MessagePart as MessagePartType } from '@/api/types' + +const mocks = vi.hoisted(() => ({ + useTTS: vi.fn(), +})) + +vi.mock('@/hooks/useTTS', () => ({ + useTTS: mocks.useTTS, +})) + +interface MockTTSReturn { + speakMessage: ReturnType + stop: ReturnType + activeMessageId: string | null + isPlaying: boolean + isLoading: boolean + isEnabled: boolean +} + +describe('MessagePart', () => { + const mockSpeakMessage = vi.fn() + const mockStop = vi.fn() + + beforeEach(() => { + mockSpeakMessage.mockClear() + mockStop.mockClear() + }) + + const setup = (options: { + ttsEnabled?: boolean + autoPlay?: boolean + activeMessageId?: string | null + isPlaying?: boolean + isLoading?: boolean + } = {}) => { + const mockTTS: MockTTSReturn = { + speakMessage: mockSpeakMessage, + stop: mockStop, + activeMessageId: options.activeMessageId ?? null, + isPlaying: options.isPlaying ?? false, + isLoading: options.isLoading ?? false, + isEnabled: options.ttsEnabled ?? true, + } + mocks.useTTS.mockReturnValue(mockTTS) + } + + const createStepFinishPart = (messageID: string): MessagePartType => ({ + type: 'step-finish', + messageID, + sessionID: 'test-session', + cost: 0.01, + tokens: { + input: 100, + output: 50, + cache: { read: 0, write: 0 }, + }, + time: { + start: Date.now(), + end: Date.now() + 100, + }, + }) + + const TEST_MESSAGE_ID = 'message-1' + const TEST_CONTENT = 'Test message content' + + it('renders TTS button for step-finish part with message text', () => { + setup() + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByTitle('Read aloud')).toBeInTheDocument() + }) + + it('does not render TTS button when message text is empty', () => { + setup() + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('does not render TTS button when TTS is disabled', () => { + setup({ ttsEnabled: false }) + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('calls speakMessage with message id on tap when idle', () => { + setup() + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(mockSpeakMessage).toHaveBeenCalledTimes(1) + expect(mockSpeakMessage).toHaveBeenCalledWith(TEST_MESSAGE_ID, TEST_CONTENT) + }) + + it('calls stop on tap when this message is active', () => { + const mockTTSForTest: MockTTSReturn = { + speakMessage: mockSpeakMessage, + stop: mockStop, + activeMessageId: TEST_MESSAGE_ID, + isPlaying: true, + isLoading: false, + isEnabled: true, + } + mocks.useTTS.mockReturnValue(mockTTSForTest) + + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + const button = screen.getByRole('button') + fireEvent.click(button) + + expect(mockStop).toHaveBeenCalledTimes(1) + expect(mockSpeakMessage).not.toHaveBeenCalled() + }) + + it('shows active state when this message is playing', () => { + setup({ activeMessageId: TEST_MESSAGE_ID, isPlaying: true }) + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + const button = screen.getByRole('button') + expect(button).toHaveClass('bg-red-500/20') + expect(button).toHaveClass('text-red-500') + }) + + it('shows active state when this message is loading', () => { + setup({ activeMessageId: TEST_MESSAGE_ID, isLoading: true }) + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + const button = screen.getByRole('button') + expect(button).toHaveClass('bg-red-500/20') + }) + + it('does not show active state when different message is playing', () => { + setup({ activeMessageId: 'other-message', isPlaying: true }) + const part = createStepFinishPart(TEST_MESSAGE_ID) + + render( + + ) + + const button = screen.getByRole('button') + expect(button).not.toHaveClass('bg-red-500/20') + }) + + it('tracks playback by message id not text', () => { + setup({ activeMessageId: TEST_MESSAGE_ID, isPlaying: true }) + const part1 = createStepFinishPart(TEST_MESSAGE_ID) + const part2 = createStepFinishPart('message-2') + + const { rerender } = render( + + ) + + expect(screen.getByRole('button')).toHaveClass('bg-red-500/20') + + rerender( + + ) + + expect(screen.getByRole('button')).not.toHaveClass('bg-red-500/20') + }) +}) diff --git a/frontend/src/components/message/MessagePart.tsx b/frontend/src/components/message/MessagePart.tsx index 15931ea2..25be72b6 100644 --- a/frontend/src/components/message/MessagePart.tsx +++ b/frontend/src/components/message/MessagePart.tsx @@ -59,24 +59,25 @@ function getCopyableContent(part: Part, allParts?: Part[]): string { } interface TTSButtonProps { + messageId: string content: string className?: string } -function TTSButton({ content, className = "" }: TTSButtonProps) { - const { speak, stop, isEnabled, isPlaying, isLoading, originalText } = useTTS() +function TTSButton({ messageId, content, className = "" }: TTSButtonProps) { + const { speakMessage, stop, isEnabled, isPlaying, isLoading, activeMessageId } = useTTS() if (!isEnabled || !content.trim()) { return null } - const isThisPlaying = (isPlaying || isLoading) && originalText === content + const isThisPlaying = (isPlaying || isLoading) && activeMessageId === messageId const handleClick = () => { if (isThisPlaying) { stop() } else { - speak(content) + speakMessage(messageId, content) } } @@ -85,7 +86,7 @@ function TTSButton({ content, className = "" }: TTSButtonProps) { onClick={handleClick} className={`p-1.5 rounded ${isThisPlaying ? 'bg-red-500/20 text-red-500 hover:bg-red-500/30' : 'bg-card hover:bg-card-hover text-muted-foreground hover:text-foreground'} ${className}`} title={isThisPlaying ? "Stop playback" : "Read aloud"} - disabled={isLoading && originalText !== content} + disabled={isLoading && !isThisPlaying} > {isLoading && isThisPlaying ? ( @@ -146,7 +147,7 @@ export const MessagePart = memo(function MessagePart({ part, role, allParts, par
{costText} - {messageTextContent && } + {messageTextContent && part.messageID && }
) } diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index f4ebf22a..f463c643 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -1,9 +1,10 @@ -import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, type KeyboardEvent } from 'react' +import { useState, useRef, useEffect, useMemo, useImperativeHandle, forwardRef, memo, useCallback, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react' import { useSendPrompt, useAbortSession, useSendShell, useAgents } from '@/hooks/useOpenCode' import { useCommands } from '@/hooks/useCommands' import { useCommandHandler } from '@/hooks/useCommandHandler' import { useFileSearch } from '@/hooks/useFileSearch' import { useModelSelection } from '@/hooks/useModelSelection' +import { useOpenCodeClient } from '@/hooks/useOpenCode' import { useVariants } from '@/hooks/useVariants' import { useSessionAgent } from '@/hooks/useSessionAgent' import { useSTT } from '@/hooks/useSTT' @@ -23,6 +24,8 @@ import { SessionStatusIndicator } from '@/components/ui/session-status-indicator import { ModelQuickSelect } from '@/components/model/ModelQuickSelect' import { AgentQuickSelect } from '@/components/agent/AgentQuickSelect' import { detectMentionTrigger, parsePromptToParts, getFilename, filterAgentsByQuery } from '@/lib/promptParser' +import { formatModelName, getProviders } from '@/api/providers' +import { useQuery } from '@tanstack/react-query' import type { components } from '@/api/opencode-types' @@ -41,6 +44,8 @@ const revokeBlobUrls = (attachments: ImageAttachment[]) => { const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] +const VOICE_SEND_SWIPE_THRESHOLD = 48 + type CommandType = components['schemas']['Command'] @@ -99,7 +104,16 @@ export const PromptInput = memo(forwardRef( const [localMode, setLocalMode] = useState(null) const [isFocused, setIsFocused] = useState(false) const [isTogglingRecording, setIsTogglingRecording] = useState(false) + const [isVoiceHoldActive, setIsVoiceHoldActive] = useState(false) + const [isVoiceSwipeArmed, setIsVoiceSwipeArmed] = useState(false) + const [isVoiceAutoSendPending, setIsVoiceAutoSendPending] = useState(false) const lastAddedTranscriptRef = useRef('') + const voiceHoldStartYRef = useRef(null) + const voiceSwipeArmedRef = useRef(false) + const voicePendingReleaseRef = useRef(false) + const pendingVoiceAutoSubmitRef = useRef(false) + const ignoreVoiceClickUntilRef = useRef(0) + const handleSubmitRef = useRef<() => void>(() => {}) const { isRecording, @@ -116,6 +130,16 @@ export const PromptInput = memo(forwardRef( const textareaRef = useRef(null) const fileInputRef = useRef(null) + const resetVoiceGestureState = useCallback(() => { + voiceHoldStartYRef.current = null + voiceSwipeArmedRef.current = false + voicePendingReleaseRef.current = false + pendingVoiceAutoSubmitRef.current = false + ignoreVoiceClickUntilRef.current = 0 + setIsVoiceHoldActive(false) + setIsVoiceSwipeArmed(false) + setIsVoiceAutoSendPending(false) + }, []) useImperativeHandle(ref, () => ({ setPromptValue: (value: string) => { @@ -128,6 +152,7 @@ export const PromptInput = memo(forwardRef( revokeBlobUrls(imageAttachments) setImageAttachments([]) setSelectedAgent(null) + resetVoiceGestureState() if (isRecording) { abortRecording() } else { @@ -138,7 +163,7 @@ export const PromptInput = memo(forwardRef( triggerFileUpload: () => { fileInputRef.current?.click() } - }), [imageAttachments, clearSTT, isRecording, abortRecording]) + }), [imageAttachments, clearSTT, isRecording, abortRecording, resetVoiceGestureState]) const sendPrompt = useSendPrompt(opcodeUrl, directory) const sendShell = useSendShell(opcodeUrl, directory) const abortSession = useAbortSession(opcodeUrl, directory, sessionID, repoId) @@ -194,6 +219,9 @@ export const PromptInput = memo(forwardRef( if (disabled) return if (!prompt.trim() && imageAttachments.length === 0) return + pendingVoiceAutoSubmitRef.current = false + setIsVoiceAutoSendPending(false) + if (hasActiveStream) { const parts = parsePromptToParts(prompt, attachedFiles, imageAttachments) const agentUsed = selectedAgent || currentMode @@ -205,6 +233,9 @@ export const PromptInput = memo(forwardRef( variant: currentVariant }) setStoredAgent(sessionID, agentUsed) + if (model) { + setStoredModel({ providerID: model.providerID, modelID: model.modelID }) + } setPrompt('') setAttachedFiles(new Map()) revokeBlobUrls(imageAttachments) @@ -256,6 +287,9 @@ export const PromptInput = memo(forwardRef( }) setStoredAgent(sessionID, agentUsed) + if (model) { + setStoredModel({ providerID: model.providerID, modelID: model.modelID }) + } setPrompt('') setAttachedFiles(new Map()) revokeBlobUrls(imageAttachments) @@ -264,6 +298,8 @@ export const PromptInput = memo(forwardRef( clearSTT() } + handleSubmitRef.current = handleSubmit + const handleStop = () => { abortSession.mutate(sessionID) } @@ -372,6 +408,10 @@ export const PromptInput = memo(forwardRef( } const handleVoiceToggle = async () => { + pendingVoiceAutoSubmitRef.current = false + setIsVoiceAutoSendPending(false) + setIsVoiceSwipeArmed(false) + voiceSwipeArmedRef.current = false if (isRecording) { stopRecording() } else { @@ -379,7 +419,7 @@ export const PromptInput = memo(forwardRef( try { await startRecording() if (textareaRef.current) { - textareaRef.current.blur() + textareaRef.current?.blur() } } catch { setIsTogglingRecording(false) @@ -387,6 +427,101 @@ export const PromptInput = memo(forwardRef( } } + const handleVoiceClick = async () => { + if (Date.now() < ignoreVoiceClickUntilRef.current) { + return + } + + await handleVoiceToggle() + } + + const handleVoiceHoldStart = async () => { + if (!isRecording && !isProcessing) { + pendingVoiceAutoSubmitRef.current = false + setIsVoiceAutoSendPending(false) + setIsTogglingRecording(true) + try { + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate(10) + } + await startRecording() + if (textareaRef.current) { + textareaRef.current?.blur() + } + } catch { + setIsTogglingRecording(false) + } + } + } + + const handleVoiceHoldEnd = (shouldAutoSend: boolean) => { + setIsVoiceHoldActive(false) + setIsVoiceSwipeArmed(false) + voiceHoldStartYRef.current = null + voiceSwipeArmedRef.current = false + pendingVoiceAutoSubmitRef.current = shouldAutoSend + setIsVoiceAutoSendPending(shouldAutoSend) + + if (isRecording) { + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate([10, 20, 10]) + } + stopRecording() + } else if (isTogglingRecording) { + voicePendingReleaseRef.current = true + } + } + + const handleVoicePointerDown = async (event: ReactPointerEvent) => { + if ((event.pointerType === 'mouse' && event.button !== 0) || disabled || isProcessing) { + return + } + + ignoreVoiceClickUntilRef.current = Date.now() + 400 + voiceHoldStartYRef.current = event.clientY + voiceSwipeArmedRef.current = false + voicePendingReleaseRef.current = false + pendingVoiceAutoSubmitRef.current = false + setIsVoiceHoldActive(true) + setIsVoiceSwipeArmed(false) + setIsVoiceAutoSendPending(false) + + if (event.currentTarget.setPointerCapture) { + event.currentTarget.setPointerCapture(event.pointerId) + } + + await handleVoiceHoldStart() + } + + const handleVoicePointerMove = (event: ReactPointerEvent) => { + if (!isVoiceHoldActive || voiceHoldStartYRef.current === null) { + return + } + + const nextIsSwipeArmed = voiceHoldStartYRef.current - event.clientY >= VOICE_SEND_SWIPE_THRESHOLD + + if (nextIsSwipeArmed !== voiceSwipeArmedRef.current) { + voiceSwipeArmedRef.current = nextIsSwipeArmed + setIsVoiceSwipeArmed(nextIsSwipeArmed) + + if (nextIsSwipeArmed && typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate(10) + } + } + } + + const handleVoicePointerEnd = (event: ReactPointerEvent, canceled = false) => { + if (event.currentTarget.releasePointerCapture && event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + if (!isVoiceHoldActive && !isTogglingRecording && !isRecording) { + return + } + + handleVoiceHoldEnd(canceled ? false : voiceSwipeArmedRef.current) + } + useEffect(() => { const textToUse = transcript || interimTranscript if (!isRecording && textToUse && textToUse !== 'Processing...' && textToUse !== 'Recording...') { @@ -398,7 +533,10 @@ export const PromptInput = memo(forwardRef( setPrompt(prev => `${prev} ${trimmedTranscript}`) } lastAddedTranscriptRef.current = trimmedTranscript - textareaRef.current?.focus() + + if (!pendingVoiceAutoSubmitRef.current) { + textareaRef.current?.focus() + } } } }, [isRecording, interimTranscript, transcript, prompt]) @@ -415,6 +553,35 @@ export const PromptInput = memo(forwardRef( } }, [isTogglingRecording]) + useEffect(() => { + if (isRecording && voicePendingReleaseRef.current) { + voicePendingReleaseRef.current = false + + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate([10, 20, 10]) + } + + stopRecording() + } + }, [isRecording, stopRecording]) + + useEffect(() => { + if (!pendingVoiceAutoSubmitRef.current || isRecording || isProcessing || !prompt.trim()) { + return + } + + pendingVoiceAutoSubmitRef.current = false + setIsVoiceAutoSendPending(false) + handleSubmitRef.current() + }, [prompt, isRecording, isProcessing]) + + useEffect(() => { + if (pendingVoiceAutoSubmitRef.current && !isRecording && !isProcessing && !transcript.trim() && !interimTranscript.trim()) { + pendingVoiceAutoSubmitRef.current = false + setIsVoiceAutoSendPending(false) + } + }, [isRecording, isProcessing, transcript, interimTranscript]) + const addImageAttachment = (file: File) => { const generateId = () => { if (typeof crypto !== 'undefined' && crypto.randomUUID) { @@ -671,6 +838,7 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) setPrompt('') revokeBlobUrls(imageAttachments) setImageAttachments([]) + resetVoiceGestureState() clearSTT() } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 't') { e.preventDefault() @@ -733,15 +901,73 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const currentMode = localMode ?? sessionAgent.agent const setStoredAgent = useSessionAgentStore((s) => s.setAgent) -const { model, modelString } = useModelSelection(opcodeUrl, directory) + const client = useOpenCodeClient(opcodeUrl, directory) + const { data: providersData } = useQuery({ + queryKey: ['opencode', 'providers', opcodeUrl, directory], + queryFn: () => getProviders(), + enabled: !!client, + staleTime: 30000, + }) + +const { model, modelString, setModel: setStoredModel } = useModelSelection(opcodeUrl, directory) const currentModel = modelString || '' - const displayModelName = model?.modelID || currentModel + const displayModelName = useMemo(() => { + if (!model) { + return currentModel + } + + const provider = providersData?.providers.find((item) => item.id === model.providerID) + const modelData = provider?.models?.[model.modelID] + + return modelData ? formatModelName(modelData) : model.modelID || currentModel + }, [currentModel, model, providersData]) const isMobile = useMobile() const { setShowDialog, hasForSession: hasPermissionsForSession } = usePermissions() const hasPendingPermissionForSession = hasPermissionsForSession(sessionID) const { hasVariants, currentVariant, cycleVariant } = useVariants(opcodeUrl, directory) const showStopButton = hasActiveStream const hideSecondaryButtons = isMobile && hasActiveStream + const showVoiceFeedback = isVoiceHoldActive || isRecording || isTogglingRecording || isProcessing || isVoiceAutoSendPending + const voiceFeedbackLabel = isProcessing + ? isVoiceAutoSendPending + ? 'Transcribing and sending...' + : 'Transcribing...' + : isRecording + ? isVoiceSwipeArmed + ? 'Release to send' + : 'Recording... swipe up to send' + : isTogglingRecording || isVoiceHoldActive + ? 'Starting microphone...' + : null + const voiceFeedbackToneClasses = isVoiceSwipeArmed || isVoiceAutoSendPending + ? 'border-blue-400/60 bg-blue-500/90 text-white shadow-blue-500/30' + : 'border-red-500/60 bg-red-600/90 text-white shadow-red-500/30' + const voiceFeedbackGlowClasses = isVoiceSwipeArmed || isVoiceAutoSendPending ? 'bg-blue-500/30' : 'bg-red-500/25' + const voiceButtonTitle = isProcessing + ? 'Transcribing speech' + : isRecording + ? isVoiceSwipeArmed + ? 'Release to send' + : 'Release to transcribe' + : 'Hold to speak' + + const renderVoiceStatusOverlay = () => { + if (!showVoiceFeedback || !voiceFeedbackLabel) { + return null + } + + return ( + <> +
+
+ {voiceFeedbackLabel} +
+ + ) + } @@ -753,11 +979,12 @@ const { model, modelString } = useModelSelection(opcodeUrl, directory) onPromptChange?.(prompt.trim().length > 0) }, [prompt, onPromptChange]) - useEffect(() => { + useEffect(() => { if (isRecording) { abortRecording() } clearSTT() + resetVoiceGestureState() lastAddedTranscriptRef.current = '' setIsTogglingRecording(false) setLocalMode(null) @@ -861,13 +1088,23 @@ return (
- + {isMobile && showScrollButton ? ( + + ) : !isMobile ? ( + + ) : null} {showStopButton && ( {sttEnabled && sttSupported && ( - +
+ {renderVoiceStatusOverlay()} + +
+ )} + {isMobile && !showScrollButton && sttEnabled && sttSupported && !hasPendingPermissionForSession && ( +
+ {renderVoiceStatusOverlay()} + +
)} - {isMobile && !prompt.trim() && imageAttachments.length === 0 && sttEnabled && sttSupported && !hasPendingPermissionForSession ? ( - - ) : ( - )}
diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index decc1797..677b26dd 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -100,7 +100,7 @@ const ModelCard = memo(function ModelCard({ ? "bg-blue-600/20 border-blue-500" : "bg-card border-border hover:bg-accent" }`} - onClick={() => onSelect(provider.id, model.id)} + onClick={() => onSelect(provider.id, model.key || model.id)} >
@@ -321,7 +321,7 @@ export function ModelSelectDialog({ provider.models.map((model) => ({ model, provider, - modelKey: `${provider.id}/${model.id}`, + modelKey: `${provider.id}/${model.key || model.id}`, })) ); }, [connectedProviders]); @@ -347,7 +347,7 @@ export function ModelSelectDialog({ if (search) { filtered = filtered.filter((item) => item.model.name.toLowerCase().includes(search) || - item.model.id.toLowerCase().includes(search) || + (item.model.key || item.model.id).toLowerCase().includes(search) || item.provider.name.toLowerCase().includes(search) ); } diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx new file mode 100644 index 00000000..a99fa050 --- /dev/null +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -0,0 +1,232 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MobileTabBar } from './MobileTabBar' +import { useMobile } from '@/hooks/useMobile' +import { usePermissions, useQuestions } from '@/contexts/EventContext' + +vi.mock('@/hooks/useMobile') +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: vi.fn(), + useQuestions: vi.fn(), +})) +vi.mock('@/components/file-browser/FileBrowserSheet', () => ({ + FileBrowserSheet: ({ isOpen }: { isOpen: boolean }) => isOpen ?
FileBrowserSheet
: null, +})) +vi.mock('@/components/navigation/RepoQuickSwitchSheet', () => ({ + RepoQuickSwitchSheet: ({ isOpen }: { isOpen: boolean }) => isOpen ?
RepoQuickSwitchSheet
: null, +})) +vi.mock('@/components/navigation/NotificationsSheet', () => ({ + NotificationsSheet: ({ isOpen }: { isOpen: boolean }) => isOpen ?
NotificationsSheet
: null, +})) +vi.mock('@/components/navigation/MoreDrawer', () => ({ + MoreDrawer: ({ isOpen }: { isOpen: boolean }) => isOpen ?
MoreDrawer
: null, +})) + +describe('MobileTabBar', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 0, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 0, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + }) + + it('renders nothing when useMobile returns false', () => { + vi.mocked(useMobile).mockReturnValue(false) + const queryClient = new QueryClient() + const { container } = render( + + + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when pathname is not / or /schedules', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + const { container } = render( + + + + + , + ) + expect(container.firstChild).toBeNull() + }) + + it('renders tab bar on root path', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Files')).toBeInTheDocument() + expect(screen.getByText('Alerts')).toBeInTheDocument() + expect(screen.getByText('Schedules')).toBeInTheDocument() + expect(screen.getByText('More')).toBeInTheDocument() + }) + + it('renders tab bar on /schedules path', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + expect(screen.getByText('Repos')).toBeInTheDocument() + expect(screen.getByText('Schedules')).toBeInTheDocument() + }) + + it('shows badge when permissionCount + questionCount > 0', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 2, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 1, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + render( + + + + + , + ) + const badge = document.querySelector('.bg-orange-500') + expect(badge).toBeInTheDocument() + }) + + it('does not show badge when both counts are 0', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 0, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 0, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + render( + + + + + , + ) + const badge = document.querySelector('.bg-orange-500') + expect(badge).not.toBeInTheDocument() + }) + + it('opens repos sheet when Repos tab is clicked', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + fireEvent.click(screen.getByText('Repos')) + expect(screen.getByTestId('repo-quick-switch-sheet')).toBeInTheDocument() + }) + + it('opens files sheet when Files tab is clicked', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + fireEvent.click(screen.getByText('Files')) + expect(screen.getByTestId('file-browser-sheet')).toBeInTheDocument() + }) + + it('opens notifications sheet when Alerts tab is clicked', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + fireEvent.click(screen.getByText('Alerts')) + expect(screen.getByTestId('notifications-sheet')).toBeInTheDocument() + }) + + it('opens more drawer when More tab is clicked', () => { + vi.mocked(useMobile).mockReturnValue(true) + const queryClient = new QueryClient() + render( + + + + + , + ) + fireEvent.click(screen.getByText('More')) + expect(screen.getByTestId('more-drawer')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx new file mode 100644 index 00000000..f24fd95b --- /dev/null +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -0,0 +1,125 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { FolderGit2, FolderOpen, Bell, CalendarClock, Menu } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useMobile } from '@/hooks/useMobile' +import { useMobileTabBar } from '@/hooks/useMobileTabBar' +import { usePermissions, useQuestions } from '@/contexts/EventContext' +import { FileBrowserSheet } from '@/components/file-browser/FileBrowserSheet' +import { RepoQuickSwitchSheet } from '@/components/navigation/RepoQuickSwitchSheet' +import { NotificationsSheet } from '@/components/navigation/NotificationsSheet' +import { MoreDrawer } from '@/components/navigation/MoreDrawer' + +interface TabDef { + key: string + label: string + icon: React.ElementType + onClick: () => void + active: boolean + badge?: number +} + +export function MobileTabBar() { + const isMobile = useMobile() + const location = useLocation() + const navigate = useNavigate() + const { openSheet, open, close } = useMobileTabBar() + const { pendingCount: permissionCount } = usePermissions() + const { pendingCount: questionCount } = useQuestions() + const totalPending = permissionCount + questionCount + + if (!isMobile) return null + + const allow = location.pathname === '/' || location.pathname === '/schedules' + if (!allow) return null + + const tabs: TabDef[] = [ + { + key: 'repos', + label: 'Repos', + icon: FolderGit2, + onClick: () => open('repos'), + active: openSheet === 'repos' || location.pathname === '/', + }, + { + key: 'files', + label: 'Files', + icon: FolderOpen, + onClick: () => open('files'), + active: openSheet === 'files', + }, + { + key: 'notifications', + label: 'Alerts', + icon: Bell, + onClick: () => open('notifications'), + badge: totalPending, + active: openSheet === 'notifications', + }, + { + key: 'schedules', + label: 'Schedules', + icon: CalendarClock, + onClick: () => navigate('/schedules'), + active: location.pathname === '/schedules' && !openSheet, + }, + { + key: 'more', + label: 'More', + icon: Menu, + onClick: () => open('more'), + active: openSheet === 'more', + }, + ] + + return ( + <> +
+ {tabs.map((tab) => { + const Icon = tab.icon + return ( + + ) + })} +
+ + {openSheet === 'repos' && ( + + )} + + {openSheet === 'files' && ( + + )} + + {openSheet === 'notifications' && ( + + )} + + {openSheet === 'more' && ( + + )} + + ) +} diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx new file mode 100644 index 00000000..33d925a3 --- /dev/null +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -0,0 +1,193 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { MoreDrawer } from './MoreDrawer' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSettings } from '@/hooks/useSettings' +import { useAuth } from '@/hooks/useAuth' + +vi.mock('@/hooks/useSettingsDialog') +vi.mock('@/hooks/useSettings') +vi.mock('@/hooks/useAuth') + +describe('MoreDrawer', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders all menu items', () => { + vi.mocked(useSettingsDialog).mockReturnValue({ + isOpen: false, + open: vi.fn(), + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + setActiveTab: vi.fn(), + }) + vi.mocked(useSettings).mockReturnValue({ + settings: undefined, + preferences: undefined, + isLoading: false, + error: null, + updateSettings: vi.fn(), + updateSettingsAsync: vi.fn(), + resetSettings: vi.fn(), + isUpdating: false, + isResetting: false, + }) + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + config: null, + signInWithEmail: vi.fn(), + signInWithProvider: vi.fn(), + signInWithPasskey: vi.fn(), + signUpWithEmail: vi.fn(), + addPasskey: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + expect(screen.getByText('Settings')).toBeInTheDocument() + expect(screen.getByText('Theme')).toBeInTheDocument() + expect(screen.getByText('Light')).toBeInTheDocument() + expect(screen.getByText('Dark')).toBeInTheDocument() + expect(screen.getByText('System')).toBeInTheDocument() + expect(screen.getByText('Logout')).toBeInTheDocument() + }) + + it('calls useSettingsDialog.open and onClose when Settings is clicked', () => { + const openSettingsMock = vi.fn() + vi.mocked(useSettingsDialog).mockReturnValue({ + isOpen: false, + open: openSettingsMock, + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + setActiveTab: vi.fn(), + }) + vi.mocked(useSettings).mockReturnValue({ + settings: undefined, + preferences: undefined, + isLoading: false, + error: null, + updateSettings: vi.fn(), + updateSettingsAsync: vi.fn(), + resetSettings: vi.fn(), + isUpdating: false, + isResetting: false, + }) + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + config: null, + signInWithEmail: vi.fn(), + signInWithProvider: vi.fn(), + signInWithPasskey: vi.fn(), + signUpWithEmail: vi.fn(), + addPasskey: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + fireEvent.click(screen.getByText('Settings')) + expect(openSettingsMock).toHaveBeenCalled() + expect(handleClose).toHaveBeenCalled() + }) + + it('calls logout when Logout is clicked', async () => { + const logoutMock = vi.fn().mockResolvedValue(undefined) + vi.mocked(useSettingsDialog).mockReturnValue({ + isOpen: false, + open: vi.fn(), + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + setActiveTab: vi.fn(), + }) + vi.mocked(useSettings).mockReturnValue({ + settings: undefined, + preferences: undefined, + isLoading: false, + error: null, + updateSettings: vi.fn(), + updateSettingsAsync: vi.fn(), + resetSettings: vi.fn(), + isUpdating: false, + isResetting: false, + }) + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + config: null, + signInWithEmail: vi.fn(), + signInWithProvider: vi.fn(), + signInWithPasskey: vi.fn(), + signUpWithEmail: vi.fn(), + addPasskey: vi.fn(), + logout: logoutMock, + refreshSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + fireEvent.click(screen.getByText('Logout')) + expect(logoutMock).toHaveBeenCalled() + }) + + it('calls updateSettings with theme when theme button is clicked', () => { + const updateSettingsMock = vi.fn() + vi.mocked(useSettingsDialog).mockReturnValue({ + isOpen: false, + open: vi.fn(), + close: vi.fn(), + toggle: vi.fn(), + activeTab: 'account', + setActiveTab: vi.fn(), + }) + vi.mocked(useSettings).mockReturnValue({ + settings: undefined, + preferences: { theme: 'dark' }, + isLoading: false, + error: null, + updateSettings: updateSettingsMock, + updateSettingsAsync: vi.fn(), + resetSettings: vi.fn(), + isUpdating: false, + isResetting: false, + }) + vi.mocked(useAuth).mockReturnValue({ + user: null, + isAuthenticated: false, + isLoading: false, + config: null, + signInWithEmail: vi.fn(), + signInWithProvider: vi.fn(), + signInWithPasskey: vi.fn(), + signUpWithEmail: vi.fn(), + addPasskey: vi.fn(), + logout: vi.fn(), + refreshSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + fireEvent.click(screen.getByText('Light')) + expect(updateSettingsMock).toHaveBeenCalledWith({ theme: 'light' }) + }) +}) diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx new file mode 100644 index 00000000..00261b97 --- /dev/null +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -0,0 +1,111 @@ +import { useSettingsDialog } from '@/hooks/useSettingsDialog' +import { useSettings } from '@/hooks/useSettings' +import { useAuth } from '@/hooks/useAuth' +import { SideDrawer, SideDrawerHeader, SideDrawerContent } from '@/components/ui/side-drawer' +import { Settings, LogOut, Info, Moon, Sun, Monitor } from 'lucide-react' + +interface MoreDrawerProps { + isOpen: boolean + onClose: () => void +} + +export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { + const { open: openSettingsDialog } = useSettingsDialog() + const { preferences, updateSettings } = useSettings() + const { logout } = useAuth() + + const handleSettingsClick = () => { + openSettingsDialog() + onClose() + } + + const handleLogoutClick = async () => { + try { + await logout() + } finally { + onClose() + } + } + + const handleThemeChange = (theme: 'light' | 'dark' | 'system') => { + updateSettings({ theme }) + } + + const currentTheme = preferences?.theme || 'dark' + + return ( + + + + + +
+

Theme

+
+ + + +
+
+ + + +
+
+ + + {import.meta.env.VITE_APP_VERSION ? `v${import.meta.env.VITE_APP_VERSION}` : 'OpenCode'} + +
+
+
+
+ ) +} diff --git a/frontend/src/components/navigation/NotificationsSheet.test.tsx b/frontend/src/components/navigation/NotificationsSheet.test.tsx new file mode 100644 index 00000000..a01e7c7b --- /dev/null +++ b/frontend/src/components/navigation/NotificationsSheet.test.tsx @@ -0,0 +1,214 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { NotificationsSheet } from './NotificationsSheet' +import { usePermissions, useQuestions } from '@/contexts/EventContext' + +vi.mock('@/contexts/EventContext') + +describe('NotificationsSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders empty state when both counts are 0', () => { + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 0, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 0, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + expect(screen.getAllByText("You're all caught up").length).toBe(2) + }) + + it('renders permission section with pending count', () => { + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 2, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: { + id: 'perm-1', + sessionID: 'session-1', + permission: 'write', + patterns: ['/test/pattern'], + metadata: {}, + always: [], + tool: { + messageID: 'msg-1', + callID: 'call-1', + }, + }, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 0, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + expect(screen.getByText('Pending permissions')).toBeInTheDocument() + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + + it('renders question section with pending count', () => { + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 0, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 1, + navigateToCurrent: vi.fn(), + current: { + id: 'q-1', + sessionID: 'session-1', + questions: [{ + question: 'Test question', + options: [], + }], + metadata: {}, + tool: { + messageID: 'msg-1', + callID: 'call-1', + }, + }, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + const handleClose = vi.fn() + render( + , + { wrapper: ({ children }) => {children} }, + ) + expect(screen.getByText('Pending questions')).toBeInTheDocument() + expect(screen.getByText('Test question')).toBeInTheDocument() + }) + + it('calls setShowDialog and onClose when permission is clicked', () => { + const setShowDialogMock = vi.fn() + const navigateToPermissionMock = vi.fn() + const handleClose = vi.fn() + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 1, + setShowDialog: setShowDialogMock, + navigateToCurrent: navigateToPermissionMock, + current: { + id: 'perm-1', + sessionID: 'session-1', + permission: 'write', + patterns: ['/test/pattern'], + metadata: {}, + always: [], + tool: { + messageID: 'msg-1', + callID: 'call-1', + }, + }, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 0, + navigateToCurrent: vi.fn(), + current: null, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + render( + , + { wrapper: ({ children }) => {children} }, + ) + fireEvent.click(screen.getByText('/test/pattern')) + expect(navigateToPermissionMock).toHaveBeenCalled() + expect(setShowDialogMock).toHaveBeenCalled() + expect(handleClose).toHaveBeenCalled() + }) + + it('calls navigateToCurrent and onClose when question is clicked', () => { + const navigateToQuestionMock = vi.fn() + const handleClose = vi.fn() + vi.mocked(usePermissions).mockReturnValue({ + pendingCount: 0, + setShowDialog: vi.fn(), + navigateToCurrent: vi.fn(), + current: null, + respond: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + vi.mocked(useQuestions).mockReturnValue({ + pendingCount: 1, + navigateToCurrent: navigateToQuestionMock, + current: { + id: 'q-1', + sessionID: 'session-1', + questions: [{ + question: 'Test question', + options: [], + }], + metadata: {}, + tool: { + messageID: 'msg-1', + callID: 'call-1', + }, + }, + reply: vi.fn(), + reject: vi.fn(), + dismiss: vi.fn(), + getForCallID: vi.fn(), + hasForSession: vi.fn(), + }) + render( + , + { wrapper: ({ children }) => {children} }, + ) + fireEvent.click(screen.getByText('Test question')) + expect(navigateToQuestionMock).toHaveBeenCalled() + expect(handleClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/navigation/NotificationsSheet.tsx b/frontend/src/components/navigation/NotificationsSheet.tsx new file mode 100644 index 00000000..1529084d --- /dev/null +++ b/frontend/src/components/navigation/NotificationsSheet.tsx @@ -0,0 +1,108 @@ +import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' +import { usePermissions, useQuestions } from '@/contexts/EventContext' +import { Bell, HelpCircle } from 'lucide-react' + +interface NotificationsSheetProps { + isOpen: boolean + onClose: () => void +} + +export function NotificationsSheet({ isOpen, onClose }: NotificationsSheetProps) { + const { + current: currentPermission, + pendingCount: permissionCount, + setShowDialog, + navigateToCurrent: navigateToPermission, + } = usePermissions() + const { + current: currentQuestion, + pendingCount: questionCount, + navigateToCurrent: navigateToQuestion, + } = useQuestions() + + const handlePermissionClick = () => { + navigateToPermission() + setShowDialog(true) + onClose() + } + + const handleQuestionClick = () => { + navigateToQuestion() + onClose() + } + + return ( + + + +
+
+ +

Pending permissions

+
+ {permissionCount === 0 ? ( +
+ You're all caught up +
+ ) : ( +
+ {currentPermission && ( + + )} + {permissionCount > 1 && ( +
+ +{permissionCount - 1} more +
+ )} +
+ )} +
+ +
+
+ +

Pending questions

+
+ {questionCount === 0 ? ( +
+ You're all caught up +
+ ) : ( +
+ {currentQuestion && ( + + )} + {questionCount > 1 && ( +
+ +{questionCount - 1} more +
+ )} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx new file mode 100644 index 00000000..9a6700fa --- /dev/null +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' +import { listRepos } from '@/api/repos' + +vi.mock('@/api/repos') + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: React.ReactNode }) => ( + + + {children} + + + ) +} + +describe('RepoQuickSwitchSheet', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders with loading state', async () => { + vi.mocked(listRepos).mockImplementation(() => new Promise(() => {})) + const handleClose = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + expect(screen.getByText('Switch repo')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByPlaceholderText('Search repos...')).toBeInTheDocument() + }) + }) + + it('renders empty state when no repos', async () => { + vi.mocked(listRepos).mockResolvedValue([]) + const handleClose = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + await waitFor(() => { + expect(screen.getByText('No repos found')).toBeInTheDocument() + }) + }) + + it('renders repo list and filters on search', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + { + id: 2, + repoUrl: 'https://github.com/test/repo2.git', + localPath: '/path/to/repo2', + sourcePath: null, + currentBranch: 'develop', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.getByText('repo2')).toBeInTheDocument() + }) + const input = screen.getByPlaceholderText('Search repos...') + fireEvent.change(input, { target: { value: 'repo1' } }) + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + expect(screen.queryByText('repo2')).not.toBeInTheDocument() + }) + }) + + it('navigates and closes on repo click', async () => { + vi.mocked(listRepos).mockResolvedValue([ + { + id: 1, + repoUrl: 'https://github.com/test/repo1.git', + localPath: '/path/to/repo1', + sourcePath: null, + currentBranch: 'main', + isLocal: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]) + const handleClose = vi.fn() + render( + , + { wrapper: createWrapper() }, + ) + await waitFor(() => { + expect(screen.getByText('repo1')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('repo1')) + expect(handleClose).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx new file mode 100644 index 00000000..87992e69 --- /dev/null +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -0,0 +1,87 @@ +import { useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Input } from '@/components/ui/input' +import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' +import { getRepoDisplayName } from '@/lib/utils' +import { listRepos } from '@/api/repos' + +interface RepoQuickSwitchSheetProps { + isOpen: boolean + onClose: () => void +} + +export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetProps) { + const navigate = useNavigate() + const [searchQuery, setSearchQuery] = useState('') + + const { data: repos, isLoading } = useQuery({ + queryKey: ['repos'], + queryFn: listRepos, + enabled: isOpen, + }) + + const filteredRepos = useMemo(() => { + if (!repos) return [] + if (!searchQuery.trim()) return repos + const query = searchQuery.toLowerCase() + return repos.filter((repo) => { + const displayName = getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath).toLowerCase() + return displayName.includes(query) + }) + }, [repos, searchQuery]) + + const handleClick = (id: number) => { + navigate(`/repos/${id}`) + onClose() + } + + return ( + + + + setSearchQuery(e.target.value)} + autoFocus + /> + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : filteredRepos.length === 0 ? ( +
+ No repos found +
+ ) : ( +
+ {filteredRepos.map((repo) => ( + + ))} +
+ )} + + + ) +} diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx index 0c8a11a8..689ce673 100644 --- a/frontend/src/components/repo/AddRepoDialog.tsx +++ b/frontend/src/components/repo/AddRepoDialog.tsx @@ -4,6 +4,7 @@ import { createRepo, discoverRepos } from '@/api/repos' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Loader2 } from 'lucide-react' import { showToast } from '@/lib/toast' import type { DiscoverReposResponse } from '@opencode-manager/shared/types' @@ -99,53 +100,22 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { return ( - - + + Add Repository -
+
-
- - - -
+ setRepoType(value as 'remote' | 'local' | 'folder')}> + + Remote + Local + Folder + +
{repoType === 'remote' ? ( @@ -156,7 +126,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { value={repoUrl} onChange={(e) => handleRepoUrlChange(e.target.value)} disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500" + className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

Full URL or shorthand format (owner/repo for GitHub) @@ -170,10 +140,10 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { value={localPath} onChange={(e) => setLocalPath(e.target.value)} disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500" + className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

- Directory name for a new repo, or an absolute path to link an existing Git repo in place so its OpenCode sessions stay attached + Directory name for a new repo, or an absolute path to link an existing Git repo

) : ( @@ -184,57 +154,49 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { value={folderPath} onChange={(e) => setFolderPath(e.target.value)} disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500" + className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

- Scans the folder for nested Git repositories and links each one in place so existing OpenCode sessions show up immediately + Scans the folder for nested Git repositories and links each one

)}
- + setBranch(e.target.value)} disabled={mutation.isPending || repoType === 'folder'} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500" + className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" />

- {repoType === 'folder' - ? 'Folder discovery links each repository on its current branch' + {repoType === 'folder' + ? 'Links each repository on its current branch' : branch - ? repoType === 'remote' - ? `Clones repository directly to '${branch}' branch` - : localPath?.startsWith('/') - ? `Links the repo in place and checks out '${branch}' branch` - : `Initializes repository with '${branch}' branch` - : repoType === 'remote' - ? "Clones repository to default branch" - : localPath?.startsWith('/') - ? 'Links the repo in place and keeps its current branch' - : "Initializes repository with 'main' branch" + ? `Uses '${branch}' branch` + : 'Uses default branch' }

{showSkipSSHCheckbox && ( -
+
setSkipSSHVerification(e.target.checked)} disabled={mutation.isPending} - className="mt-1 h-4 w-4 rounded border-[#2a2a2a] bg-[#1a1a1a] text-blue-600 focus:ring-blue-600" + className="mt-1 h-5 w-5 rounded border-[#2a2a2a] bg-[#1a1a1a] text-blue-600 focus:ring-blue-600" />

- Auto-accept the SSH host key. Use for self-hosted or internal Git servers. + Auto-accept the SSH host key for self-hosted or internal servers

@@ -243,7 +205,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { - - - - -
+ {activityLabel && ( + + {activityLabel} + + )}
- - setShowSourceControl(false)} - currentBranch={branchToDisplay || ""} - repoUrl={repo.repoUrl} - isRepoWorktree={repo.isWorktree} - repoName={repoName} - /> - ); -} +} \ No newline at end of file diff --git a/frontend/src/components/repo/RepoList.tsx b/frontend/src/components/repo/RepoList.tsx index 2a988f6a..48089f84 100644 --- a/frontend/src/components/repo/RepoList.tsx +++ b/frontend/src/components/repo/RepoList.tsx @@ -6,14 +6,44 @@ import { CSS } from "@dnd-kit/utilities" import { listRepos, deleteRepo, updateRepoOrder } from "@/api/repos" import { fetchReposGitStatus } from "@/api/git" import { DeleteDialog } from "@/components/ui/delete-dialog" -import { ListToolbar } from "@/components/ui/list-toolbar" import { GitBranch, Search, GripVertical } from "lucide-react" import type { Repo } from "@/api/types" import type { GitStatusResponse } from "@/types/git" import { RepoCard } from "./RepoCard" import { RepoCardSkeleton } from "./RepoCardSkeleton" import { useMobile } from "@/hooks/useMobile" -import { getRepoDisplayName } from "@/lib/utils" +import { useSettings } from "@/hooks/useSettings" +import { + buildRepoViewModels, + filterReposBySearch, + filterReposByMode, + sortRepos, + groupReposIntoSections, + countAttentionItems, + type RepoFilterMode, + type RepoSortMode, +} from "./repo-list-state" +import { RepoListControls } from "./RepoListControls" + +function formatActivityLabel(timestamp: number): string { + const now = Date.now() + const diff = now - timestamp + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) { + return days === 1 ? '1d ago' : `${days}d ago` + } + if (hours > 0) { + return hours === 1 ? '1h ago' : `${hours}h ago` + } + if (minutes > 0) { + return minutes === 1 ? '1m ago' : `${minutes}m ago` + } + return 'just now' +} interface RepoCardWrapperProps { repo: Repo @@ -22,6 +52,10 @@ interface RepoCardWrapperProps { isSelected: boolean onSelect: (id: number, selected: boolean) => void gitStatus?: GitStatusResponse + manageMode: boolean + isMobile: boolean + activityLabel?: string + hasSelectedRepos?: boolean } function SortableRepoCard({ @@ -31,7 +65,12 @@ function SortableRepoCard({ isSelected, onSelect, gitStatus, -}: RepoCardWrapperProps) { + manageMode, + isMobile, + activityLabel, + hasSelectedRepos, + isManualSort, +}: RepoCardWrapperProps & { isManualSort: boolean }) { const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: repo.id }) const style = { @@ -43,15 +82,17 @@ function SortableRepoCard({ return (
-
- -
-
+ {isManualSort && ( +
+ +
+ )} +
@@ -73,6 +118,10 @@ function StaticRepoCard({ isSelected, onSelect, gitStatus, + manageMode, + isMobile, + activityLabel, + hasSelectedRepos, }: RepoCardWrapperProps) { return ( ) } @@ -89,13 +142,23 @@ function StaticRepoCard({ export function RepoList() { const queryClient = useQueryClient() const isMobile = useMobile() + const { preferences, updateSettings } = useSettings() const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [repoToDelete, setRepoToDelete] = useState(null) const [selectedRepos, setSelectedRepos] = useState>(new Set()) const [searchQuery, setSearchQuery] = useState("") - const [reorderMode, setReorderMode] = useState(false) - - const isDragEnabled = !isMobile || reorderMode + const [filterMode, setFilterMode] = useState('recent') + const manageMode = selectedRepos.size > 0 + + const sortMode = (preferences?.repoSortMode as RepoSortMode) || 'recent' + const repoOrder = preferences?.repoOrder + + const handleSortModeChange = (newSortMode: RepoSortMode) => { + updateSettings({ repoSortMode: newSortMode }) + } + + const isManualSort = sortMode === 'manual' + const isDragEnabled = !isMobile || (isManualSort && selectedRepos.size === 0) const { data: repos, @@ -129,6 +192,28 @@ export function RepoList() { gcTime: 60 * 60 * 1000, }) + const viewModels = useMemo(() => { + if (!repos) return [] + return buildRepoViewModels(repos, gitStatuses) + }, [repos, gitStatuses]) + + const filteredViewModels = useMemo(() => { + const searched = filterReposBySearch(viewModels, searchQuery) + return filterReposByMode(searched, filterMode) + }, [viewModels, searchQuery, filterMode]) + + const sortedViewModels = useMemo(() => { + return sortRepos(filteredViewModels, sortMode, repoOrder) + }, [filteredViewModels, sortMode, repoOrder]) + + const sections = useMemo(() => { + return groupReposIntoSections(sortedViewModels, filterMode, sortMode) + }, [sortedViewModels, filterMode, sortMode]) + + const attentionCount = useMemo(() => { + return countAttentionItems(viewModels) + }, [viewModels]) + const deleteMutation = useMutation({ mutationFn: deleteRepo, onSuccess: () => { @@ -252,30 +337,6 @@ export function RepoList() { ) } - const dedupedRepos = repos.reduce((acc, repo) => { - if (repo.isWorktree) { - acc.push(repo) - } else { - const key = repo.repoUrl || repo.sourcePath || repo.localPath - const existing = acc.find((r) => (r.repoUrl || r.sourcePath || r.localPath) === key && !r.isWorktree) - - if (!existing) { - acc.push(repo) - } - } - - return acc - }, [] as Repo[]) - - const filteredRepos = dedupedRepos.filter((repo) => { - const repoName = getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath) - const searchTarget = repo.repoUrl || repo.sourcePath || repo.localPath || "" - return ( - repoName.toLowerCase().includes(searchQuery.toLowerCase()) || - searchTarget.toLowerCase().includes(searchQuery.toLowerCase()) - ) - }) - const handleSelectRepo = (id: number, selected: boolean) => { const newSelected = new Set(selectedRepos) if (selected) { @@ -287,59 +348,53 @@ export function RepoList() { } const handleSelectAll = () => { - const allFilteredSelected = filteredRepos.every((repo) => - selectedRepos.has(repo.id), - ) - - if (allFilteredSelected) { - setSelectedRepos(new Set()) + const visibleIds = sortedViewModels.map(r => r.id) + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every(id => selectedRepos.has(id)) + + if (allVisibleSelected) { + // Unselect only the visible repos, keep hidden ones + const newSelected = new Set(selectedRepos) + visibleIds.forEach(id => newSelected.delete(id)) + setSelectedRepos(newSelected) } else { - const filteredIds = filteredRepos.map((repo) => repo.id) - setSelectedRepos(new Set([...selectedRepos, ...filteredIds])) + // Add all visible repos to existing selection (preserves hidden selections) + const newSelected = new Set(selectedRepos) + visibleIds.forEach(id => newSelected.add(id)) + setSelectedRepos(newSelected) } } - const handleBatchDelete = () => { - if (selectedRepos.size > 0) { - setDeleteDialogOpen(true) - } - } - - const handleDeleteAll = () => { - if (filteredRepos.length === 0) return - setSelectedRepos(new Set(filteredRepos.map((r) => r.id))) - setDeleteDialogOpen(true) - } - return ( <>
-
- 0 && - filteredRepos.every((repo) => selectedRepos.has(repo.id)) - } - onToggleSelectAll={handleSelectAll} - onDelete={handleBatchDelete} - onDeleteAll={handleDeleteAll} - reorderMode={reorderMode} - onToggleReorderMode={() => setReorderMode((m) => !m)} - showReorderToggle={isMobile} - /> -
+ 0 && sortedViewModels.every(r => selectedRepos.has(r.id))} + onSelectAll={handleSelectAll} + onClearSelection={() => setSelectedRepos(new Set())} + onDelete={() => { + setRepoToDelete(null) + setDeleteDialogOpen(true) + }} + hasLocalRepos={hasLocalRepos} + hasClonedRepos={hasClonedRepos} + />
- {filteredRepos.length === 0 ? ( + {sortedViewModels.length === 0 ? (

- No repositories found matching "{searchQuery}" + {sections[0]?.emptyMessage || `No repositories found${searchQuery ? ` matching "${searchQuery}"` : ''}`}

) : isDragEnabled ? ( @@ -348,9 +403,9 @@ export function RepoList() { collisionDetection={closestCenter} onDragEnd={handleDragEnd} > - r.id)} strategy={verticalListSortingStrategy}> + r.id)} strategy={verticalListSortingStrategy}>
- {filteredRepos.map((repo) => ( + {sortedViewModels.map((repo) => ( 0} /> ))}
@@ -371,7 +431,7 @@ export function RepoList() { ) : (
- {filteredRepos.map((repo) => ( + {sortedViewModels.map((repo) => ( 0} /> ))}
@@ -397,16 +461,15 @@ export function RepoList() { open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} onConfirm={() => { - if (repoToDelete) { - deleteMutation.mutate(repoToDelete) - } else if (selectedRepos.size > 0) { + if (selectedRepos.size > 0) { batchDeleteMutation.mutate(Array.from(selectedRepos)) + } else if (repoToDelete) { + deleteMutation.mutate(repoToDelete) } }} onCancel={() => { setDeleteDialogOpen(false) setRepoToDelete(null) - setSelectedRepos(new Set()) }} title={ selectedRepos.size > 0 diff --git a/frontend/src/components/repo/RepoListControls.tsx b/frontend/src/components/repo/RepoListControls.tsx new file mode 100644 index 00000000..5397d5f4 --- /dev/null +++ b/frontend/src/components/repo/RepoListControls.tsx @@ -0,0 +1,235 @@ +import { useState } from 'react' +import { Search, SlidersHorizontal, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useMobile } from '@/hooks/useMobile' +import type { RepoFilterMode, RepoSortMode } from './repo-list-state' + +interface RepoListControlsProps { + searchQuery: string + onSearchChange: (query: string) => void + filterMode: RepoFilterMode + onFilterModeChange: (mode: RepoFilterMode) => void + sortMode: RepoSortMode + onSortModeChange: (mode: RepoSortMode) => void + filteredCount: number + attentionCount: number + selectedCount: number + allVisibleSelected: boolean + onSelectAll: () => void + onClearSelection: () => void + onDelete: () => void + hasLocalRepos: boolean + hasClonedRepos: boolean +} + +const FILTER_OPTIONS: { value: RepoFilterMode; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'recent', label: 'Recent' }, + { value: 'attention', label: 'Changes' }, + { value: 'worktrees', label: 'Worktrees' }, + { value: 'local', label: 'Local' }, +] + +const SORT_OPTIONS: { value: RepoSortMode; label: string }[] = [ + { value: 'recent', label: 'Recent' }, + { value: 'manual', label: 'Manual' }, + { value: 'name', label: 'Name' }, +] + +export function RepoListControls({ + searchQuery, + onSearchChange, + filterMode, + onFilterModeChange, + sortMode, + onSortModeChange, + filteredCount, + attentionCount, + selectedCount, + allVisibleSelected, + onSelectAll, + onClearSelection, + onDelete, + hasLocalRepos, + hasClonedRepos, +}: RepoListControlsProps) { + const isMobile = useMobile() + const [showMenu, setShowMenu] = useState(false) + + const currentSortLabel = SORT_OPTIONS.find((s) => s.value === sortMode)?.label ?? 'Recent' + const inSelectionMode = selectedCount > 0 + + const getDeleteLabel = () => { + if (hasLocalRepos && !hasClonedRepos) { + return 'Unlink' + } + return 'Delete' + } + + if (inSelectionMode) { + return ( +
+
+ + {selectedCount} selected + + + + +
+
+ ) + } + + return ( +
+
+
+ + onSearchChange(e.target.value)} + placeholder="Search repositories..." + className="pl-9 h-9" + /> +
+ + {isMobile ? ( + + + + + + {FILTER_OPTIONS.map((option) => { + const count = + option.value === 'attention' + ? attentionCount + : option.value === 'all' + ? filteredCount + : undefined + + return ( + { + onFilterModeChange(option.value) + setShowMenu(false) + }} + className={filterMode === option.value ? 'bg-accent' : ''} + > + {option.label} + {count !== undefined && count > 0 && ( + + {count} + + )} + + ) + })} + + + ) : ( + + + + + + {SORT_OPTIONS.map((option) => ( + { + onSortModeChange(option.value) + setShowMenu(false) + }} + className={sortMode === option.value ? 'bg-accent' : ''} + > + {option.label} + + ))} + + + )} +
+ + {!isMobile && ( +
+ {FILTER_OPTIONS.map((option) => { + const count = + option.value === 'attention' + ? attentionCount + : option.value === 'all' + ? filteredCount + : undefined + + return ( + + ) + })} +
+ )} + + {!isMobile && ( +
+ + {filteredCount} {filteredCount === 1 ? 'repo' : 'repos'} + {searchQuery && ` matching "${searchQuery}"`} + + {attentionCount > 0 && filterMode !== 'attention' && ( + + {attentionCount} {attentionCount === 1 ? 'needs attention' : 'need attention'} + + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/repo/RepoRowActions.tsx b/frontend/src/components/repo/RepoRowActions.tsx new file mode 100644 index 00000000..884c8446 --- /dev/null +++ b/frontend/src/components/repo/RepoRowActions.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react' +import { Loader2, GitBranch, Download, Trash2, MoreVertical } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { SourceControlPanel } from '@/components/source-control/SourceControlPanel' +import { DownloadDialog } from '@/components/ui/download-dialog' +import { downloadRepo } from '@/api/repos' +import { showToast } from '@/lib/toast' +import { getRepoDisplayName } from '@/lib/utils' + +interface RepoRowActionsProps { + repo: { + id: number + repoUrl?: string | null + localPath?: string + sourcePath?: string + branch?: string + currentBranch?: string + cloneStatus: string + isWorktree?: boolean + isLocal?: boolean + fullPath?: string + } + gitStatus?: { + branch: string + ahead: number + behind: number + } + onDelete: (id: number) => void + isDeleting: boolean + isMobile: boolean + onActionsOpenChange?: (isOpen: boolean) => void +} + +export function RepoRowActions({ + repo, + gitStatus, + onDelete, + isDeleting, + isMobile, + onActionsOpenChange, +}: RepoRowActionsProps) { + const [showDownloadDialog, setShowDownloadDialog] = useState(false) + const [showSourceControl, setShowSourceControl] = useState(false) + + const repoName = getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath) + const branchToDisplay = gitStatus?.branch || repo.currentBranch || repo.branch + const isReady = repo.cloneStatus === 'ready' + + const handleSourceControlOpen = (open: boolean) => { + setShowSourceControl(open) + onActionsOpenChange?.(open) + } + + const handleDownloadDialogOpen = (open: boolean) => { + setShowDownloadDialog(open) + onActionsOpenChange?.(open) + } + + const handleDownload = async (options: { includeGit?: boolean; includePaths?: string[] }) => { + try { + await downloadRepo(repo.id, repoName, options) + showToast.success('Download complete') + } catch (error: unknown) { + showToast.error(error instanceof Error ? error.message : 'Download failed') + } + } + + if (isMobile) { + return ( + <> + + + + + e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + > + handleSourceControlOpen(true)} + disabled={!isReady} + > + + Source Control + + handleDownloadDialogOpen(true)} + disabled={!isReady} + > + + Download + + onDelete(repo.id)} + disabled={isDeleting} + className="text-destructive" + > + {isDeleting ? ( + + ) : ( + + )} + {repo.isLocal ? 'Unlink' : 'Delete'} + + + + + { + setShowSourceControl(false) + onActionsOpenChange?.(false) + }} + currentBranch={branchToDisplay || ''} + repoUrl={repo.repoUrl} + isRepoWorktree={repo.isWorktree} + repoName={repoName} + /> + { + setShowDownloadDialog(open) + onActionsOpenChange?.(open) + }} + onDownload={handleDownload} + title="Download Repository" + description="This will create a ZIP archive of the entire repository." + itemName={repoName} + targetPath={repo.fullPath} + /> + + ) + } + + return ( + <> +
e.stopPropagation()}> + + + + + +
+ + { setShowSourceControl(false); onActionsOpenChange?.(false); }} + currentBranch={branchToDisplay || ''} + repoUrl={repo.repoUrl} + isRepoWorktree={repo.isWorktree} + repoName={repoName} + /> + { setShowDownloadDialog(open); onActionsOpenChange?.(open); }} + onDownload={handleDownload} + title="Download Repository" + description="This will create a ZIP archive of the entire repository." + itemName={repoName} + targetPath={repo.fullPath} + /> + + ) +} \ No newline at end of file diff --git a/frontend/src/components/repo/repo-list-state.test.ts b/frontend/src/components/repo/repo-list-state.test.ts new file mode 100644 index 00000000..1685962b --- /dev/null +++ b/frontend/src/components/repo/repo-list-state.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect } from 'vitest' +import type { Repo } from '@/api/types' +import type { GitStatusResponse } from '@/types/git' +import { + getActivityTimestamp, + getAttentionState, + dedupeRepos, + filterReposBySearch, + filterReposByMode, + sortRepos, + groupReposIntoSections, + countAttentionItems, + type RepoViewModel, +} from './repo-list-state' + +const createMockRepo = (overrides: Partial = {}): Repo => ({ + id: 1, + repoUrl: 'https://github.com/test/repo', + localPath: 'repos/test-repo', + fullPath: '/Users/test/repos/test-repo', + sourcePath: undefined, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now() - 100000, + lastPulled: undefined, + lastAccessedAt: undefined, + openCodeConfigName: undefined, + isWorktree: false, + isLocal: false, + ...overrides, +}) + +const createMockGitStatus = (overrides: Partial = {}): GitStatusResponse => ({ + branch: 'main', + ahead: 0, + behind: 0, + files: [], + hasChanges: false, + ...overrides, +}) + +describe('repo-list-state', () => { + describe('getActivityTimestamp', () => { + it('should return lastAccessedAt when available', () => { + const now = Date.now() + const repo = createMockRepo({ lastAccessedAt: now }) + expect(getActivityTimestamp(repo)).toBe(now) + }) + + it('should return lastPulled when lastAccessedAt is not available', () => { + const now = Date.now() + const repo = createMockRepo({ lastAccessedAt: undefined, lastPulled: now }) + expect(getActivityTimestamp(repo)).toBe(now) + }) + + it('should return clonedAt as fallback', () => { + const clonedAt = Date.now() - 100000 + const repo = createMockRepo({ lastAccessedAt: undefined, lastPulled: undefined, clonedAt }) + expect(getActivityTimestamp(repo)).toBe(clonedAt) + }) + }) + + describe('getAttentionState', () => { + it('should return ready state for ready repos without git status', () => { + const repo = createMockRepo({ cloneStatus: 'ready' }) + const result = getAttentionState(repo, undefined) + expect(result).toEqual({ + hasChanges: false, + ahead: 0, + behind: 0, + isCloneStatusReady: true, + }) + }) + + it('should return not ready for cloning repos', () => { + const repo = createMockRepo({ cloneStatus: 'cloning' }) + const gitStatus = createMockGitStatus({ hasChanges: true, ahead: 2, behind: 1 }) + const result = getAttentionState(repo, gitStatus) + expect(result).toEqual({ + hasChanges: false, + ahead: 0, + behind: 0, + isCloneStatusReady: false, + }) + }) + + it('should return attention state from git status when ready', () => { + const repo = createMockRepo({ cloneStatus: 'ready' }) + const gitStatus = createMockGitStatus({ hasChanges: true, ahead: 3, behind: 2 }) + const result = getAttentionState(repo, gitStatus) + expect(result).toEqual({ + hasChanges: true, + ahead: 3, + behind: 2, + isCloneStatusReady: true, + }) + }) + }) + + describe('dedupeRepos', () => { + it('should preserve worktrees', () => { + const repos = [ + createMockRepo({ id: 1, isWorktree: true, localPath: 'repos/parent/.worktrees/feature1' }), + createMockRepo({ id: 2, isWorktree: true, localPath: 'repos/parent/.worktrees/feature2' }), + ] + const result = dedupeRepos(repos) + expect(result).toHaveLength(2) + }) + + it('should dedupe non-worktree repos by key', () => { + const repos = [ + createMockRepo({ id: 1, repoUrl: 'https://github.com/test/repo', localPath: 'repos/test-repo' }), + createMockRepo({ id: 2, repoUrl: 'https://github.com/test/repo', localPath: 'repos/test-repo-clone' }), + ] + const result = dedupeRepos(repos) + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + + it('should keep first occurrence when deduping', () => { + const repos = [ + createMockRepo({ id: 1, repoUrl: 'https://github.com/test/repo', localPath: 'repos/test-repo' }), + createMockRepo({ id: 2, repoUrl: 'https://github.com/test/repo', localPath: 'repos/test-repo' }), + ] + const result = dedupeRepos(repos) + expect(result[0].id).toBe(1) + }) + + it('should preserve worktrees alongside deduped repos', () => { + const parentRepo = createMockRepo({ id: 1, repoUrl: 'https://github.com/test/repo', isWorktree: false }) + const worktree = createMockRepo({ id: 2, isWorktree: true, sourcePath: '/repos/test-repo/.worktrees/feature' }) + const repos = [parentRepo, worktree] + const result = dedupeRepos(repos) + expect(result).toHaveLength(2) + }) + }) + + describe('filterReposBySearch', () => { + it('should return all repos when search is empty', () => { + const repos = [ + createMockRepo({ id: 1, localPath: 'repos/test-repo' }), + createMockRepo({ id: 2, localPath: 'repos/other-repo' }), + ] + const viewModels = repos.map(r => ({ ...r, attentionState: getAttentionState(r), activityTimestamp: getActivityTimestamp(r) })) + const result = filterReposBySearch(viewModels, '') + expect(result).toHaveLength(2) + }) + + it('should filter by display name', () => { + const repos = [ + createMockRepo({ id: 1, localPath: 'repos/test-repo' }), + createMockRepo({ id: 2, localPath: 'repos/other-repo' }), + ] + const viewModels = repos.map(r => ({ ...r, attentionState: getAttentionState(r), activityTimestamp: getActivityTimestamp(r) })) + const result = filterReposBySearch(viewModels, 'test') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + + it('should filter case-insensitively', () => { + const repos = [ + createMockRepo({ id: 1, localPath: 'repos/test-repo' }), + createMockRepo({ id: 2, localPath: 'repos/other-repo' }), + ] + const viewModels = repos.map(r => ({ ...r, attentionState: getAttentionState(r), activityTimestamp: getActivityTimestamp(r) })) + const result = filterReposBySearch(viewModels, 'TEST') + expect(result).toHaveLength(1) + }) + }) + + describe('filterReposByMode', () => { + const createViewModel = (overrides: Partial = {}): RepoViewModel => { + const repo = createMockRepo(overrides) + return { + ...repo, + attentionState: getAttentionState(repo), + activityTimestamp: getActivityTimestamp(repo), + } + } + + it('should return all repos for "all" mode', () => { + const repos = [createViewModel({ id: 1 }), createViewModel({ id: 2 })] + const result = filterReposByMode(repos, 'all') + expect(result).toHaveLength(2) + }) + + it('should filter by "recent" mode (ready repos)', () => { + const repos = [ + createViewModel({ id: 1, cloneStatus: 'ready' }), + createViewModel({ id: 2, cloneStatus: 'cloning' }), + ] + const result = filterReposByMode(repos, 'recent') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + + it('should filter by "attention" mode', () => { + const gitStatusWithChanges = createMockGitStatus({ hasChanges: true }) + const gitStatusClean = createMockGitStatus({ hasChanges: false }) + const repos = [ + { ...createViewModel({ id: 1 }), attentionState: getAttentionState(createMockRepo({ cloneStatus: 'ready' }), gitStatusWithChanges) }, + { ...createViewModel({ id: 2 }), attentionState: getAttentionState(createMockRepo({ cloneStatus: 'ready' }), gitStatusClean) }, + ] + const result = filterReposByMode(repos, 'attention') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + + it('should filter by "worktrees" mode', () => { + const repos = [ + createViewModel({ id: 1, isWorktree: true }), + createViewModel({ id: 2, isWorktree: false }), + ] + const result = filterReposByMode(repos, 'worktrees') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + + it('should filter by "local" mode', () => { + const repos = [ + createViewModel({ id: 1, isLocal: true }), + createViewModel({ id: 2, isLocal: false }), + ] + const result = filterReposByMode(repos, 'local') + expect(result).toHaveLength(1) + expect(result[0].id).toBe(1) + }) + }) + + describe('sortRepos', () => { + const createViewModel = (id: number, overrides: Partial = {}): RepoViewModel => { + const repo = createMockRepo({ id, ...overrides }) + return { + ...repo, + attentionState: getAttentionState(repo), + activityTimestamp: getActivityTimestamp(repo), + } + } + + it('should sort by recent (descending) by default', () => { + const now = Date.now() + const repos = [ + createViewModel(1, { lastAccessedAt: now - 1000 }), + createViewModel(2, { lastAccessedAt: now - 500 }), + createViewModel(3, { lastAccessedAt: now - 2000 }), + ] + const result = sortRepos(repos, 'recent') + expect(result[0].id).toBe(2) + expect(result[1].id).toBe(1) + expect(result[2].id).toBe(3) + }) + + it('should sort alphabetically by name', () => { + const repos = [ + createViewModel(1, { localPath: 'repos/zebra' }), + createViewModel(2, { localPath: 'repos/alpha' }), + createViewModel(3, { localPath: 'repos/middle' }), + ] + const result = sortRepos(repos, 'name') + expect(result[0].id).toBe(2) + expect(result[1].id).toBe(3) + expect(result[2].id).toBe(1) + }) + + it('should preserve manual order when provided', () => { + const repos = [ + createViewModel(1), + createViewModel(2), + createViewModel(3), + ] + const manualOrder = [3, 1, 2] + const result = sortRepos(repos, 'manual', manualOrder) + expect(result[0].id).toBe(3) + expect(result[1].id).toBe(1) + expect(result[2].id).toBe(2) + }) + + it('should place unordered repos at the end for manual sort', () => { + const repos = [ + createViewModel(1), + createViewModel(2), + createViewModel(3), + ] + const manualOrder = [1] + const result = sortRepos(repos, 'manual', manualOrder) + expect(result[0].id).toBe(1) + expect(result[1].id).toBe(2) + expect(result[2].id).toBe(3) + }) + + it('should return repos unchanged when manual order is empty', () => { + const repos = [createViewModel(1), createViewModel(2)] + const result = sortRepos(repos, 'manual', []) + expect(result).toEqual(repos) + }) + }) + + describe('groupReposIntoSections', () => { + const createViewModel = (id: number, overrides: Partial = {}): RepoViewModel => { + const repo = createMockRepo({ id, ...overrides }) + return { + ...repo, + attentionState: getAttentionState(repo), + activityTimestamp: overrides.activityTimestamp ?? getActivityTimestamp(repo), + } + } + + it('should return single section for manual sort mode', () => { + const repos = [createViewModel(1), createViewModel(2)] + const result = groupReposIntoSections(repos, 'all', 'manual') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('All Repositories') + }) + + it('should return attention section for attention filter', () => { + const now = Date.now() + const repos = [ + createViewModel(1, { + activityTimestamp: now, + attentionState: { hasChanges: true, ahead: 0, behind: 0, isCloneStatusReady: true } + }), + createViewModel(2, { + activityTimestamp: now, + attentionState: { hasChanges: false, ahead: 0, behind: 0, isCloneStatusReady: true } + }), + ] + const result = groupReposIntoSections(repos, 'attention', 'recent') + expect(result).toHaveLength(1) + expect(result[0].title).toBe('Needs Attention') + expect(result[0].repos).toHaveLength(1) + }) + + it('should group repos into recent and all sections', () => { + const now = Date.now() + const oneHourAgo = now - 60 * 60 * 1000 + const repos = [ + createViewModel(1, { + activityTimestamp: now, + attentionState: { hasChanges: false, ahead: 0, behind: 0, isCloneStatusReady: true } + }), + createViewModel(2, { + activityTimestamp: oneHourAgo, + attentionState: { hasChanges: false, ahead: 0, behind: 0, isCloneStatusReady: true } + }), + ] + const result = groupReposIntoSections(repos, 'all', 'recent') + expect(result.length).toBeGreaterThanOrEqual(1) + }) + }) + + describe('countAttentionItems', () => { + it('should count repos with changes, ahead, or behind', () => { + const repos = [ + { + ...createMockRepo({ id: 1 }), + attentionState: { hasChanges: true, ahead: 0, behind: 0, isCloneStatusReady: true }, + activityTimestamp: Date.now(), + }, + { + ...createMockRepo({ id: 2 }), + attentionState: { hasChanges: false, ahead: 2, behind: 0, isCloneStatusReady: true }, + activityTimestamp: Date.now(), + }, + { + ...createMockRepo({ id: 3 }), + attentionState: { hasChanges: false, ahead: 0, behind: 0, isCloneStatusReady: true }, + activityTimestamp: Date.now(), + }, + ] + expect(countAttentionItems(repos)).toBe(2) + }) + }) +}) \ No newline at end of file diff --git a/frontend/src/components/repo/repo-list-state.ts b/frontend/src/components/repo/repo-list-state.ts new file mode 100644 index 00000000..48a302e1 --- /dev/null +++ b/frontend/src/components/repo/repo-list-state.ts @@ -0,0 +1,258 @@ +import type { Repo } from "@/api/types" +import type { GitStatusResponse } from "@/types/git" +import { getRepoDisplayName } from "@/lib/utils" + +export type RepoFilterMode = 'all' | 'recent' | 'attention' | 'worktrees' | 'local' +export type RepoSortMode = 'recent' | 'manual' | 'name' + +export interface RepoAttentionState { + hasChanges: boolean + ahead: number + behind: number + isCloneStatusReady: boolean +} + +export interface RepoViewModel extends Repo { + attentionState: RepoAttentionState + activityTimestamp: number +} + +export interface RepoListSection { + title: string + repos: RepoViewModel[] + emptyMessage?: string +} + +export function getActivityTimestamp(repo: Repo): number { + return repo.lastAccessedAt ?? repo.lastPulled ?? repo.clonedAt +} + +export function getAttentionState(repo: Repo, gitStatus?: GitStatusResponse): RepoAttentionState { + if (!gitStatus || repo.cloneStatus !== 'ready') { + return { + hasChanges: false, + ahead: 0, + behind: 0, + isCloneStatusReady: repo.cloneStatus === 'ready' + } + } + + return { + hasChanges: gitStatus.hasChanges, + ahead: gitStatus.ahead, + behind: gitStatus.behind, + isCloneStatusReady: true + } +} + +export function dedupeRepos(repos: Repo[]): Repo[] { + return repos.reduce((acc, repo) => { + if (repo.isWorktree) { + acc.push(repo) + } else { + const key = repo.repoUrl || repo.sourcePath || repo.localPath + const existing = acc.find( + (r) => (r.repoUrl || r.sourcePath || r.localPath) === key && !r.isWorktree + ) + + if (!existing) { + acc.push(repo) + } + } + + return acc + }, [] as Repo[]) +} + +export function buildRepoViewModels( + repos: Repo[], + gitStatuses: Map | undefined +): RepoViewModel[] { + return dedupeRepos(repos).map((repo) => ({ + ...repo, + attentionState: getAttentionState(repo, gitStatuses?.get(repo.id)), + activityTimestamp: getActivityTimestamp(repo) + })) +} + +export function filterReposBySearch(repos: RepoViewModel[], searchQuery: string): RepoViewModel[] { + if (!searchQuery.trim()) { + return repos + } + + const query = searchQuery.toLowerCase() + return repos.filter((repo) => { + const repoName = getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath) + const searchTarget = repo.repoUrl || repo.sourcePath || repo.localPath || "" + return ( + repoName.toLowerCase().includes(query) || + searchTarget.toLowerCase().includes(query) + ) + }) +} + +export function filterReposByMode(repos: RepoViewModel[], mode: RepoFilterMode): RepoViewModel[] { + switch (mode) { + case 'recent': + return repos.filter((repo) => repo.attentionState.isCloneStatusReady) + case 'attention': + return repos.filter( + (repo) => + repo.attentionState.hasChanges || + repo.attentionState.ahead > 0 || + repo.attentionState.behind > 0 + ) + case 'worktrees': + return repos.filter((repo) => repo.isWorktree) + case 'local': + return repos.filter((repo) => repo.isLocal) + case 'all': + default: + return repos + } +} + +export function sortRepos(repos: RepoViewModel[], mode: RepoSortMode, manualOrder?: number[]): RepoViewModel[] { + switch (mode) { + case 'recent': + return [...repos].sort((a, b) => b.activityTimestamp - a.activityTimestamp) + case 'name': + return [...repos].sort((a, b) => { + const nameA = getRepoDisplayName(a.repoUrl, a.localPath, a.sourcePath).toLowerCase() + const nameB = getRepoDisplayName(b.repoUrl, b.localPath, b.sourcePath).toLowerCase() + return nameA.localeCompare(nameB) + }) + case 'manual': { + if (!manualOrder || manualOrder.length === 0) { + return repos + } + const orderMap = new Map(manualOrder.map((id, index) => [id, index])) + const orderedRepos = repos + .filter((repo) => orderMap.has(repo.id)) + .sort((a, b) => { + const indexA = orderMap.get(a.id)! + const indexB = orderMap.get(b.id)! + return indexA - indexB + }) + const remainingRepos = repos.filter((repo) => !orderMap.has(repo.id)) + return [...orderedRepos, ...remainingRepos] + } + default: + return repos + } +} + +export function groupReposIntoSections( + repos: RepoViewModel[], + filterMode: RepoFilterMode, + sortMode: RepoSortMode +): RepoListSection[] { + if (sortMode === 'manual') { + return [{ + title: 'All Repositories', + repos, + emptyMessage: repos.length === 0 ? 'No repositories found' : undefined + }] + } + + if (filterMode === 'attention') { + const needsAttention = repos.filter( + (repo) => + repo.attentionState.hasChanges || + repo.attentionState.ahead > 0 || + repo.attentionState.behind > 0 + ) + + if (needsAttention.length === 0) { + return [{ + title: 'Needs Attention', + repos: [], + emptyMessage: 'No repositories need attention' + }] + } + + return [{ + title: 'Needs Attention', + repos: needsAttention + }] + } + + if (filterMode === 'recent') { + const recentRepos = repos.filter((repo) => repo.attentionState.isCloneStatusReady) + + if (recentRepos.length === 0) { + return [{ + title: 'Recent', + repos: [], + emptyMessage: 'No recently accessed repositories' + }] + } + + return [{ + title: 'Recent', + repos: recentRepos + }] + } + + const now = Date.now() + const oneDayAgo = now - 24 * 60 * 60 * 1000 + const needsAttention = repos.filter( + (repo) => + repo.attentionState.isCloneStatusReady && + (repo.attentionState.hasChanges || + repo.attentionState.ahead > 0 || + repo.attentionState.behind > 0) + ) + + const recentThreshold = oneDayAgo + const recentlyActive = repos.filter( + (repo) => + repo.attentionState.isCloneStatusReady && + repo.activityTimestamp >= recentThreshold + ) + + const sections: RepoListSection[] = [] + + if (needsAttention.length > 0) { + sections.push({ + title: 'Needs Attention', + repos: needsAttention + }) + } + + if (recentlyActive.length > 0) { + sections.push({ + title: 'Recent', + repos: recentlyActive + }) + } + + const remaining = repos.filter( + (repo) => + !needsAttention.includes(repo) && !recentlyActive.includes(repo) + ) + + if (remaining.length > 0) { + sections.push({ + title: 'All Repositories', + repos: remaining + }) + } else if (sections.length === 0) { + sections.push({ + title: 'All Repositories', + repos, + emptyMessage: repos.length === 0 ? 'No repositories found' : undefined + }) + } + + return sections +} + +export function countAttentionItems(repos: RepoViewModel[]): number { + return repos.filter( + (repo) => + repo.attentionState.hasChanges || + repo.attentionState.ahead > 0 || + repo.attentionState.behind > 0 + ).length +} \ No newline at end of file diff --git a/frontend/src/components/session/SessionList.tsx b/frontend/src/components/session/SessionList.tsx index dfe8c3f5..93920d62 100644 --- a/frontend/src/components/session/SessionList.tsx +++ b/frontend/src/components/session/SessionList.tsx @@ -1,8 +1,9 @@ import { useState, useMemo } from "react"; -import { useSessions, useDeleteSession } from "@/hooks/useOpenCode"; +import { useSessions, useDeleteSession, useCreateSession } from "@/hooks/useOpenCode"; import { ListToolbar } from "@/components/ui/list-toolbar"; import { DeleteSessionDialog } from "./DeleteSessionDialog"; import { SessionCard } from "./SessionCard"; +import { Card } from "@/components/ui/card"; interface SessionListProps { opcodeUrl: string; @@ -19,6 +20,9 @@ export const SessionList = ({ }: SessionListProps) => { const { data: sessions, isLoading } = useSessions(opcodeUrl, directory); const deleteSession = useDeleteSession(opcodeUrl, directory); + const createSession = useCreateSession(opcodeUrl, directory, (newSession) => { + onSelectSession(newSession.id); + }); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [sessionToDelete, setSessionToDelete] = useState< string | string[] | null @@ -68,8 +72,18 @@ export const SessionList = ({ if (!sessions || sessions.length === 0) { return ( -
- No sessions yet. Create one to get started. +
+ createSession.mutate({ agent: undefined })} + > +
+

No sessions yet

+

+ Click here to start a new session +

+
+
); } diff --git a/frontend/src/components/settings/OAuthAuthorizeDialog.tsx b/frontend/src/components/settings/OAuthAuthorizeDialog.tsx index 882871a2..f9d5085f 100644 --- a/frontend/src/components/settings/OAuthAuthorizeDialog.tsx +++ b/frontend/src/components/settings/OAuthAuthorizeDialog.tsx @@ -1,55 +1,156 @@ -import { useState } from 'react' +import { useState, useMemo, useEffect, useCallback } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Copy } from 'lucide-react' -import { oauthApi, type OAuthAuthorizeResponse } from '@/api/oauth' -import { mapOAuthError, OAuthMethod } from '@/lib/oauthErrors' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ExternalLink } from 'lucide-react' +import { oauthApi, type OAuthAuthorizeResponse, type ProviderAuthMethod } from '@/api/oauth' +import { mapOAuthError } from '@/lib/oauthErrors' interface OAuthAuthorizeDialogProps { providerId: string providerName: string + methods: ProviderAuthMethod[] open: boolean onOpenChange: (open: boolean) => void - onSuccess: (response: OAuthAuthorizeResponse) => void + onSuccess: (response: OAuthAuthorizeResponse, methodIndex: number) => void +} + +type OAuthPrompt = NonNullable[number] + +function isBrowserLocalMethod(method: ProviderAuthMethod): boolean { + return method.label.toLowerCase().includes('browser') +} + +function getVisiblePrompts(method: ProviderAuthMethod, inputs: Record): OAuthPrompt[] { + const visiblePrompts: OAuthPrompt[] = [] + const activeInputs: Record = {} + + for (const prompt of method.prompts ?? []) { + const isVisible = !('when' in prompt) || !prompt.when || activeInputs[prompt.when.key] === prompt.when.value + + if (!isVisible) { + continue + } + + visiblePrompts.push(prompt) + + if (inputs[prompt.key]) { + activeInputs[prompt.key] = inputs[prompt.key] + } + } + + return visiblePrompts +} + +function hasPrompts(method: ProviderAuthMethod): boolean { + return (method.prompts?.length ?? 0) > 0 } export function OAuthAuthorizeDialog({ providerId, - providerName, + providerName, + methods, open, onOpenChange, onSuccess }: OAuthAuthorizeDialogProps) { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) + const [selectedMethodIndex, setSelectedMethodIndex] = useState(null) + const [promptInputs, setPromptInputs] = useState>>({}) + const [autoStarted, setAutoStarted] = useState(false) + + const getMethodInputs = useCallback((methodIndex: number) => promptInputs[methodIndex] || {}, [promptInputs]) + + const oauthMethods = useMemo(() => { + return methods + .flatMap((method, index) => method.type === 'oauth' ? [{ method, index }] : []) + .filter(({ method }: { method: ProviderAuthMethod }) => { + if (providerId === 'openai' && isBrowserLocalMethod(method)) { + return false + } + return true + }) + }, [methods, providerId]) + + const handleAuthorize = useCallback(async (methodIndex: number) => { + const method = methods[methodIndex] + const methodInputs = getMethodInputs(methodIndex) + const visiblePrompts = getVisiblePrompts(method, methodInputs) + const missingPrompt = visiblePrompts.some((prompt) => !methodInputs[prompt.key]?.trim()) + + if (missingPrompt) { + setError('Please complete all authentication fields') + setSelectedMethodIndex(methodIndex) + return + } - const handleAuthorize = async (methodIndex: number) => { setIsLoading(true) setError(null) + setSelectedMethodIndex(methodIndex) try { - const response = await oauthApi.authorize(providerId, methodIndex) - onSuccess(response) + const inputs = visiblePrompts.length > 0 + ? Object.fromEntries( + visiblePrompts.map((prompt) => [prompt.key, methodInputs[prompt.key].trim()]) + ) + : undefined + const response = await oauthApi.authorize(providerId, methodIndex, inputs) + onSuccess(response, methodIndex) } catch (err) { setError(mapOAuthError(err, 'authorize')) - console.error('OAuth authorize error:', err) } finally { setIsLoading(false) } + }, [methods, providerId, onSuccess, getMethodInputs]) + + useEffect(() => { + if (open && oauthMethods.length === 1 && !autoStarted && !isLoading) { + const { index } = oauthMethods[0] + setAutoStarted(true) + setSelectedMethodIndex(index) + if (!hasPrompts(methods[index])) { + void handleAuthorize(index) + } + } + }, [open, oauthMethods, autoStarted, isLoading, methods, handleAuthorize]) + + const handleMethodSelection = (methodIndex: number) => { + setError(null) + setSelectedMethodIndex(methodIndex) + + if (!hasPrompts(methods[methodIndex])) { + void handleAuthorize(methodIndex) + } + } + + const handlePromptChange = (methodIndex: number, key: string, value: string) => { + setPromptInputs((prev) => ({ + ...prev, + [methodIndex]: { + ...prev[methodIndex], + [key]: value, + }, + })) } const handleClose = () => { setError(null) + setPromptInputs({}) + setSelectedMethodIndex(null) onOpenChange(false) } return ( - + Connect to {providerName} - Connect your {providerName} account using authorization code. + Select an authentication method to connect your {providerName} account. @@ -59,20 +160,92 @@ export function OAuthAuthorizeDialog({
)} -
- +
+ {oauthMethods.map(({ method, index }) => { + const isBrowserLocal = isBrowserLocalMethod(method) + const methodInputs = getMethodInputs(index) + const visiblePrompts = getVisiblePrompts(method, methodInputs) + const canSubmitPrompts = visiblePrompts.every((prompt) => methodInputs[prompt.key]?.trim()) + + return ( +
+
+ + {isBrowserLocal && ( + + Localhost only + + )} +
+ + {selectedMethodIndex === index && hasPrompts(method) && ( +
+ {visiblePrompts.map((prompt) => ( + prompt.type === 'text' ? ( +
+ + handlePromptChange(index, prompt.key, e.target.value)} + placeholder={prompt.placeholder} + className="bg-background border-border" + disabled={isLoading} + /> +
+ ) : ( +
+ + +
+ ) + ))} + + +
+ )} + + {isBrowserLocal && ( +

+ This method relies on a callback server started by OpenCode and may not work when OCM is remote. +

+ )} +
+ ) + })}
-

• "Use Authorization Code" will give you a code to manually enter

+

• Some methods may require completing authorization in your browser

diff --git a/frontend/src/components/settings/OAuthCallbackDialog.tsx b/frontend/src/components/settings/OAuthCallbackDialog.tsx index 46b17595..edee18ea 100644 --- a/frontend/src/components/settings/OAuthCallbackDialog.tsx +++ b/frontend/src/components/settings/OAuthCallbackDialog.tsx @@ -4,13 +4,16 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Loader2, ExternalLink, CheckCircle } from 'lucide-react' +import { CopyButton } from '@/components/ui/copy-button' +import { showToast } from '@/lib/toast' import { oauthApi, type OAuthAuthorizeResponse } from '@/api/oauth' -import { mapOAuthError, OAuthMethod } from '@/lib/oauthErrors' +import { mapOAuthError } from '@/lib/oauthErrors' interface OAuthCallbackDialogProps { providerId: string providerName: string authResponse: OAuthAuthorizeResponse + methodIndex: number open: boolean onOpenChange: (open: boolean) => void onSuccess: () => void @@ -20,6 +23,7 @@ export function OAuthCallbackDialog({ providerId, providerName, authResponse, + methodIndex, open, onOpenChange, onSuccess @@ -29,19 +33,19 @@ export function OAuthCallbackDialog({ const [authCode, setAuthCode] = useState('') const [error, setError] = useState(null) - const handleCodeCallback = async () => { - if (!authCode.trim()) { - setError('Please enter the authorization code') - return - } - + const handleCallback = async () => { setIsLoading(true) setLoadingMessage('Completing authentication...') setError(null) try { setLoadingMessage('Restarting server with new credentials...') - await oauthApi.callback(providerId, { method: OAuthMethod.CODE, code: authCode.trim() }) + await oauthApi.callback( + providerId, + authResponse.method === 'code' + ? { method: methodIndex, code: authCode.trim() } + : { method: methodIndex } + ) onSuccess() } catch (err) { setError(mapOAuthError(err, 'callback')) @@ -62,13 +66,22 @@ export function OAuthCallbackDialog({ onOpenChange(false) } + const isAutoMethod = authResponse.method === 'auto' + + // Extract device/user code from instructions (e.g., "Enter code: 596A-E304") + const codeMatch = authResponse.instructions.match(/(?:Enter code|User code|Device code)[:\s]+([A-Z0-9-]+)/i) + const deviceCode = codeMatch ? codeMatch[1] : '' + return ( Complete {providerName} Authentication - Enter the authorization code from the provider. + {isAutoMethod + ? 'Follow the instructions below to complete authentication.' + : 'Enter the authorization code from the provider.' + } @@ -82,33 +95,72 @@ export function OAuthCallbackDialog({

{authResponse.instructions}

- + + {deviceCode && ( +
+ + {deviceCode} + + showToast.success('Code copied to clipboard')} + /> +
+ )} + +
+ + showToast.success('URL copied to clipboard')} + /> +
-
- - setAuthCode(e.target.value)} - placeholder="Enter the authorization code..." - className="bg-background border-border" - disabled={isLoading} - /> -
+ {!isAutoMethod && ( +
+
+ + showToast.success('Code copied to clipboard')} + /> +
+ setAuthCode(e.target.value)} + placeholder="Enter the authorization code..." + className="bg-background border-border" + disabled={isLoading} + /> +
+ )} +
+ + +
+
+

Config Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.configSourcePath || 'No importable OpenCode config found'} +

+
+
+

State Source

+

+ {isImportStatusLoading ? 'Checking...' : importStatus?.stateSourcePath || 'No importable OpenCode state found'} +

+
+
+
+

Workspace State

+

+ {importStatus?.workspaceStatePath || 'Unavailable'} +

+

+ {importStatus?.workspaceStateExists + ? 'A workspace session database already exists. Import is blocked to protect it from being replaced by detected host state.' + : 'No workspace session database exists yet. Import will seed it from the detected host state.'} +

+
+ {syncOpenCodeImportMutation.error && ( +
+

Import blocked

+

+ {getOpenCodeImportErrorMessage(syncOpenCodeImportMutation.error)} +

+

+ This workspace already has OpenCode session state, so host state import was stopped to prevent accidental replacement of existing chats and history. If you want to use host state instead, clear the workspace state first and then run the import again. +

+
+ )} + {syncOpenCodeImportMutation.data?.relinkedRepos && ( +
+

Last Relink Result

+

+ Linked {syncOpenCodeImportMutation.data.relinkedRepos.relinkedCount} repos, matched {syncOpenCodeImportMutation.data.relinkedRepos.existingCount} existing repos, skipped {syncOpenCodeImportMutation.data.relinkedRepos.nonRepoPathCount} non-repo session paths, and ignored {syncOpenCodeImportMutation.data.relinkedRepos.duplicatePathCount} duplicate session paths. +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length > 0 && ( +

+ {syncOpenCodeImportMutation.data.relinkedRepos.errors.length} repo paths could not be linked. +

+ )} +
+ )} + {!canImportFromHost && !isImportStatusLoading && ( +

+ No host OpenCode config or state was detected. For Docker installs, bind your host OpenCode config and state into the container before using this action. +

+ )} +
+ + c.isDefault)?.content?.plugin?.includes('@opencode-manager/memory') ?? false} + memoryPluginEnabled={((configs.find(c => c.isDefault)?.content?.plugin as string[] | undefined) ?? []).includes('@opencode-manager/memory')} onToggle={async (enabled) => { - const defaultConfig = configs.find(c => c.isDefault) - if (!defaultConfig) return - - const currentPlugins = defaultConfig.content?.plugin ?? [] + const defaultConfig = configs.find(c => c.isDefault) + if (!defaultConfig) return + + const currentPlugins = (defaultConfig.content?.plugin as string[] | undefined) ?? [] const memoryPlugin = '@opencode-manager/memory' const newPlugins = enabled ? currentPlugins.includes(memoryPlugin) @@ -488,6 +654,11 @@ export function OpenCodeConfigManager() {
{config.name} + {!config.isValid && ( + + Invalid Config + + )} {config.isDefault && ( Current @@ -613,7 +784,7 @@ export function OpenCodeConfigManager() { {configs.map(config => ( - {config.name} {config.isDefault && '(Default)'} + {config.name} {config.isDefault && '(Default)'} {!config.isValid && '(Invalid)'} ))} @@ -623,6 +794,26 @@ export function OpenCodeConfigManager() {
{selectedConfig ? ( <> + {!selectedConfig.isValid && selectedConfig.validationIssues && selectedConfig.validationIssues.length > 0 && ( +
+

This configuration has validation issues

+

+ OpenCode may fail to start until these fields are corrected. Open the config editor to fix the file directly. +

+
    + {selectedConfig.validationIssues.slice(0, 8).map((issue) => ( +
  • + {issue.path}: {issue.message} +
  • + ))} +
+ {selectedConfig.validationIssues.length > 8 && ( +

+ Showing 8 of {selectedConfig.validationIssues.length} issues. Open the config editor to review and fix the file. +

+ )} +
+ )}
@@ -720,7 +911,7 @@ export function OpenCodeConfigManager() {
{ const paths = skills?.paths?.filter(Boolean) @@ -767,6 +958,50 @@ export function OpenCodeConfigManager() {
+ +
+ +
+
+ | undefined) ?? {}} + onChange={(providers) => { + const updatedContent = { + ...selectedConfig.content, + provider: providers + } + updateConfigContent(selectedConfig.name, updatedContent) + }} + /> +
+
+
) : (
diff --git a/frontend/src/components/settings/OpenCodeModelDialog.tsx b/frontend/src/components/settings/OpenCodeModelDialog.tsx new file mode 100644 index 00000000..b19bd341 --- /dev/null +++ b/frontend/src/components/settings/OpenCodeModelDialog.tsx @@ -0,0 +1,637 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useCallback, useEffect, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import type { ModelConfig, ProviderConfig } from '@/api/types/settings' + +type ConfigModel = Partial & { + limit?: { + context?: number + input?: number + output?: number + } +} & Record + +type ConfigProvider = Omit, 'models' | 'env'> & { + api?: string + npm?: string + env?: string[] + models?: Record +} & Record + +const handledModelKeys = new Set([ + 'id', + 'providerID', + 'api', + 'name', + 'family', + 'capabilities', + 'cost', + 'limit', + 'status', + 'options', + 'headers', + 'release_date', + 'variants', +]) + +function parseJsonObject(value: string): Record | null { + try { + const parsed = JSON.parse(value) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + return null + } catch { + return null + } +} + +function stringifyJson(value: unknown): string { + if (!value || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length === 0)) { + return '' + } + + return JSON.stringify(value, null, 2) +} + +function parseOptionalJsonField(value: string): Record | undefined { + if (!value.trim()) return undefined + return parseJsonObject(value) ?? undefined +} + +function parseOptionalNumber(value: string): number | undefined { + if (!value.trim()) return undefined + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined +} + +function jsonObjectField(label: string) { + return z.string().superRefine((value, ctx) => { + if (!value.trim()) return + if (!parseJsonObject(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must be a valid JSON object`, + }) + } + }) +} + +const modelFormSchema = z.object({ + providerId: z.string(), + modelId: z.string().min(1, 'Model ID is required').regex(/^[a-zA-Z0-9._-]+$/, 'Must use only letters, numbers, dots, hyphens, and underscores'), + backingModelId: z.string(), + providerModelProviderId: z.string(), + displayName: z.string(), + family: z.string(), + status: z.enum(['none', 'alpha', 'beta', 'deprecated', 'active']), + releaseDate: z.string(), + apiUrl: z.string(), + apiNpm: z.string(), + contextLimit: z.string(), + inputLimit: z.string(), + outputLimit: z.string(), + capabilitiesJson: jsonObjectField('Capabilities'), + costJson: jsonObjectField('Cost'), + optionsJson: jsonObjectField('Options'), + headersJson: jsonObjectField('Headers'), + variantsJson: jsonObjectField('Variants'), + extraJson: jsonObjectField('Advanced fields'), + createNewProvider: z.boolean(), + newProviderType: z.enum(['api', 'npm']), + newProviderId: z.string(), + newProviderName: z.string().optional(), + newProviderBaseUrl: z.string().optional(), + newProviderNpm: z.string().optional(), +}).superRefine((data, ctx) => { + if (data.createNewProvider) { + if (!data.newProviderId?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Provider ID is required when creating a new provider', path: ['newProviderId'] }) + } else if (!/^[a-z0-9-]+$/.test(data.newProviderId)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Must be lowercase letters, numbers, and hyphens only', path: ['newProviderId'] }) + } + if (data.newProviderType === 'api' && !data.newProviderBaseUrl?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Base URL is required for API providers', path: ['newProviderBaseUrl'] }) + } + if (data.newProviderType === 'npm' && !data.newProviderNpm?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'NPM package is required for npm providers', path: ['newProviderNpm'] }) + } + } else { + if (!data.providerId?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Provider is required', path: ['providerId'] }) + } + } + + for (const [field, label] of [ + ['contextLimit', 'Context limit'], + ['inputLimit', 'Input limit'], + ['outputLimit', 'Output limit'], + ] as const) { + const value = data[field] + if (value.trim() && parseOptionalNumber(value) === undefined) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${label} must be a number`, path: [field] }) + } + } +}) + +type ModelFormValues = z.infer + +export interface NewProviderConfig { + id: string + type: 'api' | 'npm' + name?: string + baseUrl?: string + npm?: string +} + +interface OpenCodeModelDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (providerId: string, modelId: string, model: ConfigModel, newProvider?: NewProviderConfig) => void + availableProviders: string[] + existingProviders?: Record + selectedProviderId: string + editingModel?: { + providerId: string + modelId: string + model: ConfigModel + } +} + +export function OpenCodeModelDialog({ + open, + onOpenChange, + onSubmit, + availableProviders, + existingProviders, + selectedProviderId, + editingModel, +}: OpenCodeModelDialogProps) { + const getDefaultValues = useCallback((): ModelFormValues => { + if (editingModel) { + const extraEntries = Object.fromEntries( + Object.entries(editingModel.model).filter(([key]) => !handledModelKeys.has(key)) + ) + + return { + providerId: editingModel.providerId, + modelId: editingModel.modelId, + backingModelId: typeof editingModel.model.id === 'string' ? editingModel.model.id : '', + providerModelProviderId: typeof editingModel.model.providerID === 'string' ? editingModel.model.providerID : '', + displayName: editingModel.model.name || '', + family: editingModel.model.family || '', + status: (editingModel.model.status as ModelFormValues['status']) || 'none', + releaseDate: editingModel.model.release_date || '', + apiUrl: editingModel.model.api?.url || '', + apiNpm: editingModel.model.api?.npm || '', + contextLimit: editingModel.model.limit?.context?.toString() || '', + inputLimit: editingModel.model.limit?.input?.toString() || '', + outputLimit: editingModel.model.limit?.output?.toString() || '', + capabilitiesJson: stringifyJson(editingModel.model.capabilities), + costJson: stringifyJson(editingModel.model.cost), + optionsJson: stringifyJson(editingModel.model.options), + headersJson: stringifyJson(editingModel.model.headers), + variantsJson: stringifyJson(editingModel.model.variants), + extraJson: stringifyJson(extraEntries), + createNewProvider: false, + newProviderType: 'api', + newProviderId: '', + newProviderName: '', + newProviderBaseUrl: '', + newProviderNpm: '', + } + } + + return { + providerId: selectedProviderId || availableProviders[0] || '', + modelId: '', + backingModelId: '', + providerModelProviderId: '', + displayName: '', + family: '', + status: 'none', + releaseDate: '', + apiUrl: '', + apiNpm: '', + contextLimit: '', + inputLimit: '', + outputLimit: '', + capabilitiesJson: '', + costJson: '', + optionsJson: '', + headersJson: '', + variantsJson: '', + extraJson: '', + createNewProvider: availableProviders.length === 0, + newProviderType: 'api', + newProviderId: '', + newProviderName: '', + newProviderBaseUrl: '', + newProviderNpm: '', + } + }, [editingModel, selectedProviderId, availableProviders]) + + const form = useForm({ + resolver: zodResolver(modelFormSchema), + defaultValues: getDefaultValues(), + mode: 'onChange', + }) + + const { isValid } = form.formState + const createNewProvider = form.watch('createNewProvider') + const newProviderType = form.watch('newProviderType') + + useEffect(() => { + if (open) { + form.reset(getDefaultValues()) + void form.trigger() + } + }, [open, form, getDefaultValues]) + + const handleSubmit = (values: ModelFormValues) => { + const extra = parseOptionalJsonField(values.extraJson) + const capabilities = parseOptionalJsonField(values.capabilitiesJson) + const cost = parseOptionalJsonField(values.costJson) + const options = parseOptionalJsonField(values.optionsJson) + const headers = parseOptionalJsonField(values.headersJson) + const variants = parseOptionalJsonField(values.variantsJson) + + const model: ConfigModel = { + ...(extra || {}), + } + + if (values.backingModelId.trim()) model.id = values.backingModelId.trim() + if (values.providerModelProviderId.trim()) model.providerID = values.providerModelProviderId.trim() + if (values.displayName.trim()) model.name = values.displayName.trim() + if (values.family.trim()) model.family = values.family.trim() + if (values.status !== 'none') model.status = values.status + if (values.releaseDate.trim()) model.release_date = values.releaseDate.trim() + + if (values.apiUrl.trim() || values.apiNpm.trim()) { + model.api = { url: values.apiUrl.trim(), ...(values.apiNpm.trim() ? { npm: values.apiNpm.trim() } : {}) } + } + + const contextLimit = parseOptionalNumber(values.contextLimit) + const inputLimit = parseOptionalNumber(values.inputLimit) + const outputLimit = parseOptionalNumber(values.outputLimit) + if (contextLimit !== undefined || inputLimit !== undefined || outputLimit !== undefined) { + model.limit = { + ...(contextLimit !== undefined ? { context: contextLimit } : {}), + ...(inputLimit !== undefined ? { input: inputLimit } : {}), + ...(outputLimit !== undefined ? { output: outputLimit } : {}), + } as ConfigModel['limit'] + } + + if (capabilities) model.capabilities = capabilities as ConfigModel['capabilities'] + if (cost) model.cost = cost as ConfigModel['cost'] + if (options) model.options = options + if (headers) model.headers = headers as Record + if (variants) model.variants = variants as ConfigModel['variants'] + + let newProvider: NewProviderConfig | undefined + if (values.createNewProvider) { + newProvider = { + id: values.newProviderId, + type: values.newProviderType, + name: values.newProviderName || undefined, + baseUrl: values.newProviderBaseUrl || undefined, + npm: values.newProviderNpm || undefined, + } + } + + if (newProvider) onSubmit(values.providerId, values.modelId, model, newProvider) + else onSubmit(values.providerId, values.modelId, model) + + form.reset() + onOpenChange(false) + } + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) form.reset() + onOpenChange(isOpen) + } + + const providerOptions = useMemo(() => { + return availableProviders.map((p: string) => ({ value: p, label: existingProviders?.[p]?.name || p })) + }, [availableProviders, existingProviders]) + + const isEditing = !!editingModel + + return ( + + + + {isEditing ? 'Edit Model' : 'Create Model'} + + +
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> + +
+ {!isEditing && ( + ( + +
+ Create new provider +

Add a new provider configuration

+
+ + + +
+ )} + /> + )} + + {createNewProvider ? ( +
+

New Provider

+ + ( + + Provider Type + + + + )} /> + + ( + + Provider ID + + + + )} /> + + ( + + Display Name + + + + )} /> + + {newProviderType === 'api' && ( + ( + + Base URL + + + + )} /> + )} + + {newProviderType === 'npm' && ( + ( + + NPM Package + + + + )} /> + )} +
+ ) : ( + ( + + Provider + + + + )} /> + )} + +
+ ( + + Config Key + + + + )} /> + + ( + + Provider Model ID + + + + )} /> +
+ +
+ ( + + Display Name + + + + )} /> + + ( + + Family + + + + )} /> +
+ +
+ ( + + Status + + + + )} /> + + ( + + Release Date + + + + )} /> +
+ +
+ ( + + Model Provider ID + + + + )} /> + +
+ ( + + API URL + + + + )} /> + + ( + + API NPM + + + + )} /> +
+
+ +
+ ( + + Context Limit + + + + )} /> + + ( + + Input Limit + + + + )} /> + + ( + + Output Limit + + + + )} /> +
+ +
+

Structured JSON fields

+ + ( + + Options JSON + +