diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index a3452cc7..7da56d68 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -274,36 +274,24 @@ function getMimeType(filePath: string): AllowedMimeType { return mimeTypes[ext] || 'text/plain' } -async function countFileLines(filePath: string): Promise { - return new Promise((resolve, reject) => { - let lineCount = 0 - const stream = createReadStream(filePath, { encoding: 'utf8' }) - const rl = createInterface({ input: stream, crlfDelay: Infinity }) - - rl.on('line', () => { lineCount++ }) - rl.on('close', () => resolve(lineCount)) - rl.on('error', reject) - }) -} - -async function readFileLines(filePath: string, startLine: number, endLine: number): Promise { +async function readFileLinesAndCount( + filePath: string, + startLine: number, + endLine: number +): Promise<{ lines: string[]; totalLines: number }> { return new Promise((resolve, reject) => { const lines: string[] = [] let currentLine = 0 const stream = createReadStream(filePath, { encoding: 'utf8' }) const rl = createInterface({ input: stream, crlfDelay: Infinity }) - + rl.on('line', (line) => { if (currentLine >= startLine && currentLine < endLine) { lines.push(line) } currentLine++ - if (currentLine >= endLine) { - rl.close() - stream.destroy() - } }) - rl.on('close', () => resolve(lines)) + rl.on('close', () => resolve({ lines, totalLines: currentLine })) rl.on('error', reject) }) } @@ -322,9 +310,8 @@ export async function getFileRange(userPath: string, startLine: number, endLine: throw { message: 'Path is a directory', statusCode: 400 } } - const totalLines = await countFileLines(validatedPath) + const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine) const clampedEnd = Math.min(endLine, totalLines) - const lines = await readFileLines(validatedPath, startLine, clampedEnd) const mimeType = getMimeType(validatedPath) return { diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index bf4b079f..a7bd732f 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -9,7 +9,7 @@ import { MarkdownRenderer } from './MarkdownRenderer' const API_BASE = API_BASE_URL -const VIRTUALIZATION_THRESHOLD_BYTES = 8_000 +const VIRTUALIZATION_THRESHOLD_BYTES = 50_000 const MARKDOWN_PREVIEW_SIZE_LIMIT = 1_000_000 interface FilePreviewProps { diff --git a/frontend/src/components/message/EditableUserMessage.tsx b/frontend/src/components/message/EditableUserMessage.tsx index 43de5ec3..cc3aa8ce 100644 --- a/frontend/src/components/message/EditableUserMessage.tsx +++ b/frontend/src/components/message/EditableUserMessage.tsx @@ -89,7 +89,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({ onKeyDown={handleKeyDown} onFocus={() => setIsEditingMessage(true)} onBlur={() => setIsEditingMessage(false)} - className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-sm" + className="w-full p-3 rounded-lg bg-background border border-primary/50 focus:border-primary focus:ring-1 focus:ring-primary outline-none resize-none min-h-[60px] text-[16px] md:text-sm" placeholder="Edit your message..." disabled={refreshMessage.isPending} /> diff --git a/frontend/src/components/model/ModelSelectDialog.tsx b/frontend/src/components/model/ModelSelectDialog.tsx index 6f0aa103..decc1797 100644 --- a/frontend/src/components/model/ModelSelectDialog.tsx +++ b/frontend/src/components/model/ModelSelectDialog.tsx @@ -57,7 +57,7 @@ function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) { placeholder="Search models..." value={value} onChange={(e) => setValue(e.target.value)} - className="pl-10 text-sm" + className="pl-10 md:text-sm" /> diff --git a/frontend/src/components/repo/RepoMcpDialog.tsx b/frontend/src/components/repo/RepoMcpDialog.tsx index 5aacf365..a5b0d50a 100644 --- a/frontend/src/components/repo/RepoMcpDialog.tsx +++ b/frontend/src/components/repo/RepoMcpDialog.tsx @@ -1,21 +1,17 @@ import { useState, useEffect, useCallback } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Switch } from '@/components/ui/switch' +import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Loader2, XCircle, AlertCircle, Plug } from 'lucide-react' -import { mcpApi, type McpStatus } from '@/api/mcp' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { DeleteDialog } from '@/components/ui/delete-dialog' +import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu' +import { Loader2, XCircle, AlertCircle, Plug, Shield, MoreVertical, Key, RefreshCw } from 'lucide-react' +import { McpOAuthDialog } from '@/components/settings/McpOAuthDialog' +import { mcpApi, type McpStatus, type McpServerConfig, type McpAuthStartResponse } from '@/api/mcp' import { useMutation } from '@tanstack/react-query' import { showToast } from '@/lib/toast' -interface McpServerConfig { - type: 'local' | 'remote' - enabled?: boolean - command?: string[] - url?: string - environment?: Record - timeout?: number -} - interface RepoMcpDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -28,6 +24,8 @@ interface RepoMcpDialogProps { export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcpDialogProps) { const [localStatus, setLocalStatus] = useState>({}) const [isLoadingStatus, setIsLoadingStatus] = useState(false) + const [removeAuthConfirmServer, setRemoveAuthConfirmServer] = useState(null) + const [authDialogServerId, setAuthDialogServerId] = useState(null) const mcpServers = config?.content?.mcp as Record | undefined || {} const serverIds = Object.keys(mcpServers) @@ -68,6 +66,40 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp showToast.error(error instanceof Error ? error.message : 'Failed to update MCP server') }, }) + + const removeAuthMutation = useMutation({ + mutationFn: async (serverId: string) => { + if (!directory) throw new Error('No directory provided') + await mcpApi.removeAuthDirectory(serverId, directory) + }, + onSuccess: async () => { + showToast.success('Authentication removed for this location') + setRemoveAuthConfirmServer(null) + await fetchStatus() + }, + onError: (error) => { + showToast.error(error instanceof Error ? error.message : 'Failed to remove authentication') + }, + }) + + const handleOAuthAutoAuth = async () => { + if (!authDialogServerId || !directory) return + await mcpApi.authenticateDirectory(authDialogServerId, directory) + await fetchStatus() + setAuthDialogServerId(null) + } + + const handleOAuthStartAuth = async (): Promise => { + if (!authDialogServerId) throw new Error('No server ID') + return await mcpApi.startAuth(authDialogServerId) + } + + const handleOAuthCompleteAuth = async (code: string) => { + if (!authDialogServerId) return + await mcpApi.completeAuth(authDialogServerId, code) + await fetchStatus() + setAuthDialogServerId(null) + } useEffect(() => { if (open && directory) { @@ -156,7 +188,14 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp const serverConfig = mcpServers[serverId] const status = localStatus[serverId] const isConnected = status?.status === 'connected' + const needsAuth = status?.status === 'needs_auth' const failed = status?.status === 'failed' + const isRemote = serverConfig.type === 'remote' + const hasOAuthConfig = isRemote && !!serverConfig.oauth + const hasOAuthError = failed && isRemote && /oauth|auth.*state/i.test(status.error) + const isOAuthServer = hasOAuthConfig || hasOAuthError || (needsAuth && isRemote) + const connectedWithOAuth = isOAuthServer && isConnected + const showAuthButton = needsAuth || (isOAuthServer && failed) return (
{getDisplayName(serverId)}

+ {connectedWithOAuth && ( + + + + )} {getStatusBadge(status)}

@@ -181,20 +225,94 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp )} - { - toggleMutation.mutate({ serverId, enable: enabled }) - }} - onClick={(e) => e.stopPropagation()} - /> +

+ {showAuthButton ? ( + + ) : ( + { + toggleMutation.mutate({ serverId, enable: enabled }) + }} + onClick={(e) => e.stopPropagation()} + /> + )} + {(isOAuthServer || needsAuth) && ( + + + + + + {showAuthButton && ( + setAuthDialogServerId(serverId)}> + + Authenticate + + )} + {connectedWithOAuth && ( + setAuthDialogServerId(serverId)}> + + Re-authenticate + + )} + {connectedWithOAuth && ( + <> + + setRemoveAuthConfirmServer(serverId)} + disabled={removeAuthMutation.isPending} + > + + {removeAuthMutation.isPending ? 'Removing...' : 'Remove Auth'} + + + )} + + + )} +
) })} )} + + setRemoveAuthConfirmServer(null)} + onConfirm={() => { + if (removeAuthConfirmServer) { + removeAuthMutation.mutate(removeAuthConfirmServer) + } + }} + onCancel={() => setRemoveAuthConfirmServer(null)} + title="Remove Authentication" + description="This will remove the OAuth credentials for this MCP server at this location. You will need to re-authenticate to use this server here again." + itemName={removeAuthConfirmServer ? getDisplayName(removeAuthConfirmServer) : ''} + isDeleting={removeAuthMutation.isPending} + /> + + !o && setAuthDialogServerId(null)} + serverName={authDialogServerId || ''} + onAutoAuth={handleOAuthAutoAuth} + onStartAuth={handleOAuthStartAuth} + onCompleteAuth={handleOAuthCompleteAuth} + directory={directory} + /> ) diff --git a/frontend/src/components/session/QuestionPrompt.tsx b/frontend/src/components/session/QuestionPrompt.tsx index da675d07..b531fdd7 100644 --- a/frontend/src/components/session/QuestionPrompt.tsx +++ b/frontend/src/components/session/QuestionPrompt.tsx @@ -435,7 +435,7 @@ function QuestionStep({ value={customInput} onChange={(e) => onCustomInputChange(e.target.value)} placeholder="Type your own answer..." - className="min-h-[60px] sm:min-h-[80px] text-xs sm:text-sm resize-none border-blue-500/30 focus:border-blue-500" + className="min-h-[60px] sm:min-h-[80px] text-[16px] sm:text-xs md:text-sm resize-none border-blue-500/30 focus:border-blue-500" onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() diff --git a/frontend/src/components/settings/AccountSettings.tsx b/frontend/src/components/settings/AccountSettings.tsx index d355044f..932bf191 100644 --- a/frontend/src/components/settings/AccountSettings.tsx +++ b/frontend/src/components/settings/AccountSettings.tsx @@ -163,11 +163,11 @@ export function AccountSettings() {
- +
- +
@@ -227,7 +227,7 @@ export function AccountSettings() { value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="At least 8 characters" - className="h-9 sm:h-10 text-sm" + className="h-9 sm:h-10 md:text-sm" />
@@ -271,7 +271,7 @@ export function AccountSettings() { placeholder="Passkey name (optional)" value={passkeyName} onChange={(e) => setPasskeyName(e.target.value)} - className="h-9 sm:h-10 text-sm" + className="h-9 sm:h-10 md:text-sm" /> + {serverType === 'local' ? ( +
+ + setCommand(e.target.value)} + placeholder="npx @modelcontextprotocol/server-filesystem /tmp" + className="bg-background border-border font-mono" + /> +

+ Command and arguments to run the MCP server +

- {environment.map((env, index) => ( -
- handleUpdateEnvironmentVar(index, 'key', e.target.value)} - placeholder="API_KEY" - className="bg-background border-border font-mono" - /> - handleUpdateEnvironmentVar(index, 'value', e.target.value)} - placeholder="your-api-key-here" - className="bg-background border-border font-mono" + ) : ( +
+ + setUrl(e.target.value)} + placeholder="http://localhost:3000/mcp" + className="bg-background border-border font-mono" + /> +

+ URL of the remote MCP server +

+
+ )} + + {serverType === 'remote' && ( +
+
+ - {environment.length > 1 && ( - - )} +
- ))} + {oauthEnabled && ( +
+

+ Leave fields blank to use the server's default OAuth discovery +

+
+ + setOauthClientId(e.target.value)} + placeholder="Optional" + className="bg-background border-border font-mono" + /> +
+
+ + setOauthClientSecret(e.target.value)} + placeholder="Optional" + className="bg-background border-border font-mono" + /> +
+
+ + setOauthScope(e.target.value)} + placeholder="e.g., read write" + className="bg-background border-border font-mono" + /> +
+
+ )} +
+ )} + + {serverType === 'local' && ( +
+
+ + +
+ {environment.map((env, index) => ( +
+ handleUpdateEnvironmentVar(index, 'key', e.target.value)} + placeholder="API_KEY" + className="bg-background border-border font-mono" + /> + handleUpdateEnvironmentVar(index, 'value', e.target.value)} + placeholder="your-api-key-here" + className="bg-background border-border font-mono" + /> + {environment.length > 1 && ( + + )} +
+ ))} +

+ Environment variables to set when running the MCP server +

+
+ )} + +
+ + setTimeout(e.target.value)} + placeholder="5000" + className="bg-background border-border" + />

- Environment variables to set when running the MCP server + Timeout in milliseconds for fetching tools (default: 5000)

- )} -
- - setTimeout(e.target.value)} - placeholder="5000" - className="bg-background border-border" - /> -

- Timeout in milliseconds for fetching tools (default: 5000) -

-
- -
- - +
+ + +
- -