Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions backend/src/services/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,36 +274,24 @@ function getMimeType(filePath: string): AllowedMimeType {
return mimeTypes[ext] || 'text/plain'
}

async function countFileLines(filePath: string): Promise<number> {
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<string[]> {
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)
})
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/message/EditableUserMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/model/ModelSelectDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</div>
</div>
Expand Down
156 changes: 137 additions & 19 deletions frontend/src/components/repo/RepoMcpDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>
timeout?: number
}

interface RepoMcpDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
Expand All @@ -28,6 +24,8 @@ interface RepoMcpDialogProps {
export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcpDialogProps) {
const [localStatus, setLocalStatus] = useState<Record<string, McpStatus>>({})
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
const [removeAuthConfirmServer, setRemoveAuthConfirmServer] = useState<string | null>(null)
const [authDialogServerId, setAuthDialogServerId] = useState<string | null>(null)

const mcpServers = config?.content?.mcp as Record<string, McpServerConfig> | undefined || {}
const serverIds = Object.keys(mcpServers)
Expand Down Expand Up @@ -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<McpAuthStartResponse> => {
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) {
Expand Down Expand Up @@ -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 (
<div
Expand All @@ -168,6 +207,11 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
<p className="text-sm font-medium truncate">
{getDisplayName(serverId)}
</p>
{connectedWithOAuth && (
<span title="OAuth authenticated">
<Shield className="h-3 w-3 text-muted-foreground" />
</span>
)}
{getStatusBadge(status)}
</div>
<p className="text-xs text-muted-foreground truncate">
Expand All @@ -181,20 +225,94 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
)}
</div>

<Switch
checked={isConnected}
disabled={toggleMutation.isPending}
onCheckedChange={(enabled) => {
toggleMutation.mutate({ serverId, enable: enabled })
}}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex items-center gap-2">
{showAuthButton ? (
<Button
onClick={() => setAuthDialogServerId(serverId)}
disabled={toggleMutation.isPending}
variant="default"
size="sm"
>
<Key className="h-3 w-3 mr-1" />
Auth
</Button>
) : (
<Switch
checked={isConnected}
disabled={toggleMutation.isPending || removeAuthMutation.isPending}
onCheckedChange={(enabled) => {
toggleMutation.mutate({ serverId, enable: enabled })
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{(isOAuthServer || needsAuth) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{showAuthButton && (
<DropdownMenuItem onClick={() => setAuthDialogServerId(serverId)}>
<Key className="h-4 w-4 mr-2" />
Authenticate
</DropdownMenuItem>
)}
{connectedWithOAuth && (
<DropdownMenuItem onClick={() => setAuthDialogServerId(serverId)}>
<RefreshCw className="h-4 w-4 mr-2" />
Re-authenticate
</DropdownMenuItem>
)}
{connectedWithOAuth && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveAuthConfirmServer(serverId)}
disabled={removeAuthMutation.isPending}
>
<Shield className="h-4 w-4 mr-2" />
{removeAuthMutation.isPending ? 'Removing...' : 'Remove Auth'}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
)
})}
</div>
)}
</div>

<DeleteDialog
open={!!removeAuthConfirmServer}
onOpenChange={() => 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}
/>

<McpOAuthDialog
open={!!authDialogServerId}
onOpenChange={(o) => !o && setAuthDialogServerId(null)}
serverName={authDialogServerId || ''}
onAutoAuth={handleOAuthAutoAuth}
onStartAuth={handleOAuthStartAuth}
onCompleteAuth={handleOAuthCompleteAuth}
directory={directory}
/>
</DialogContent>
</Dialog>
)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/session/QuestionPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/settings/AccountSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,11 @@ export function AccountSettings() {
<div className="space-y-3 sm:space-y-4">
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm">Name</Label>
<Input value={user.name} disabled className="h-9 sm:h-10 text-sm" />
<Input value={user.name} disabled className="h-9 sm:h-10 md:text-sm" />
</div>
<div className="space-y-1.5">
<Label className="text-xs sm:text-sm">Email</Label>
<Input value={user.email} disabled className="h-9 sm:h-10 text-sm" />
<Input value={user.email} disabled className="h-9 sm:h-10 md:text-sm" />
</div>
<Button variant="outline" onClick={() => setEditingProfile(false)} className="h-9 sm:h-10">
Done
Expand Down Expand Up @@ -216,7 +216,7 @@ export function AccountSettings() {
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
className="h-9 sm:h-10 text-sm"
className="h-9 sm:h-10 md:text-sm"
/>
</div>
<div className="space-y-1.5">
Expand All @@ -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"
/>
</div>
<div className="flex gap-2">
Expand Down Expand Up @@ -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"
/>
<Button
onClick={handleAddPasskey}
Expand Down
Loading