diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9c5965c6..28094f62 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,17 +18,24 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Set up pnpm - uses: pnpm/action-setup@v4 + - name: Set up Node.js + uses: actions/setup-node@v5 with: - version: 9.15.0 + node-version: 22 + package-manager-cache: false - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Set up pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.0 --activate + + - name: Restore pnpm cache + uses: actions/setup-node@v5 with: node-version: 22 + package-manager-cache: false cache: pnpm - name: Set up Bun diff --git a/backend/test/middleware/control-plane.test.ts b/backend/test/middleware/control-plane.test.ts index 4f899708..1e618e0b 100644 --- a/backend/test/middleware/control-plane.test.ts +++ b/backend/test/middleware/control-plane.test.ts @@ -14,7 +14,9 @@ const createTestApp = () => { app.use('/api/*', createRequireControlPlaneAuth({ token: 'secret-token' })) app.get('/api/health', (c) => c.json({ ok: true })) + app.get('/api/health/build', (c) => c.json({ ok: true, build: true })) app.get('/api/repos', (c) => c.json({ ok: true })) + app.post('/api/repos', (c) => c.json({ ok: true, method: c.req.method })) app.get('/api/opencode/event', (c) => c.text('stream-ready')) return app @@ -28,11 +30,20 @@ describe('control-plane middleware', () => { expect(response.status).toBe(200) }) + it('keeps nested health endpoints public', async () => { + const app = createTestApp() + const response = await app.request('http://localhost/api/health/build') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true, build: true }) + }) + it('rejects anonymous access to protected routes', async () => { const app = createTestApp() const response = await app.request('http://localhost/api/repos') expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toBe('Bearer realm="opencode-webui"') await expect(response.json()).resolves.toEqual({ error: 'Authentication required' }) }) @@ -60,6 +71,29 @@ describe('control-plane middleware', () => { await expect(response.text()).resolves.toBe('stream-ready') }) + it('rejects query token auth on non-GET requests', async () => { + const app = createTestApp() + const response = await app.request('http://localhost/api/repos?access_token=secret-token', { + method: 'POST', + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: 'Authentication required' }) + }) + + it('returns 503 when auth middleware is enabled without a configured token', async () => { + const app = new Hono() + app.use('/api/*', createRequireControlPlaneAuth({ token: ' ' })) + app.get('/api/repos', (c) => c.json({ ok: true })) + + const response = await app.request('http://localhost/api/repos') + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: 'Control-plane auth is enabled but AUTH_TOKEN is not configured', + }) + }) + it('returns allowed CORS headers for configured origins', async () => { const app = createTestApp() const response = await app.request('http://localhost/api/repos', { diff --git a/backend/test/routes/health.test.ts b/backend/test/routes/health.test.ts index 58f37523..8ad3e9fd 100644 --- a/backend/test/routes/health.test.ts +++ b/backend/test/routes/health.test.ts @@ -77,6 +77,22 @@ describe('health routes', () => { }) }) + it('exposes readiness from the root health endpoint', async () => { + const app = createHealthRoutes(createMockDb(), { + checkOpencodeHealth: vi.fn().mockResolvedValue(true), + getOpencodePort: () => 5551, + getBuildInfo: () => buildInfo, + }) + + const response = await app.request('http://localhost/') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + status: 'healthy', + build: buildInfo, + }) + }) + it('keeps liveness independent from readiness', async () => { const app = createHealthRoutes(createMockDb(false), { checkOpencodeHealth: vi.fn().mockResolvedValue(false), @@ -92,4 +108,67 @@ describe('health routes', () => { build: buildInfo, }) }) + + it('returns build metadata directly', async () => { + const app = createHealthRoutes(createMockDb(), { + checkOpencodeHealth: vi.fn().mockResolvedValue(true), + getOpencodePort: () => 5551, + getBuildInfo: () => buildInfo, + }) + + const response = await app.request('http://localhost/build') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual(buildInfo) + }) + + it('returns unhealthy readiness with build info when dependency checks throw', async () => { + const app = createHealthRoutes(createMockDb(), { + checkOpencodeHealth: vi.fn().mockRejectedValue(new Error('opencode probe failed')), + getOpencodePort: () => 5551, + getBuildInfo: () => buildInfo, + }) + + const response = await app.request('http://localhost/ready') + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toMatchObject({ + status: 'unhealthy', + error: 'opencode probe failed', + build: buildInfo, + }) + }) + + it('reports process health and port details', async () => { + const app = createHealthRoutes(createMockDb(), { + checkOpencodeHealth: vi.fn().mockResolvedValue(true), + getOpencodePort: () => 6001, + getBuildInfo: () => buildInfo, + }) + + const response = await app.request('http://localhost/processes') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + opencode: { + port: 6001, + healthy: true, + }, + }) + }) + + it('returns 500 from process health when dependency checks throw', async () => { + const app = createHealthRoutes(createMockDb(), { + checkOpencodeHealth: vi.fn().mockRejectedValue(new Error('process probe failed')), + getOpencodePort: () => 6001, + getBuildInfo: () => buildInfo, + }) + + const response = await app.request('http://localhost/processes') + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toMatchObject({ + error: 'process probe failed', + }) + }) }) diff --git a/backend/test/services/proxy.test.ts b/backend/test/services/proxy.test.ts new file mode 100644 index 00000000..1f3ce23a --- /dev/null +++ b/backend/test/services/proxy.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import fs from 'fs/promises' +import path from 'path' + +vi.mock('../../src/db/queries', () => ({ + listRepos: vi.fn(), +})) + +import * as repoQueries from '../../src/db/queries' +import { proxyRequest } from '../../src/services/proxy' + +const artifactsRoot = path.resolve(process.cwd(), 'test-artifacts/proxy') +const reposRoot = path.resolve(process.cwd(), 'workspace/repos') + +describe('proxyRequest hardening', () => { + beforeEach(async () => { + vi.restoreAllMocks() + vi.mocked(repoQueries.listRepos).mockReturnValue([]) + await fs.rm(artifactsRoot, { recursive: true, force: true }) + await fs.rm(reposRoot, { recursive: true, force: true }) + await fs.mkdir(artifactsRoot, { recursive: true }) + await fs.mkdir(reposRoot, { recursive: true }) + }) + + afterEach(async () => { + await fs.rm(artifactsRoot, { recursive: true, force: true }) + await fs.rm(reposRoot, { recursive: true, force: true }) + }) + + it('rejects proxy directory traversal outside the tracked repos base', async () => { + const outsidePath = path.join(artifactsRoot, 'outside') + await fs.mkdir(outsidePath, { recursive: true }) + + const response = await proxyRequest( + new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(outsidePath)}`), + {} as any, + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Path traversal detected' }) + }) + + it('rejects proxy requests for untracked repositories', async () => { + const untrackedRepo = path.join(reposRoot, 'untracked-repo') + await fs.mkdir(untrackedRepo, { recursive: true }) + + const fetchSpy = vi.spyOn(globalThis, 'fetch') + const response = await proxyRequest( + new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(untrackedRepo)}`), + {} as any, + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ error: 'Directory is not a tracked repository' }) + expect(fetchSpy).not.toHaveBeenCalled() + }) + + it('allows tracked repositories and preserves upstream JSON responses', async () => { + const trackedRepo = path.join(reposRoot, 'tracked-repo') + await fs.mkdir(trackedRepo, { recursive: true }) + vi.mocked(repoQueries.listRepos).mockReturnValue([ + { + id: 1, + localPath: 'tracked-repo', + fullPath: trackedRepo, + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + } as any, + ]) + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'content-type': 'application/json', + connection: 'keep-alive', + }, + })) + + const response = await proxyRequest( + new Request(`http://localhost/api/opencode/session?directory=${encodeURIComponent(trackedRepo)}`, { + headers: { + Authorization: 'Bearer secret-token', + Connection: 'keep-alive', + Host: 'localhost:3001', + 'X-Trace-Id': 'trace-123', + }, + }), + {} as any, + ) + + expect(fetchSpy).toHaveBeenCalledWith('http://127.0.0.1:5551/session', { + method: 'GET', + headers: { + authorization: 'Bearer secret-token', + 'x-trace-id': 'trace-123', + }, + body: undefined, + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ ok: true }) + expect(response.headers.get('connection')).toBeNull() + }) +}) diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 56a211ea..4ed846eb 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -54,6 +54,28 @@ export interface ProviderWithModels { authMethod?: "oauth" | "apiKey" | "none"; } +type ModelCapabilities = { + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + toolcall?: boolean; +}; + +type ProviderModelConfig = Partial & { + capabilities?: ModelCapabilities; +}; + +type LiveProviderConfig = { + id: string; + name?: string; + api?: string; + env?: string[]; + npm?: string; + models?: Record; + options?: Record; + authMethod?: Provider["authMethod"]; +}; + // Default providers for common OpenCode setups export const DEFAULT_PROVIDERS: Provider[] = [ { @@ -176,13 +198,13 @@ async function getProvidersFromConfig(): Promise { try { const { data } = await apiClient.get(`${API_BASE_URL}/api/opencode/config/providers`); if (Array.isArray(data?.providers)) { - return data.providers.map((provider: any) => ({ + return data.providers.map((provider: LiveProviderConfig) => ({ id: provider.id, name: provider.name || provider.id, api: provider.api, env: Array.isArray(provider.env) ? provider.env : [], npm: provider.npm, - models: provider.models || {}, + models: normalizeModels(provider.models), options: provider.options || {}, authMethod: provider.authMethod || 'apiKey', })); @@ -194,13 +216,13 @@ async function getProvidersFromConfig(): Promise { try { const configResponse = await settingsApi.getDefaultOpenCodeConfig(); if (configResponse?.content?.provider) { - const providerRecord = configResponse.content.provider as Record; + const providerRecord = configResponse.content.provider as Record>; return Object.entries(providerRecord).map(([id, provider]) => ({ ...provider, id: provider.id || id, name: provider.name || id, env: provider.env || [], - models: provider.models || {}, + models: normalizeModels(provider.models), authMethod: provider.authMethod || 'apiKey', })); } @@ -216,7 +238,20 @@ export async function getProviders(): Promise { return await getProvidersFromConfig(); } -function normalizeModel(id: string, model: any): Model { +function normalizeModels(models: unknown): Record { + if (!models || typeof models !== 'object' || Array.isArray(models)) { + return {}; + } + + return Object.fromEntries( + Object.entries(models as Record).map(([id, model]) => [ + id, + normalizeModel(id, model), + ]), + ); +} + +function normalizeModel(id: string, model: ProviderModelConfig = {}): Model { const capabilities = model?.capabilities || {}; return { ...model, diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index 82822461..506c2191 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -54,7 +54,7 @@ useEffect(() => { } }, [initialFileError]) - const loadFiles = async (path: string) => { + const loadFiles = useCallback(async (path: string) => { setLoading(true) setError(null) @@ -73,7 +73,7 @@ useEffect(() => { } finally { setLoading(false) } - } + }, [onDirectoryLoad]) const handleFileSelect = useCallback(async (file: FileInfo) => { if (file.isDirectory) { @@ -136,7 +136,7 @@ useEffect(() => { } catch (err) { setError(err instanceof Error ? err.message : 'Upload failed') } - }, [currentPath]) + }, [currentPath, loadFiles]) const handleCreateFile = useCallback(async (name: string, type: 'file' | 'folder') => { try { @@ -154,7 +154,7 @@ useEffect(() => { } catch (err) { setError(err instanceof Error ? err.message : 'Create failed') } - }, [currentPath]) + }, [currentPath, loadFiles]) const handleDelete = useCallback(async (path: string) => { try { @@ -171,7 +171,7 @@ useEffect(() => { } catch (err) { setError(err instanceof Error ? err.message : 'Delete failed') } - }, [currentPath]) + }, [currentPath, loadFiles]) const handleRename = useCallback(async (oldPath: string, newPath: string) => { try { @@ -189,7 +189,7 @@ useEffect(() => { } catch (err) { setError(err instanceof Error ? err.message : 'Rename failed') } - }, [currentPath]) + }, [currentPath, loadFiles]) const handleDragEnter = (e: React.DragEvent) => { e.preventDefault() @@ -223,7 +223,7 @@ useEffect(() => { useEffect(() => { loadFiles(basePath) - }, [basePath]) + }, [basePath, loadFiles]) useEffect(() => { const handleFileSaved = (event: CustomEvent<{ path: string; content: string }>) => { @@ -248,7 +248,7 @@ useEffect(() => { document.addEventListener('keydown', handleEscape) return () => document.removeEventListener('keydown', handleEscape) } - }, [isPreviewModalOpen]) + }, [isPreviewModalOpen, handleCloseModal]) const filteredFiles = files?.children?.filter(file => file.name.toLowerCase().includes(searchQuery.toLowerCase()) diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 771fe1ef..043aa92c 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -300,7 +300,21 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, )} {showSaveButton && ( - )} diff --git a/frontend/src/components/message/TextPart.tsx b/frontend/src/components/message/TextPart.tsx index 23451598..94a4b80e 100644 --- a/frontend/src/components/message/TextPart.tsx +++ b/frontend/src/components/message/TextPart.tsx @@ -27,7 +27,7 @@ function CodeBlock({ children, className, ...props }: CodeBlockProps) { if (typeof node === 'number') return node.toString() if (Array.isArray(node)) return node.map(extractTextContent).join('') if (React.isValidElement(node)) { - const element = node as React.ReactElement + const element = node as React.ReactElement<{ children?: React.ReactNode }> if (element.props.children) { return extractTextContent(element.props.children as React.ReactNode) } diff --git a/frontend/src/components/settings/AddMcpServerDialog.tsx b/frontend/src/components/settings/AddMcpServerDialog.tsx index d052c442..eea48038 100644 --- a/frontend/src/components/settings/AddMcpServerDialog.tsx +++ b/frontend/src/components/settings/AddMcpServerDialog.tsx @@ -21,6 +21,15 @@ interface EnvironmentVariable { value: string } +interface McpServerConfigValue { + type: 'local' | 'remote' + enabled: boolean + command?: string[] + url?: string + environment?: Record + timeout?: number +} + export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServerDialogProps) { const [serverId, setServerId] = useState('') const [serverType, setServerType] = useState<'local' | 'remote'>('local') @@ -37,9 +46,9 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe const config = await settingsApi.getDefaultOpenCodeConfig() if (!config) throw new Error('No default config found') - const currentMcp = (config.content?.mcp as Record) || {} + const currentMcp = (config.content?.mcp as Record) || {} - const mcpConfig: any = { + const mcpConfig: McpServerConfigValue = { type: serverType, enabled, } @@ -67,8 +76,9 @@ export function AddMcpServerDialog({ open, onOpenChange, onUpdate }: AddMcpServe mcpConfig.url = url.trim() } - if (timeout && parseInt(timeout)) { - mcpConfig.timeout = parseInt(timeout) + const parsedTimeout = Number.parseInt(timeout, 10) + if (timeout && parsedTimeout) { + mcpConfig.timeout = parsedTimeout } const updatedConfig = { @@ -278,4 +288,4 @@ onSuccess: async () => { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/settings/KeyboardShortcuts.tsx b/frontend/src/components/settings/KeyboardShortcuts.tsx index 8ad3100a..b3569f6f 100644 --- a/frontend/src/components/settings/KeyboardShortcuts.tsx +++ b/frontend/src/components/settings/KeyboardShortcuts.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useSettings } from '@/hooks/useSettings' import { Loader2 } from 'lucide-react' import { DEFAULT_KEYBOARD_SHORTCUTS } from '@/api/types/settings' @@ -15,18 +15,12 @@ export function KeyboardShortcuts() { const [recordingKey, setRecordingKey] = useState(null) const [tempShortcuts, setTempShortcuts] = useState>({}) const [currentKeys, setCurrentKeys] = useState('') + const shortcuts = useMemo( + () => ({ ...DEFAULT_KEYBOARD_SHORTCUTS, ...preferences?.keyboardShortcuts, ...tempShortcuts }), + [preferences?.keyboardShortcuts, tempShortcuts], + ) - if (isLoading) { - return ( -
- -
- ) - } - - const shortcuts = { ...DEFAULT_KEYBOARD_SHORTCUTS, ...preferences?.keyboardShortcuts, ...tempShortcuts } - - const handleKeyDown = (e: KeyboardEvent, action: string) => { + const handleKeyDown = useCallback((e: KeyboardEvent, action: string) => { e.preventDefault() const keys = [] @@ -69,43 +63,49 @@ export function KeyboardShortcuts() { // Show current modifier keys being held setCurrentKeys(keys.join('+')) } - } + }, [shortcuts, updateSettings]) - const handleKeyUp = (e: KeyboardEvent) => { + const handleKeyUp = useCallback((e: KeyboardEvent) => { // Clear current keys display when modifiers are released if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) { setCurrentKeys('') } - } + }, []) const startRecording = (action: string) => { setRecordingKey(action) setCurrentKeys('') } - const stopRecording = () => { + const stopRecording = useCallback(() => { setRecordingKey(null) setCurrentKeys('') - } + }, []) useEffect(() => { - if (recordingKey) { - const handleGlobalKeyDown = (e: KeyboardEvent) => { - handleKeyDown(e, recordingKey) - } - - const handleGlobalKeyUp = (e: KeyboardEvent) => { - handleKeyUp(e) - } - - document.addEventListener('keydown', handleGlobalKeyDown) - document.addEventListener('keyup', handleGlobalKeyUp) - return () => { - document.removeEventListener('keydown', handleGlobalKeyDown) - document.removeEventListener('keyup', handleGlobalKeyUp) - } + if (!recordingKey) { + return + } + + const handleGlobalKeyDown = (e: KeyboardEvent) => { + handleKeyDown(e, recordingKey) + } + + document.addEventListener('keydown', handleGlobalKeyDown) + document.addEventListener('keyup', handleKeyUp) + return () => { + document.removeEventListener('keydown', handleGlobalKeyDown) + document.removeEventListener('keyup', handleKeyUp) } - }, [recordingKey]) + }, [recordingKey, handleKeyDown, handleKeyUp]) + + if (isLoading) { + return ( +
+ +
+ ) + } return (
diff --git a/frontend/src/components/settings/McpManager.tsx b/frontend/src/components/settings/McpManager.tsx index 6f43ce97..d0f8bcd1 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -40,7 +40,7 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps setTogglingServerId(serverId) - const currentMcp = (config.content?.mcp as Record) || {} + const currentMcp = (config.content?.mcp as Record) || {} const serverConfig = currentMcp[serverId] if (!serverConfig) return @@ -70,8 +70,9 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps mutationFn: async (serverId: string) => { if (!config) return - const currentMcp = (config.content?.mcp as Record) || {} - const { [serverId]: deleted, ...rest } = currentMcp + const currentMcp = (config.content?.mcp as Record) || {} + const rest = { ...currentMcp } + delete rest[serverId] const updatedConfig = { ...config.content, diff --git a/frontend/src/components/settings/OpenCodeConfigManager.tsx b/frontend/src/components/settings/OpenCodeConfigManager.tsx index fc117013..69053023 100644 --- a/frontend/src/components/settings/OpenCodeConfigManager.tsx +++ b/frontend/src/components/settings/OpenCodeConfigManager.tsx @@ -12,15 +12,53 @@ import { CommandsEditor } from './CommandsEditor' import { AgentsEditor } from './AgentsEditor' import { McpManager } from './McpManager' import { settingsApi } from '@/api/settings' +import type { OpenCodeConfig } from '@/api/types/settings' import { useMutation } from '@tanstack/react-query' -interface OpenCodeConfig { - id: number - name: string - content: Record - isDefault: boolean - createdAt: number - updatedAt: number +interface CommandConfig { + template: string + description?: string + agent?: string + model?: string + subtask?: boolean + topP?: number +} + +interface AgentConfig { + prompt?: string + description?: string + mode?: 'subagent' | 'primary' | 'all' + temperature?: number + topP?: number + model?: { + modelID: string + providerID: string + } + tools?: Record + permission?: { + edit?: 'ask' | 'allow' | 'deny' + bash?: 'ask' | 'allow' | 'deny' | Record + webfetch?: 'ask' | 'allow' | 'deny' + } + disable?: boolean + [key: string]: unknown +} + +interface McpServerConfig { + type: 'local' | 'remote' + enabled: boolean + command?: string[] + url?: string + environment?: Record + timeout?: number +} + +function getConfigSection>(value: unknown): T { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as T + } + + return {} as T } export function OpenCodeConfigManager() { @@ -169,6 +207,10 @@ export function OpenCodeConfigManager() { setIsEditDialogOpen(true) } + const commandConfig = getConfigSection>(selectedConfig?.content.command) + const agentConfig = getConfigSection>(selectedConfig?.content.agent) + const mcpConfig = getConfigSection>(selectedConfig?.content.mcp) + if (isLoading) { return (
@@ -331,7 +373,7 @@ export function OpenCodeConfigManager() {

Commands

- {Object.keys(selectedConfig.content.command as Record || {}).length} configured + {Object.keys(commandConfig).length} configured
@@ -339,7 +381,7 @@ export function OpenCodeConfigManager() {
) || {}} + commands={commandConfig} onChange={(commands) => { const updatedContent = { ...selectedConfig.content, @@ -360,7 +402,7 @@ export function OpenCodeConfigManager() {

Agents

- {Object.keys(selectedConfig.content.agent as Record || {}).length} configured + {Object.keys(agentConfig).length} configured
@@ -368,7 +410,7 @@ export function OpenCodeConfigManager() {
) || {}} + agents={agentConfig} onChange={(agents) => { const updatedContent = { ...selectedConfig.content, @@ -389,7 +431,7 @@ export function OpenCodeConfigManager() {

MCP Servers

- {Object.keys((selectedConfig.content.mcp as Record) || {}).length} configured + {Object.keys(mcpConfig).length} configured
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index f000e3ef..300e3c81 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -33,4 +33,4 @@ function Badge({ className, variant, ...props }: BadgeProps) { ) } -export { Badge, badgeVariants } +export { Badge } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 4b7a1252..e15dd22f 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -57,4 +57,4 @@ function Button({ ) } -export { Button, buttonVariants } +export { Button } diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index e9c0ec3c..d495886b 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -167,7 +167,6 @@ const FormMessage = React.forwardRef< FormMessage.displayName = "FormMessage" export { - useFormField, Form, FormItem, FormLabel, diff --git a/frontend/src/components/ui/virtualized-text-view.tsx b/frontend/src/components/ui/virtualized-text-view.tsx index fac1f9e7..7b5e9177 100644 --- a/frontend/src/components/ui/virtualized-text-view.tsx +++ b/frontend/src/components/ui/virtualized-text-view.tsx @@ -155,8 +155,6 @@ export const VirtualizedTextView = forwardRef(null) const isMobile = useMobile() - void renderTrigger - const chunkSize = isMobile && lineWrap ? 800 : 400 const overscan = isMobile && lineWrap ? 200 : 100 const bufferMultiplier = 4 @@ -313,6 +311,7 @@ export const VirtualizedTextView = forwardRef { + void renderTrigger const range = calculateVisibleRange(scrollTopRef.current) currentVisibleRangeRef.current = range return range