From f2ce49a65069c8a8b82895c88ee389071daa72ab Mon Sep 17 00:00:00 2001 From: diamondplated Date: Sun, 29 Mar 2026 09:49:58 -0500 Subject: [PATCH] fix: harden opencode webui flows - fix Copilot auth/session/provider UX and command handling - repair session routing, SSE refresh, keyboard shortcuts, and accessibility issues - expose build provenance metadata from the running image Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .dockerignore | 1 - Dockerfile | 38 ++++++++- backend/src/routes/health.ts | 12 ++- backend/src/routes/providers.ts | 9 +- backend/src/routes/settings.ts | 6 +- backend/src/services/auth.ts | 45 +++++++++- backend/src/services/build-info.ts | 49 +++++++++++ backend/src/services/proxy.ts | 38 +++++++++ backend/src/services/settings.ts | 48 +++++++++-- frontend/index.html | 3 +- frontend/src/api/providers.ts | 84 ++++++++++++++----- frontend/src/api/types.ts | 36 ++++++++ frontend/src/api/types/settings.ts | 2 +- .../file-browser/FilePreviewDialog.tsx | 3 +- .../file-browser/GitChangesSheet.tsx | 3 +- .../src/components/message/MessageThread.tsx | 16 ++++ .../src/components/message/PromptInput.tsx | 26 ++++-- .../src/components/message/ToolCallPart.tsx | 46 +++++++++- .../components/model/ModelSelectDialog.tsx | 33 ++------ .../session/ContextUsageIndicator.tsx | 2 +- .../components/settings/GeneralSettings.tsx | 69 ++++++++++----- .../components/settings/ProviderSettings.tsx | 65 +++++++++----- .../components/settings/SettingsDialog.tsx | 5 +- frontend/src/components/ui/dialog.tsx | 9 +- frontend/src/hooks/useCommandHandler.ts | 56 ++++--------- frontend/src/hooks/useCommands.ts | 84 +------------------ frontend/src/hooks/useContextUsage.ts | 3 +- frontend/src/hooks/useKeyboardShortcuts.ts | 46 +++++++++- frontend/src/hooks/useSSE.ts | 27 ++++++ frontend/src/lib/providerTemplates.ts | 8 +- frontend/src/pages/SessionDetail.tsx | 64 +++++++------- shared/src/schemas/settings.ts | 4 +- 32 files changed, 655 insertions(+), 285 deletions(-) create mode 100644 backend/src/services/build-info.ts diff --git a/.dockerignore b/.dockerignore index 3e3f569d..e5c767b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,6 @@ config opencode-src temp -.git .github .gitignore diff --git a/Dockerfile b/Dockerfile index 629cf433..c175afc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,40 @@ RUN curl -fsSL https://bun.sh/install | bash && \ WORKDIR /app +FROM base AS metadata + +COPY . . + +RUN node <<'NODE' +const { execSync } = require('child_process') +const fs = require('fs') + +const run = (command, fallback) => { + try { + return execSync(command, { encoding: 'utf8' }).trim() || fallback + } catch { + return fallback + } +} + +const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')) +const sha = run('git rev-parse HEAD', 'unknown') +const shortSha = run('git rev-parse --short=12 HEAD', sha === 'unknown' ? 'unknown' : sha.slice(0, 12)) +const dirty = run('git status --porcelain', '') !== '' + +const buildInfo = { + packageVersion: packageJson.version || 'unknown', + buildTime: new Date().toISOString(), + git: { + sha, + shortSha, + dirty, + }, +} + +fs.writeFileSync('/tmp/build-info.json', JSON.stringify(buildInfo, null, 2)) +NODE + FROM base AS deps COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ @@ -61,13 +95,13 @@ COPY --from=deps /app/node_modules ./node_modules COPY --from=builder /app/shared ./shared COPY --from=builder /app/backend ./backend COPY --from=builder /app/frontend/dist ./frontend/dist +COPY --from=metadata /tmp/build-info.json ./build-info.json COPY package.json pnpm-workspace.yaml ./ RUN mkdir -p /app/backend/node_modules/@opencode-webui && \ ln -s /app/shared /app/backend/node_modules/@opencode-webui/shared -COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod +x /docker-entrypoint.sh +COPY --chmod=755 scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN mkdir -p /workspace /app/data diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index 1b494a68..9a76fb9d 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -1,6 +1,7 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { opencodeServerManager } from '../services/opencode-single-server' +import { getBuildInfo } from '../services/build-info' export function createHealthRoutes(db: Database) { const app = new Hono() @@ -11,23 +12,30 @@ export function createHealthRoutes(db: Database) { const opencodeHealthy = await opencodeServerManager.checkHealth() const status = dbCheck && opencodeHealthy ? 'healthy' : 'degraded' + const build = getBuildInfo() return c.json({ status, timestamp: new Date().toISOString(), database: dbCheck ? 'connected' : 'disconnected', opencode: opencodeHealthy ? 'healthy' : 'unhealthy', - opencodePort: opencodeServerManager.getPort() + opencodePort: opencodeServerManager.getPort(), + build }) } catch (error) { return c.json({ status: 'unhealthy', timestamp: new Date().toISOString(), - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', + build: getBuildInfo() }, 503) } }) + app.get('/build', (c) => { + return c.json(getBuildInfo()) + }) + app.get('/processes', async (c) => { try { const opencodeHealthy = await opencodeServerManager.checkHealth() diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index 55abc7bb..19ed1e49 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -7,6 +7,7 @@ import { logger } from '../utils/logger' export function createProvidersRoutes() { const app = new Hono() const authService = new AuthService() + const oauthOnlyProviders = new Set(['github-copilot']) app.get('/credentials', async (c) => { try { @@ -21,8 +22,8 @@ export function createProvidersRoutes() { app.get('/:id/credentials/status', async (c) => { try { const providerId = c.req.param('id') - const hasCredentials = await authService.has(providerId) - return c.json({ hasCredentials }) + const status = await authService.getStatus(providerId) + return c.json(status) } catch (error) { logger.error('Failed to check credential status:', error) return c.json({ error: 'Failed to check credential status' }, 500) @@ -32,6 +33,10 @@ export function createProvidersRoutes() { app.post('/:id/credentials', async (c) => { try { const providerId = c.req.param('id') + if (oauthOnlyProviders.has(providerId)) { + return c.json({ error: 'This provider uses device/OAuth login and cannot be configured with an API key.' }, 400) + } + const body = await c.req.json() const validated = SetCredentialRequestSchema.parse(body) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index bd2ee7b5..27bdc85e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -66,7 +66,7 @@ export function createSettingsRoutes(db: Database) { try { const userId = c.req.query('userId') || 'default' const settings = settingsService.getSettings(userId) - return c.json(settings) + return c.json(settingsService.toClientSettings(settings)) } catch (error) { logger.error('Failed to get settings:', error) return c.json({ error: 'Failed to get settings' }, 500) @@ -80,7 +80,7 @@ export function createSettingsRoutes(db: Database) { const validated = UpdateSettingsSchema.parse(body) const settings = settingsService.updateSettings(validated.preferences, userId) - return c.json(settings) + return c.json(settingsService.toClientSettings(settings)) } catch (error) { logger.error('Failed to update settings:', error) if (error instanceof z.ZodError) { @@ -94,7 +94,7 @@ export function createSettingsRoutes(db: Database) { try { const userId = c.req.query('userId') || 'default' const settings = settingsService.resetSettings(userId) - return c.json(settings) + return c.json(settingsService.toClientSettings(settings)) } catch (error) { logger.error('Failed to reset settings:', error) return c.json({ error: 'Failed to reset settings' }, 500) diff --git a/backend/src/services/auth.ts b/backend/src/services/auth.ts index 33b9ca04..50b680cf 100644 --- a/backend/src/services/auth.ts +++ b/backend/src/services/auth.ts @@ -42,7 +42,8 @@ export class AuthService { const auth = await this.getAll() delete auth[providerId] - await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2)) + await fs.mkdir(path.dirname(this.authPath), { recursive: true }) + await fs.writeFile(this.authPath, JSON.stringify(auth, null, 2), { mode: 0o600 }) logger.info(`Deleted credentials for provider: ${providerId}`) } @@ -52,8 +53,46 @@ export class AuthService { } async has(providerId: string): Promise { - const auth = await this.getAll() - return !!auth[providerId] + const status = await this.getStatus(providerId) + return status.hasCredentials + } + + async getStatus(providerId: string): Promise<{ + hasCredentials: boolean + type: AuthEntry['type'] | null + expired: boolean + expires: number | null + hasAccessToken: boolean + hasRefreshToken: boolean + }> { + const entry = await this.get(providerId) + if (!entry) { + return { + hasCredentials: false, + type: null, + expired: false, + expires: null, + hasAccessToken: false, + hasRefreshToken: false, + } + } + + const hasAccessToken = typeof entry.access === 'string' && entry.access.length > 0 + const hasRefreshToken = typeof entry.refresh === 'string' && entry.refresh.length > 0 + const expires = typeof entry.expires === 'number' ? entry.expires : null + const expired = entry.type === 'oauth' && expires !== null ? expires <= Date.now() : false + const hasCredentials = entry.type === 'apiKey' + ? typeof entry.apiKey === 'string' && entry.apiKey.length > 0 + : hasAccessToken || hasRefreshToken + + return { + hasCredentials, + type: entry.type, + expired, + expires, + hasAccessToken, + hasRefreshToken, + } } async get(providerId: string): Promise { diff --git a/backend/src/services/build-info.ts b/backend/src/services/build-info.ts new file mode 100644 index 00000000..9115853e --- /dev/null +++ b/backend/src/services/build-info.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'fs' +import path from 'path' + +type BuildInfo = { + packageVersion: string + buildTime: string + git: { + sha: string + shortSha: string + dirty: boolean + } +} + +const DEFAULT_BUILD_INFO: BuildInfo = { + packageVersion: 'unknown', + buildTime: 'unknown', + git: { + sha: 'unknown', + shortSha: 'unknown', + dirty: false, + }, +} + +let cachedBuildInfo: BuildInfo | null = null + +export function getBuildInfo(): BuildInfo { + if (cachedBuildInfo) { + return cachedBuildInfo + } + + const buildInfoPath = path.join(process.cwd(), 'build-info.json') + + try { + const parsed = JSON.parse(readFileSync(buildInfoPath, 'utf8')) + cachedBuildInfo = { + packageVersion: typeof parsed.packageVersion === 'string' ? parsed.packageVersion : DEFAULT_BUILD_INFO.packageVersion, + buildTime: typeof parsed.buildTime === 'string' ? parsed.buildTime : DEFAULT_BUILD_INFO.buildTime, + git: { + sha: typeof parsed.git?.sha === 'string' ? parsed.git.sha : DEFAULT_BUILD_INFO.git.sha, + shortSha: typeof parsed.git?.shortSha === 'string' ? parsed.git.shortSha : DEFAULT_BUILD_INFO.git.shortSha, + dirty: typeof parsed.git?.dirty === 'boolean' ? parsed.git.dirty : DEFAULT_BUILD_INFO.git.dirty, + }, + } + } catch { + cachedBuildInfo = DEFAULT_BUILD_INFO + } + + return cachedBuildInfo +} diff --git a/backend/src/services/proxy.ts b/backend/src/services/proxy.ts index 0248d266..d9c2e597 100644 --- a/backend/src/services/proxy.ts +++ b/backend/src/services/proxy.ts @@ -3,6 +3,32 @@ import { ENV } from '@opencode-webui/shared' const OPENCODE_SERVER_URL = `http://127.0.0.1:${ENV.OPENCODE.PORT}` +function enrichProviderPayload(payload: unknown) { + if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { providers?: unknown[] }).providers)) { + return payload + } + + return { + ...(payload as Record), + providers: (payload as { providers: Array> }).providers.map((provider) => { + const authMethod = provider.id === 'github-copilot' ? 'oauth' : 'apiKey' + const options = provider.options && typeof provider.options === 'object' + ? { ...(provider.options as Record) } + : undefined + + if (authMethod === 'oauth' && options) { + delete options.apiKey + } + + return { + ...provider, + authMethod, + ...(options ? { options } : {}), + } + }), + } +} + export async function patchOpenCodeConfig(config: Record): Promise { try { const response = await fetch(`${OPENCODE_SERVER_URL}/config`, { @@ -53,6 +79,18 @@ export async function proxyRequest(request: Request) { } }) + if (request.method === 'GET' && cleanPath === '/config/providers') { + const payload = await response.json() + return new Response(JSON.stringify(enrichProviderPayload(payload)), { + status: response.status, + statusText: response.statusText, + headers: { + ...responseHeaders, + 'content-type': 'application/json', + }, + }) + } + return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index 0bffbb62..80069ac1 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -18,6 +18,40 @@ import { export class SettingsService { constructor(private db: Database) {} + private normalizeKeyboardShortcuts(shortcuts: UserPreferences['keyboardShortcuts'] | undefined): UserPreferences['keyboardShortcuts'] { + const merged = { + ...DEFAULT_USER_PREFERENCES.keyboardShortcuts, + ...(shortcuts || {}), + } + + if ((merged.compact || '').trim().toLowerCase() === 'ctrl') { + merged.compact = DEFAULT_USER_PREFERENCES.keyboardShortcuts.compact + } + + if ((merged.toggleMode || '').trim().toLowerCase() === 'tab') { + merged.toggleMode = DEFAULT_USER_PREFERENCES.keyboardShortcuts.toggleMode + } + + return merged + } + + private normalizePreferences(preferences: UserPreferences): UserPreferences { + return { + ...preferences, + keyboardShortcuts: this.normalizeKeyboardShortcuts(preferences.keyboardShortcuts), + } + } + + toClientSettings(settings: SettingsResponse): SettingsResponse { + return { + ...settings, + preferences: { + ...settings.preferences, + gitToken: undefined, + }, + } + } + getSettings(userId: string = 'default'): SettingsResponse { const row = this.db .query('SELECT preferences, updated_at FROM user_preferences WHERE user_id = ?') @@ -25,17 +59,17 @@ export class SettingsService { if (!row) { return { - preferences: DEFAULT_USER_PREFERENCES, + preferences: this.normalizePreferences(DEFAULT_USER_PREFERENCES), updatedAt: Date.now(), } } try { const parsed = JSON.parse(row.preferences) - const validated = UserPreferencesSchema.parse({ + const validated = UserPreferencesSchema.parse(this.normalizePreferences({ ...DEFAULT_USER_PREFERENCES, ...parsed, - }) + })) return { preferences: validated, @@ -44,7 +78,7 @@ export class SettingsService { } catch (error) { logger.error('Failed to parse user preferences, returning defaults', error) return { - preferences: DEFAULT_USER_PREFERENCES, + preferences: this.normalizePreferences(DEFAULT_USER_PREFERENCES), updatedAt: row.updated_at, } } @@ -55,10 +89,10 @@ export class SettingsService { userId: string = 'default' ): SettingsResponse { const current = this.getSettings(userId) - const merged: UserPreferences = { + const merged: UserPreferences = this.normalizePreferences({ ...current.preferences, ...updates, - } + }) const validated = UserPreferencesSchema.parse(merged) const updatedAt = Date.now() @@ -87,7 +121,7 @@ export class SettingsService { logger.info(`Reset preferences for user: ${userId}`) return { - preferences: DEFAULT_USER_PREFERENCES, + preferences: this.normalizePreferences(DEFAULT_USER_PREFERENCES), updatedAt: Date.now(), } } diff --git a/frontend/index.html b/frontend/index.html index 3f7e6447..41264778 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,11 +3,10 @@ - - + OpenCode WebUI diff --git a/frontend/src/api/providers.ts b/frontend/src/api/providers.ts index 6a119de5..32b739c3 100644 --- a/frontend/src/api/providers.ts +++ b/frontend/src/api/providers.ts @@ -27,6 +27,7 @@ export interface Model { experimental?: boolean; status?: "alpha" | "beta"; options?: Record; + authMethod?: "oauth" | "apiKey" | "none"; provider?: { npm: string; }; @@ -40,6 +41,7 @@ export interface Provider { npm?: string; models: Record; options?: Record; + authMethod?: "oauth" | "apiKey" | "none"; } export interface ProviderWithModels { @@ -49,10 +51,11 @@ export interface ProviderWithModels { env: string[]; npm?: string; models: Model[]; + authMethod?: "oauth" | "apiKey" | "none"; } // Default providers for common OpenCode setups -const DEFAULT_PROVIDERS: Provider[] = [ +export const DEFAULT_PROVIDERS: Provider[] = [ { id: "anthropic", name: "Anthropic", @@ -170,47 +173,82 @@ const DEFAULT_PROVIDERS: Provider[] = [ ]; async function getProvidersFromConfig(): Promise { + try { + const { data } = await axios.get(`${API_BASE_URL}/api/opencode/config/providers`); + if (Array.isArray(data?.providers)) { + return data.providers.map((provider: any) => ({ + id: provider.id, + name: provider.name || provider.id, + api: provider.api, + env: Array.isArray(provider.env) ? provider.env : [], + npm: provider.npm, + models: provider.models || {}, + options: provider.options || {}, + authMethod: provider.authMethod || 'apiKey', + })); + } + } catch (error) { + console.warn("Failed to load live providers", error); + } + try { const configResponse = await settingsApi.getDefaultOpenCodeConfig(); if (configResponse?.content?.provider) { const providerRecord = configResponse.content.provider as Record; - const providers = Object.entries(providerRecord).map(([id, provider]) => ({ + return Object.entries(providerRecord).map(([id, provider]) => ({ ...provider, id: provider.id || id, + name: provider.name || id, + env: provider.env || [], + models: provider.models || {}, + authMethod: provider.authMethod || 'apiKey', })); - return providers; } } catch (error) { console.warn("Failed to load OpenCode config", error); } - return DEFAULT_PROVIDERS; + return []; } + export async function getProviders(): Promise { return await getProvidersFromConfig(); } +function normalizeModel(id: string, model: any): Model { + const capabilities = model?.capabilities || {}; + return { + ...model, + id: model?.id || id, + name: model?.name || id, + release_date: model?.release_date, + attachment: model?.attachment ?? capabilities.attachment ?? false, + reasoning: model?.reasoning ?? capabilities.reasoning ?? false, + temperature: model?.temperature ?? capabilities.temperature ?? false, + tool_call: model?.tool_call ?? capabilities.toolcall ?? false, + cost: model?.cost, + limit: model?.limit, + modalities: model?.modalities, + experimental: model?.experimental ?? false, + status: model?.status, + options: model?.options, + provider: model?.provider, + }; +} + export async function getProvidersWithModels(): Promise { const providers = await getProviders(); - const result = providers.map((provider) => { - const models = Object.entries(provider.models || {}).map(([id, model]) => ({ - ...model, - id: model.id || id, - name: model.name || id, - })); - return { - id: provider.id, - name: provider.name, - api: provider.api, - env: provider.env || [], - npm: provider.npm, - models, - }; - }); - - return result; + return providers.map((provider) => ({ + id: provider.id, + name: provider.name || provider.id, + api: provider.api, + env: provider.env || [], + npm: provider.npm, + authMethod: provider.authMethod, + models: Object.entries(provider.models || {}).map(([id, model]) => normalizeModel(id, model)), + })); } export async function getModel( @@ -240,11 +278,11 @@ export const providerCredentialsApi = { return data.providers; }, - getStatus: async (providerId: string): Promise => { + getStatus: async (providerId: string): Promise<{ hasCredentials: boolean; type: "apiKey" | "oauth" | null; expired: boolean; expires: number | null; hasAccessToken: boolean; hasRefreshToken: boolean }> => { const { data } = await axios.get( `${API_BASE_URL}/api/providers/${providerId}/credentials/status` ); - return data.hasCredentials; + return data; }, set: async (providerId: string, apiKey: string): Promise => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8ec93415..1de94ec8 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -116,6 +116,38 @@ export interface SSEInstallationUpdateAvailableEvent { } } +export interface SSEFileEditedEvent { + type: 'file.edited' + properties: { + file: string + } +} + +export interface SSEFileWatcherUpdatedEvent { + type: 'file.watcher.updated' + properties: { + file: string + event: 'add' | 'change' | 'unlink' + } +} + +export interface SSECommandExecutedEvent { + type: 'command.executed' + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export interface SSESessionIdleEvent { + type: 'session.idle' + properties: { + sessionID: string + } +} + export type SSEEvent = | SSEMessagePartUpdatedEvent | SSEMessageUpdatedEvent @@ -129,6 +161,10 @@ export type SSEEvent = | SSEPermissionRepliedEvent | SSEInstallationUpdatedEvent | SSEInstallationUpdateAvailableEvent + | SSEFileEditedEvent + | SSEFileWatcherUpdatedEvent + | SSECommandExecutedEvent + | SSESessionIdleEvent export type ContentPart = | { type: 'text', content: string } diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 6e3dcfaf..1d65b4a6 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -83,7 +83,7 @@ const CMD_KEY = isMac ? 'Cmd' : 'Ctrl' export const DEFAULT_KEYBOARD_SHORTCUTS: Record = { submit: `${CMD_KEY}+Enter`, abort: 'Escape', - toggleMode: 'Tab', + toggleMode: `${CMD_KEY}+Shift+P`, undo: `${CMD_KEY}+Z`, redo: `${CMD_KEY}+Shift+Z`, compact: `${CMD_KEY}+K`, diff --git a/frontend/src/components/file-browser/FilePreviewDialog.tsx b/frontend/src/components/file-browser/FilePreviewDialog.tsx index 12ef5a0f..2455b681 100644 --- a/frontend/src/components/file-browser/FilePreviewDialog.tsx +++ b/frontend/src/components/file-browser/FilePreviewDialog.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { FilePreview } from './FilePreview' import { Loader2, FileText } from 'lucide-react' import { API_BASE_URL } from '@/config' @@ -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 > + File Preview
{isLoading ? (
diff --git a/frontend/src/components/file-browser/GitChangesSheet.tsx b/frontend/src/components/file-browser/GitChangesSheet.tsx index 1cb177df..693c70f1 100644 --- a/frontend/src/components/file-browser/GitChangesSheet.tsx +++ b/frontend/src/components/file-browser/GitChangesSheet.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Dialog, DialogContent } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { GitChangesPanel } from './GitChangesPanel' import { FileDiffView } from './FileDiffView' import { FilePreviewDialog } from './FilePreviewDialog' @@ -65,6 +65,7 @@ export function GitChangesSheet({ isOpen, onClose, repoId, currentBranch, repoLo className="w-screen h-screen max-w-none max-h-none p-0 gap-0 bg-background border-0 flex flex-col" hideCloseButton > + {selectedFile ? 'File Changes' : 'Git Changes'}
diff --git a/frontend/src/components/message/MessageThread.tsx b/frontend/src/components/message/MessageThread.tsx index 4920e2ed..6e1b25fe 100644 --- a/frontend/src/components/message/MessageThread.tsx +++ b/frontend/src/components/message/MessageThread.tsx @@ -18,8 +18,19 @@ interface MessageThreadProps { onFileClick?: (filePath: string, lineNumber?: number) => void } +const isWaitingForUserInput = (msg: MessageWithParts): boolean => { + if (msg.info.role !== 'assistant') return false + return msg.parts.some( + (part) => + part.type === 'tool' && + part.tool === 'question' && + part.state.status === 'running' + ) +} + const isMessageStreaming = (msg: MessageWithParts): boolean => { if (msg.info.role !== 'assistant') return false + if (isWaitingForUserInput(msg)) return false return !('completed' in msg.info.time && msg.info.time.completed) } @@ -69,6 +80,11 @@ export const MessageThread = memo(function MessageThread({ messages, onFileClick Generating... )} + {!streaming && isWaitingForUserInput(msg) && ( + + Waiting for input... + + )}
{thinking ? ( diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index faef6e51..021b24dd 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -67,7 +67,7 @@ export function PromptInput({ const { data: messages } = useMessages(opcodeUrl, sessionID, directory) const { data: config } = useConfig(opcodeUrl) const { preferences, updateSettings } = useSettings() - const { filterCommands } = useCommands(opcodeUrl) + const { commands, filterCommands } = useCommands(opcodeUrl) const { executeCommand } = useCommandHandler({ opcodeUrl, sessionID, @@ -110,12 +110,12 @@ export function PromptInput({ const commandMatch = prompt.match(/^\/([a-zA-Z0-9_-]+)(?:\s+(.*))?$/) if (commandMatch) { - const [, commandName] = commandMatch - const command = filterCommands(commandName)[0] + const [, commandName, commandArguments = ''] = commandMatch + const command = commands.find((candidate) => candidate.name === commandName) if (command) { - executeCommand(command) + executeCommand(command, commandArguments) setPrompt('') if (textareaRef.current) { textareaRef.current.style.height = 'auto' @@ -374,12 +374,24 @@ export function PromptInput({ } } + const isWaitingForUserInput = (msg: MessageWithParts): boolean => { + if (msg.info.role !== 'assistant') return false + return msg.parts.some( + (part) => + part.type === 'tool' && + part.tool === 'question' && + part.state.status === 'running' + ) + } + const isMessageStreaming = (msg: MessageWithParts): boolean => { if (msg.info.role !== 'assistant') return false + if (isWaitingForUserInput(msg)) return false return !('completed' in msg.info.time && msg.info.time.completed) } const hasActiveStream = messages?.some(msg => isMessageStreaming(msg)) || false + const isAwaitingResponse = messages?.some(msg => isWaitingForUserInput(msg)) || false const currentMode = preferences?.mode || 'build' const modeColor = currentMode === 'plan' ? 'text-yellow-600 dark:text-yellow-500' : 'text-green-600 dark:text-green-500' @@ -389,7 +401,7 @@ export function PromptInput({ const sessionModel = lastAssistantMessage?.info.role === 'assistant' ? `${lastAssistantMessage.info.providerID}/${lastAssistantMessage.info.modelID}` : null - const currentModel = sessionModel || config?.model || '' + const currentModel = sessionModel || preferences?.defaultModel || config?.model || '' useEffect(() => { const loadModelName = async () => { @@ -437,7 +449,9 @@ export function PromptInput({ placeholder={ isBashMode ? "Enter bash command..." - : "Send a message..." + : isAwaitingResponse + ? "Answer the question..." + : "Send a message..." } disabled={disabled || hasActiveStream} className={`w-full bg-background/90 px-2 md:px-3 py-2 text-[16px] text-foreground placeholder-muted-foreground focus:outline-none focus:bg-black resize-none min-h-[40px] max-h-[120px] disabled:opacity-50 disabled:cursor-not-allowed md:text-sm rounded-lg ${ diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 3a1cf19f..263d8455 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -36,6 +36,48 @@ interface ToolCallPartProps { onFileClick?: (filePath: string, lineNumber?: number) => void } +interface QuestionOption { + label?: string + description?: string +} + +interface QuestionGroup { + header?: string + question?: string + multiple?: boolean + options?: QuestionOption[] +} + +function QuestionToolInput({ input }: { input: Record }) { + const questions = Array.isArray(input.questions) ? input.questions as QuestionGroup[] : [] + + if (questions.length === 0) { + return + } + + return ( +
+ {questions.map((question, index) => ( +
+ {question.header ?
{question.header}
: null} + {question.question ?
{question.question}
: null} + {Array.isArray(question.options) && question.options.length > 0 ? ( +
+ {question.options.map((option, optionIndex) => ( +
+
{option.label || `Option ${optionIndex + 1}`}
+ {option.description ?
{option.description}
: null} +
+ ))} +
+ ) : null} +
Reply in the message box below to continue.
+
+ ))} +
+ ) +} + function ClickableJson({ json, onFileClick }: { json: unknown; onFileClick?: (filePath: string) => void }) { const jsonString = JSON.stringify(json, null, 2) const references = detectFileReferences(jsonString) @@ -204,7 +246,9 @@ export function ToolCallPart({ part, onFileClick }: ToolCallPartProps) { {part.state.status === 'running' && (
Input:
- + {part.tool === 'question' + ? } /> + : }
)} diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 1b47f158..836a916c 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -4,6 +4,7 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,28 +16,22 @@ import { formatProviderName, } from "@/api/providers"; import { useSettings } from "@/hooks/useSettings"; -import { useOpenCodeClient } from "@/hooks/useOpenCode"; -import { useParams } from "react-router-dom"; import type { ProviderWithModels, Model } from "@/api/providers"; interface ModelSelectDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - opcodeUrl?: string | null; } export function ModelSelectDialog({ open, onOpenChange, - opcodeUrl, }: ModelSelectDialogProps) { const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [selectedProvider, setSelectedProvider] = useState(""); const { preferences, updateSettings } = useSettings(); - const client = useOpenCodeClient(opcodeUrl); - const { sessionID } = useParams<{ sessionID: string }>(); const currentModel = preferences?.defaultModel || ""; @@ -55,7 +50,7 @@ export function ModelSelectDialog({ } finally { setLoading(false); } - }, [opcodeUrl, currentModel]); + }, [currentModel]); useEffect(() => { if (open) { @@ -65,10 +60,10 @@ export function ModelSelectDialog({ const filteredProviders = providers.filter((provider) => { const matchesSearch = - provider.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (provider.name || provider.id).toLowerCase().includes(searchQuery.toLowerCase()) || provider.models.some( (model) => - model.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (model.name || model.id).toLowerCase().includes(searchQuery.toLowerCase()) || model.id.toLowerCase().includes(searchQuery.toLowerCase()), ); @@ -78,25 +73,10 @@ export function ModelSelectDialog({ return matchesSearch && matchesProvider; }); - const handleModelSelect = async (providerId: string, modelId: string) => { + const handleModelSelect = (providerId: string, modelId: string) => { const newModel = `${providerId}/${modelId}`; - // Update settings for future sessions updateSettings({ defaultModel: newModel }); - - // If we're in a session, try to update the current session's model - if (sessionID && client) { - try { - await client.sendCommand(sessionID, { - command: "model", - arguments: newModel, - model: newModel, - }); - } catch { - // Ignore errors when updating session model - } - } - onOpenChange(false); }; @@ -128,6 +108,9 @@ export function ModelSelectDialog({ Select Model + + Choose a live model from the current OpenCode provider configuration. +
diff --git a/frontend/src/components/session/ContextUsageIndicator.tsx b/frontend/src/components/session/ContextUsageIndicator.tsx index 2fafc48b..ede2a161 100644 --- a/frontend/src/components/session/ContextUsageIndicator.tsx +++ b/frontend/src/components/session/ContextUsageIndicator.tsx @@ -14,7 +14,7 @@ export function ContextUsageIndicator({ opcodeUrl, sessionID, directory }: Conte const { preferences } = useSettings() const [modelName, setModelName] = useState('') - const displayModel = preferences?.defaultModel || currentModel || '' + const displayModel = currentModel || preferences?.defaultModel || '' useEffect(() => { const loadModelName = async () => { diff --git a/frontend/src/components/settings/GeneralSettings.tsx b/frontend/src/components/settings/GeneralSettings.tsx index 984db30d..e138c915 100644 --- a/frontend/src/components/settings/GeneralSettings.tsx +++ b/frontend/src/components/settings/GeneralSettings.tsx @@ -1,22 +1,40 @@ -import { useState, useEffect } from 'react' +import { useState } from 'react' import { useSettings } from '@/hooks/useSettings' import { Loader2 } from 'lucide-react' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' import { TTSSettings } from './TTSSettings' export function GeneralSettings() { - const { preferences, isLoading, updateSettings, isUpdating } = useSettings() + const { preferences, isLoading, updateSettings, updateSettingsAsync, isUpdating } = useSettings() const [gitToken, setGitToken] = useState('') - - useEffect(() => { - if (preferences) { - setGitToken(preferences.gitToken || '') + const [isSavingToken, setIsSavingToken] = useState(false) + + const handleSaveGitToken = async () => { + const trimmed = gitToken.trim() + if (!trimmed) return + setIsSavingToken(true) + try { + await updateSettingsAsync({ gitToken: trimmed }) + setGitToken('') + } finally { + setIsSavingToken(false) } - }, [preferences]) + } + + const handleClearGitToken = async () => { + setIsSavingToken(true) + try { + await updateSettingsAsync({ gitToken: '' }) + setGitToken('') + } finally { + setIsSavingToken(false) + } + } if (isLoading) { return ( @@ -112,20 +130,29 @@ export function GeneralSettings() { />
-
- - setGitToken(e.target.value)} - onBlur={() => updateSettings({ gitToken })} - className="bg-background border-border text-foreground placeholder:text-muted-foreground" - /> -

- Required for cloning private repos. Get one at github.com/settings/tokens -

+
+
+ + setGitToken(e.target.value)} + className="bg-background border-border text-foreground placeholder:text-muted-foreground" + /> +

+ Stored tokens are hidden from the UI. Enter a new token to save or replace it, or clear the stored token entirely. +

+
+
+ + +
{isUpdating && ( diff --git a/frontend/src/components/settings/ProviderSettings.tsx b/frontend/src/components/settings/ProviderSettings.tsx index 7b1c9fcb..697a82c8 100644 --- a/frontend/src/components/settings/ProviderSettings.tsx +++ b/frontend/src/components/settings/ProviderSettings.tsx @@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import { Badge } from '@/components/ui/badge' import { Loader2, Key, Check, X, Plus } from 'lucide-react' import { providerCredentialsApi, getProviders, type Provider } from '@/api/providers' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import { AddProviderDialog } from './AddProviderDialog' export function ProviderSettings() { @@ -23,9 +23,13 @@ export function ProviderSettings() { staleTime: 300000, }) - const { data: credentialsList, isLoading: credentialsLoading } = useQuery({ - queryKey: ['provider-credentials'], - queryFn: () => providerCredentialsApi.list(), + const credentialStatuses = useQueries({ + queries: (providers || []).map((provider) => ({ + queryKey: ['provider-credentials', provider.id], + queryFn: () => providerCredentialsApi.getStatus(provider.id), + staleTime: 300000, + enabled: !!providers, + })), }) const setCredentialMutation = useMutation({ @@ -57,11 +61,14 @@ export function ProviderSettings() { } } - const hasCredentials = (providerId: string) => { - return credentialsList?.includes(providerId) || false - } + const credentialStatusMap = new Map( + (providers || []).map((provider, index) => [provider.id, credentialStatuses[index]?.data]) + ) + + const getCredentialStatus = (providerId: string) => credentialStatusMap.get(providerId) + const isOAuthProvider = (provider: Provider) => provider.authMethod === 'oauth' - if (providersLoading || credentialsLoading) { + if (providersLoading || credentialStatuses.some((query) => query.isLoading)) { return (
@@ -95,7 +102,18 @@ export function ProviderSettings() { ) : (
{providers.map((provider) => { - const hasKey = hasCredentials(provider.id) + const credentialStatus = getCredentialStatus(provider.id) + const hasKey = credentialStatus?.hasCredentials || false + const isOAuth = isOAuthProvider(provider) + const statusLabel = isOAuth + ? credentialStatus?.expired + ? 'Expired' + : hasKey + ? 'Connected' + : 'Not Connected' + : hasKey + ? 'Configured' + : 'No Key' const modelCount = Object.keys(provider.models || {}).length return ( @@ -108,17 +126,22 @@ export function ProviderSettings() { {hasKey ? ( - Configured + {statusLabel} ) : ( - No Key + {statusLabel} )} {provider.npm ? Package: {provider.npm} : null} + {isOAuth && ( + + Uses device/OAuth login. Reconnect from the server with opencode auth login if the stored token expires. + + )} {typeof provider.options?.baseURL === 'string' && ( {provider.options.baseURL} )} @@ -128,15 +151,17 @@ export function ProviderSettings() {
- - {hasKey && ( + {!isOAuth && ( + + )} + {hasKey && !isOAuth && (