From 42149c4759fb82b077f89518cfd12493b787ecea Mon Sep 17 00:00:00 2001 From: OpenCode Assistant Date: Mon, 8 Dec 2025 13:52:40 +0000 Subject: [PATCH 1/3] Fix mobile input UI issues - Fix down arrow disappearing at bottom and model visibility - Only hide secondary buttons on mobile during active streaming, not when scroll button shows - Fix stop button to only show during actual session processing (busy/retry states) - Integrate session status with streaming state for better stop button control --- frontend/src/components/message/PromptInput.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index 5c25c3df..cd14cdb6 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -8,6 +8,7 @@ import { useModelSelection } from '@/hooks/useModelSelection' import { useUserBash } from '@/stores/userBashStore' import { useMobile } from '@/hooks/useMobile' +import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { ChevronDown } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' @@ -451,7 +452,9 @@ export function PromptInput({ const { model: selectedModel, modelString } = useModelSelection(opcodeUrl, directory) const currentModel = modelString || '' const isMobile = useMobile() - const hideSecondaryButtons = isMobile && (hasActiveStream || showScrollButton) + const sessionStatus = useSessionStatusForSession(sessionID) + const showStopButton = hasActiveStream && (sessionStatus.type === 'busy' || sessionStatus.type === 'retry') + const hideSecondaryButtons = isMobile && hasActiveStream useEffect(() => { const loadModelName = async () => { @@ -566,7 +569,7 @@ export function PromptInput({ )} - {hasActiveStream && ( +{showStopButton && ( )} @@ -259,7 +285,7 @@ onSuccess: async () => { checked={enabled} onCheckedChange={setEnabled} /> - + @@ -269,13 +295,13 @@ onSuccess: async () => { ) -} \ No newline at end of file +} diff --git a/frontend/src/components/settings/McpManager.tsx b/frontend/src/components/settings/McpManager.tsx index 50224a5d..54256377 100644 --- a/frontend/src/components/settings/McpManager.tsx +++ b/frontend/src/components/settings/McpManager.tsx @@ -4,14 +4,16 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogTrigger } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Switch } from '@/components/ui/switch' -import { Plus, Trash2, Globe, Terminal, Loader2 } from 'lucide-react' +import { Plus, Trash2, Globe, Terminal, Loader2, AlertCircle, RefreshCw, Key, XCircle } from 'lucide-react' import { DeleteDialog } from '@/components/ui/delete-dialog' import { AddMcpServerDialog } from './AddMcpServerDialog' +import { useMcpServers } from '@/hooks/useMcpServers' import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { McpStatus } from '@/api/mcp' interface McpServerConfig { type: 'local' | 'remote' - enabled: boolean + enabled?: boolean command?: string[] url?: string environment?: Record @@ -27,51 +29,61 @@ interface McpManagerProps { onConfigUpdate?: (configName: string, content: Record) => Promise } +function getStatusBadge(status: McpStatus) { + switch (status.status) { + case 'connected': + return Connected + case 'disabled': + return Disabled + case 'failed': + return ( + + + Failed + + ) + case 'needs_auth': + return ( + + + Auth Required + + ) + case 'needs_client_registration': + return ( + + + Registration Required + + ) + default: + return Unknown + } +} + export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [deleteConfirmServer, setDeleteConfirmServer] = useState<{ id: string; name: string } | null>(null) const [togglingServerId, setTogglingServerId] = useState(null) const queryClient = useQueryClient() - - const toggleServerMutation = useMutation({ - mutationFn: async ({ serverId, enabled }: { serverId: string; enabled: boolean }) => { - if (!config) return - - setTogglingServerId(serverId) - - const currentMcp = (config.content?.mcp as Record) || {} - const serverConfig = currentMcp[serverId] - - if (!serverConfig) return - - const updatedConfig = { - ...config.content, - mcp: { - ...currentMcp, - [serverId]: { - ...serverConfig, - enabled, - }, - }, - } - - await onUpdate(updatedConfig) - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) - }, - onSettled: () => { - setTogglingServerId(null) - }, - }) + const { + status: mcpStatus, + isLoading: isLoadingStatus, + refetch: refetchStatus, + connect, + disconnect, + authenticate, + isToggling + } = useMcpServers() const deleteServerMutation = useMutation({ 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 { [serverId]: _, ...rest } = currentMcp + void _ const updatedConfig = { ...config.content, @@ -82,16 +94,34 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['opencode-config'] }) + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) setDeleteConfirmServer(null) }, }) const mcpServers = config?.content?.mcp as Record || {} - const isAnyOperationPending = toggleServerMutation.isPending || deleteServerMutation.isPending || togglingServerId !== null + const isAnyOperationPending = deleteServerMutation.isPending || togglingServerId !== null || isToggling - const handleToggleServer = (serverId: string, enabled: boolean) => { - toggleServerMutation.mutate({ serverId, enabled }) + const handleToggleServer = async (serverId: string) => { + const currentStatus = mcpStatus?.[serverId] + if (!currentStatus) return + + setTogglingServerId(serverId) + try { + if (currentStatus.status === 'connected') { + await disconnect(serverId) + } else if (currentStatus.status === 'disabled') { + await connect(serverId) + } else if (currentStatus.status === 'needs_auth') { + await authenticate(serverId) + } else if (currentStatus.status === 'failed') { + await connect(serverId) + } + } finally { + setTogglingServerId(null) + refetchStatus() + } } const handleDeleteServer = () => { @@ -125,6 +155,14 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps return 'MCP server' } + const getErrorMessage = (serverId: string): string | null => { + const status = mcpStatus?.[serverId] + if (!status) return null + if (status.status === 'failed') return status.error + if (status.status === 'needs_client_registration') return status.error + return null + } + if (!config) { return (
@@ -155,19 +193,29 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps Manage Model Context Protocol servers for {config.name}

- - - - - - +
+ + + + + + + +
{Object.keys(mcpServers).length === 0 ? ( @@ -178,58 +226,70 @@ export function McpManager({ config, onUpdate, onConfigUpdate }: McpManagerProps ) : (
- {Object.entries(mcpServers).map(([serverId, serverConfig]) => ( - - -
-
-
- {serverConfig.type === 'local' ? ( - - ) : ( - - )} - {getServerDisplayName(serverId)} + {Object.entries(mcpServers).map(([serverId, serverConfig]) => { + const status = mcpStatus?.[serverId] + const isConnected = status?.status === 'connected' + const errorMessage = getErrorMessage(serverId) + + return ( + + +
+
+
+ {serverConfig.type === 'local' ? ( + + ) : ( + + )} + {getServerDisplayName(serverId)} +
+
+ {status ? getStatusBadge(status) : ( + Loading... + )} + + {serverConfig.type} + +
- - {serverConfig.enabled ? 'Enabled' : 'Disabled'} - - - {serverConfig.type} - + handleToggleServer(serverId)} + disabled={isAnyOperationPending || togglingServerId === serverId} + /> +
-
- handleToggleServer(serverId, enabled)} - disabled={isAnyOperationPending} - /> - + + +
+

{getServerDescription(serverConfig)}

+ {serverConfig.timeout && ( +

Timeout: {serverConfig.timeout}ms

+ )} + {serverConfig.environment && Object.keys(serverConfig.environment).length > 0 && ( +

Environment variables: {Object.keys(serverConfig.environment).length} configured

+ )} + {errorMessage && ( +
+ + {errorMessage} +
+ )}
-
-
- -
-

{getServerDescription(serverConfig)}

- {serverConfig.timeout && ( -

Timeout: {serverConfig.timeout}ms

- )} - {serverConfig.environment && Object.keys(serverConfig.environment).length > 0 && ( -

Environment variables: {Object.keys(serverConfig.environment).length} configured

- )} -
-
-
- ))} + + + ) + })}
)} diff --git a/frontend/src/hooks/useMcpServers.ts b/frontend/src/hooks/useMcpServers.ts new file mode 100644 index 00000000..341cc892 --- /dev/null +++ b/frontend/src/hooks/useMcpServers.ts @@ -0,0 +1,139 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { mcpApi } from '@/api/mcp' +import type { McpStatusMap, McpServerConfig, McpStatus } from '@/api/mcp' +import { showToast as toast } from '@/lib/toast' + +export function useMcpServers() { + const queryClient = useQueryClient() + + const statusQuery = useQuery({ + queryKey: ['mcp-status'], + queryFn: () => mcpApi.getStatus(), + refetchInterval: 5000, + staleTime: 2000, + }) + + const addServerMutation = useMutation({ + mutationFn: ({ name, config }: { name: string; config: McpServerConfig }) => + mcpApi.addServer(name, config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server added successfully') + }, + onError: (error: Error) => { + toast.error(`Failed to add MCP server: ${error.message}`) + }, + }) + + const connectMutation = useMutation({ + mutationFn: (name: string) => mcpApi.connect(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server connected') + }, + onError: (error: Error) => { + toast.error(`Failed to connect MCP server: ${error.message}`) + }, + }) + + const disconnectMutation = useMutation({ + mutationFn: (name: string) => mcpApi.disconnect(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('MCP server disconnected') + }, + onError: (error: Error) => { + toast.error(`Failed to disconnect MCP server: ${error.message}`) + }, + }) + + const startAuthMutation = useMutation({ + mutationFn: (name: string) => mcpApi.startAuth(name), + onError: (error: Error) => { + toast.error(`Failed to start authentication: ${error.message}`) + }, + }) + + const completeAuthMutation = useMutation({ + mutationFn: ({ name, code }: { name: string; code: string }) => + mcpApi.completeAuth(name, code), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication completed') + }, + onError: (error: Error) => { + toast.error(`Failed to complete authentication: ${error.message}`) + }, + }) + + const authenticateMutation = useMutation({ + mutationFn: (name: string) => mcpApi.authenticate(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication completed') + }, + onError: (error: Error) => { + toast.error(`Failed to authenticate: ${error.message}`) + }, + }) + + const removeAuthMutation = useMutation({ + mutationFn: (name: string) => mcpApi.removeAuth(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp-status'] }) + toast.success('Authentication credentials removed') + }, + onError: (error: Error) => { + toast.error(`Failed to remove authentication: ${error.message}`) + }, + }) + + const toggleServer = async (name: string, currentStatus: McpStatus) => { + if (currentStatus.status === 'connected') { + return disconnectMutation.mutateAsync(name) + } else if (currentStatus.status === 'disabled') { + return connectMutation.mutateAsync(name) + } else if (currentStatus.status === 'needs_auth') { + return authenticateMutation.mutateAsync(name) + } + } + + return { + status: statusQuery.data as McpStatusMap | undefined, + isLoading: statusQuery.isLoading, + isError: statusQuery.isError, + error: statusQuery.error, + refetch: statusQuery.refetch, + + addServer: addServerMutation.mutate, + addServerAsync: addServerMutation.mutateAsync, + isAddingServer: addServerMutation.isPending, + + connect: connectMutation.mutate, + connectAsync: connectMutation.mutateAsync, + isConnecting: connectMutation.isPending, + + disconnect: disconnectMutation.mutate, + disconnectAsync: disconnectMutation.mutateAsync, + isDisconnecting: disconnectMutation.isPending, + + toggleServer, + isToggling: connectMutation.isPending || disconnectMutation.isPending || authenticateMutation.isPending, + + startAuth: startAuthMutation.mutate, + startAuthAsync: startAuthMutation.mutateAsync, + isStartingAuth: startAuthMutation.isPending, + + completeAuth: completeAuthMutation.mutate, + completeAuthAsync: completeAuthMutation.mutateAsync, + isCompletingAuth: completeAuthMutation.isPending, + + authenticate: authenticateMutation.mutate, + authenticateAsync: authenticateMutation.mutateAsync, + isAuthenticating: authenticateMutation.isPending, + + removeAuth: removeAuthMutation.mutate, + removeAuthAsync: removeAuthMutation.mutateAsync, + isRemovingAuth: removeAuthMutation.isPending, + } +} diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 92a09220..7b2c5e63 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -24,11 +24,37 @@ fi echo "🔍 Checking OpenCode installation..." +MIN_OPENCODE_VERSION="1.0.137" + +version_gte() { + printf '%s\n%s\n' "$2" "$1" | sort -V -C +} + if ! command -v opencode >/dev/null 2>&1; then - echo "⚠️ OpenCode not found in PATH" -else - OPENCODE_VERSION=$(opencode --version 2>&1 || echo "unknown") - echo "✅ OpenCode is installed (version: $OPENCODE_VERSION)" + echo "⚠️ OpenCode not found. Installing..." + curl -fsSL https://opencode.ai/install | bash + + if ! command -v opencode >/dev/null 2>&1; then + echo "❌ Failed to install OpenCode. Exiting." + exit 1 + fi + echo "✅ OpenCode installed successfully" +fi + +OPENCODE_VERSION=$(opencode --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") +echo "✅ OpenCode is installed (version: $OPENCODE_VERSION)" + +if [ "$OPENCODE_VERSION" != "unknown" ]; then + if version_gte "$OPENCODE_VERSION" "$MIN_OPENCODE_VERSION"; then + echo "✅ OpenCode version meets minimum requirement (>=$MIN_OPENCODE_VERSION)" + else + echo "⚠️ OpenCode version $OPENCODE_VERSION is below minimum required version $MIN_OPENCODE_VERSION" + echo "🔄 Upgrading OpenCode..." + opencode upgrade || curl -fsSL https://opencode.ai/install | bash + + OPENCODE_VERSION=$(opencode --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") + echo "✅ OpenCode upgraded to version: $OPENCODE_VERSION" + fi fi echo "🚀 Starting OpenCode WebUI Backend..." From 42f604c54fc02265aa87e3f45993c34245f78060 Mon Sep 17 00:00:00 2001 From: Chris Scott Date: Tue, 9 Dec 2025 18:39:39 -0500 Subject: [PATCH 3/3] Replace stop button text with square icon --- frontend/src/components/message/PromptInput.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index cd14cdb6..c677666c 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -9,7 +9,7 @@ import { useModelSelection } from '@/hooks/useModelSelection' import { useUserBash } from '@/stores/userBashStore' import { useMobile } from '@/hooks/useMobile' import { useSessionStatusForSession } from '@/stores/sessionStatusStore' -import { ChevronDown } from 'lucide-react' +import { ChevronDown, Square } from 'lucide-react' import { CommandSuggestions } from '@/components/command/CommandSuggestions' import { MentionSuggestions, type MentionItem } from './MentionSuggestions' @@ -488,7 +488,7 @@ export function PromptInput({ return (
{hasActiveStream && ( -
+
)} @@ -573,10 +573,10 @@ export function PromptInput({ )}