Skip to content

Commit f570758

Browse files
refactor: improve virtualized text viewer with React Query infinite scroll and simplified buffering (#108)
* Add OAuth authentication support for MCP servers * Improve mobile text sizing with responsive text classes * refactor: improve virtualized text viewer with React Query infinite scroll and simplified buffering
1 parent c453235 commit f570758

24 files changed

Lines changed: 928 additions & 469 deletions

backend/src/services/files.ts

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -274,36 +274,24 @@ function getMimeType(filePath: string): AllowedMimeType {
274274
return mimeTypes[ext] || 'text/plain'
275275
}
276276

277-
async function countFileLines(filePath: string): Promise<number> {
278-
return new Promise((resolve, reject) => {
279-
let lineCount = 0
280-
const stream = createReadStream(filePath, { encoding: 'utf8' })
281-
const rl = createInterface({ input: stream, crlfDelay: Infinity })
282-
283-
rl.on('line', () => { lineCount++ })
284-
rl.on('close', () => resolve(lineCount))
285-
rl.on('error', reject)
286-
})
287-
}
288-
289-
async function readFileLines(filePath: string, startLine: number, endLine: number): Promise<string[]> {
277+
async function readFileLinesAndCount(
278+
filePath: string,
279+
startLine: number,
280+
endLine: number
281+
): Promise<{ lines: string[]; totalLines: number }> {
290282
return new Promise((resolve, reject) => {
291283
const lines: string[] = []
292284
let currentLine = 0
293285
const stream = createReadStream(filePath, { encoding: 'utf8' })
294286
const rl = createInterface({ input: stream, crlfDelay: Infinity })
295-
287+
296288
rl.on('line', (line) => {
297289
if (currentLine >= startLine && currentLine < endLine) {
298290
lines.push(line)
299291
}
300292
currentLine++
301-
if (currentLine >= endLine) {
302-
rl.close()
303-
stream.destroy()
304-
}
305293
})
306-
rl.on('close', () => resolve(lines))
294+
rl.on('close', () => resolve({ lines, totalLines: currentLine }))
307295
rl.on('error', reject)
308296
})
309297
}
@@ -322,9 +310,8 @@ export async function getFileRange(userPath: string, startLine: number, endLine:
322310
throw { message: 'Path is a directory', statusCode: 400 }
323311
}
324312

325-
const totalLines = await countFileLines(validatedPath)
313+
const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine)
326314
const clampedEnd = Math.min(endLine, totalLines)
327-
const lines = await readFileLines(validatedPath, startLine, clampedEnd)
328315
const mimeType = getMimeType(validatedPath)
329316

330317
return {

frontend/src/components/file-browser/FilePreview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { MarkdownRenderer } from './MarkdownRenderer'
99

1010
const API_BASE = API_BASE_URL
1111

12-
const VIRTUALIZATION_THRESHOLD_BYTES = 8_000
12+
const VIRTUALIZATION_THRESHOLD_BYTES = 50_000
1313
const MARKDOWN_PREVIEW_SIZE_LIMIT = 1_000_000
1414

1515
interface FilePreviewProps {

frontend/src/components/message/EditableUserMessage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const EditableUserMessage = memo(function EditableUserMessage({
8989
onKeyDown={handleKeyDown}
9090
onFocus={() => setIsEditingMessage(true)}
9191
onBlur={() => setIsEditingMessage(false)}
92-
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"
92+
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"
9393
placeholder="Edit your message..."
9494
disabled={refreshMessage.isPending}
9595
/>

frontend/src/components/model/ModelSelectDialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ function SearchInput({ onSearch, initialValue = "" }: SearchInputProps) {
5757
placeholder="Search models..."
5858
value={value}
5959
onChange={(e) => setValue(e.target.value)}
60-
className="pl-10 text-sm"
60+
className="pl-10 md:text-sm"
6161
/>
6262
</div>
6363
</div>

frontend/src/components/repo/RepoMcpDialog.tsx

Lines changed: 137 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
11
import { useState, useEffect, useCallback } from 'react'
22
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
33
import { Switch } from '@/components/ui/switch'
4+
import { Button } from '@/components/ui/button'
45
import { Badge } from '@/components/ui/badge'
5-
import { Loader2, XCircle, AlertCircle, Plug } from 'lucide-react'
6-
import { mcpApi, type McpStatus } from '@/api/mcp'
6+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
7+
import { DeleteDialog } from '@/components/ui/delete-dialog'
8+
import { DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
9+
import { Loader2, XCircle, AlertCircle, Plug, Shield, MoreVertical, Key, RefreshCw } from 'lucide-react'
10+
import { McpOAuthDialog } from '@/components/settings/McpOAuthDialog'
11+
import { mcpApi, type McpStatus, type McpServerConfig, type McpAuthStartResponse } from '@/api/mcp'
712
import { useMutation } from '@tanstack/react-query'
813
import { showToast } from '@/lib/toast'
914

10-
interface McpServerConfig {
11-
type: 'local' | 'remote'
12-
enabled?: boolean
13-
command?: string[]
14-
url?: string
15-
environment?: Record<string, string>
16-
timeout?: number
17-
}
18-
1915
interface RepoMcpDialogProps {
2016
open: boolean
2117
onOpenChange: (open: boolean) => void
@@ -28,6 +24,8 @@ interface RepoMcpDialogProps {
2824
export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcpDialogProps) {
2925
const [localStatus, setLocalStatus] = useState<Record<string, McpStatus>>({})
3026
const [isLoadingStatus, setIsLoadingStatus] = useState(false)
27+
const [removeAuthConfirmServer, setRemoveAuthConfirmServer] = useState<string | null>(null)
28+
const [authDialogServerId, setAuthDialogServerId] = useState<string | null>(null)
3129

3230
const mcpServers = config?.content?.mcp as Record<string, McpServerConfig> | undefined || {}
3331
const serverIds = Object.keys(mcpServers)
@@ -68,6 +66,40 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
6866
showToast.error(error instanceof Error ? error.message : 'Failed to update MCP server')
6967
},
7068
})
69+
70+
const removeAuthMutation = useMutation({
71+
mutationFn: async (serverId: string) => {
72+
if (!directory) throw new Error('No directory provided')
73+
await mcpApi.removeAuthDirectory(serverId, directory)
74+
},
75+
onSuccess: async () => {
76+
showToast.success('Authentication removed for this location')
77+
setRemoveAuthConfirmServer(null)
78+
await fetchStatus()
79+
},
80+
onError: (error) => {
81+
showToast.error(error instanceof Error ? error.message : 'Failed to remove authentication')
82+
},
83+
})
84+
85+
const handleOAuthAutoAuth = async () => {
86+
if (!authDialogServerId || !directory) return
87+
await mcpApi.authenticateDirectory(authDialogServerId, directory)
88+
await fetchStatus()
89+
setAuthDialogServerId(null)
90+
}
91+
92+
const handleOAuthStartAuth = async (): Promise<McpAuthStartResponse> => {
93+
if (!authDialogServerId) throw new Error('No server ID')
94+
return await mcpApi.startAuth(authDialogServerId)
95+
}
96+
97+
const handleOAuthCompleteAuth = async (code: string) => {
98+
if (!authDialogServerId) return
99+
await mcpApi.completeAuth(authDialogServerId, code)
100+
await fetchStatus()
101+
setAuthDialogServerId(null)
102+
}
71103

72104
useEffect(() => {
73105
if (open && directory) {
@@ -156,7 +188,14 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
156188
const serverConfig = mcpServers[serverId]
157189
const status = localStatus[serverId]
158190
const isConnected = status?.status === 'connected'
191+
const needsAuth = status?.status === 'needs_auth'
159192
const failed = status?.status === 'failed'
193+
const isRemote = serverConfig.type === 'remote'
194+
const hasOAuthConfig = isRemote && !!serverConfig.oauth
195+
const hasOAuthError = failed && isRemote && /oauth|auth.*state/i.test(status.error)
196+
const isOAuthServer = hasOAuthConfig || hasOAuthError || (needsAuth && isRemote)
197+
const connectedWithOAuth = isOAuthServer && isConnected
198+
const showAuthButton = needsAuth || (isOAuthServer && failed)
160199

161200
return (
162201
<div
@@ -168,6 +207,11 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
168207
<p className="text-sm font-medium truncate">
169208
{getDisplayName(serverId)}
170209
</p>
210+
{connectedWithOAuth && (
211+
<span title="OAuth authenticated">
212+
<Shield className="h-3 w-3 text-muted-foreground" />
213+
</span>
214+
)}
171215
{getStatusBadge(status)}
172216
</div>
173217
<p className="text-xs text-muted-foreground truncate">
@@ -181,20 +225,94 @@ export function RepoMcpDialog({ open, onOpenChange, config, directory }: RepoMcp
181225
)}
182226
</div>
183227

184-
<Switch
185-
checked={isConnected}
186-
disabled={toggleMutation.isPending}
187-
onCheckedChange={(enabled) => {
188-
toggleMutation.mutate({ serverId, enable: enabled })
189-
}}
190-
onClick={(e) => e.stopPropagation()}
191-
/>
228+
<div className="flex items-center gap-2">
229+
{showAuthButton ? (
230+
<Button
231+
onClick={() => setAuthDialogServerId(serverId)}
232+
disabled={toggleMutation.isPending}
233+
variant="default"
234+
size="sm"
235+
>
236+
<Key className="h-3 w-3 mr-1" />
237+
Auth
238+
</Button>
239+
) : (
240+
<Switch
241+
checked={isConnected}
242+
disabled={toggleMutation.isPending || removeAuthMutation.isPending}
243+
onCheckedChange={(enabled) => {
244+
toggleMutation.mutate({ serverId, enable: enabled })
245+
}}
246+
onClick={(e) => e.stopPropagation()}
247+
/>
248+
)}
249+
{(isOAuthServer || needsAuth) && (
250+
<DropdownMenu>
251+
<DropdownMenuTrigger asChild>
252+
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
253+
<MoreVertical className="h-4 w-4" />
254+
</Button>
255+
</DropdownMenuTrigger>
256+
<DropdownMenuContent align="end">
257+
{showAuthButton && (
258+
<DropdownMenuItem onClick={() => setAuthDialogServerId(serverId)}>
259+
<Key className="h-4 w-4 mr-2" />
260+
Authenticate
261+
</DropdownMenuItem>
262+
)}
263+
{connectedWithOAuth && (
264+
<DropdownMenuItem onClick={() => setAuthDialogServerId(serverId)}>
265+
<RefreshCw className="h-4 w-4 mr-2" />
266+
Re-authenticate
267+
</DropdownMenuItem>
268+
)}
269+
{connectedWithOAuth && (
270+
<>
271+
<DropdownMenuSeparator />
272+
<DropdownMenuItem
273+
onClick={() => setRemoveAuthConfirmServer(serverId)}
274+
disabled={removeAuthMutation.isPending}
275+
>
276+
<Shield className="h-4 w-4 mr-2" />
277+
{removeAuthMutation.isPending ? 'Removing...' : 'Remove Auth'}
278+
</DropdownMenuItem>
279+
</>
280+
)}
281+
</DropdownMenuContent>
282+
</DropdownMenu>
283+
)}
284+
</div>
192285
</div>
193286
)
194287
})}
195288
</div>
196289
)}
197290
</div>
291+
292+
<DeleteDialog
293+
open={!!removeAuthConfirmServer}
294+
onOpenChange={() => setRemoveAuthConfirmServer(null)}
295+
onConfirm={() => {
296+
if (removeAuthConfirmServer) {
297+
removeAuthMutation.mutate(removeAuthConfirmServer)
298+
}
299+
}}
300+
onCancel={() => setRemoveAuthConfirmServer(null)}
301+
title="Remove Authentication"
302+
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."
303+
itemName={removeAuthConfirmServer ? getDisplayName(removeAuthConfirmServer) : ''}
304+
isDeleting={removeAuthMutation.isPending}
305+
/>
306+
307+
<McpOAuthDialog
308+
open={!!authDialogServerId}
309+
onOpenChange={(o) => !o && setAuthDialogServerId(null)}
310+
serverName={authDialogServerId || ''}
311+
onAutoAuth={handleOAuthAutoAuth}
312+
onStartAuth={handleOAuthStartAuth}
313+
onCompleteAuth={handleOAuthCompleteAuth}
314+
directory={directory}
315+
/>
198316
</DialogContent>
199317
</Dialog>
200318
)

frontend/src/components/session/QuestionPrompt.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ function QuestionStep({
435435
value={customInput}
436436
onChange={(e) => onCustomInputChange(e.target.value)}
437437
placeholder="Type your own answer..."
438-
className="min-h-[60px] sm:min-h-[80px] text-xs sm:text-sm resize-none border-blue-500/30 focus:border-blue-500"
438+
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"
439439
onKeyDown={(e) => {
440440
if (e.key === 'Enter' && !e.shiftKey) {
441441
e.preventDefault()

frontend/src/components/settings/AccountSettings.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,11 @@ export function AccountSettings() {
163163
<div className="space-y-3 sm:space-y-4">
164164
<div className="space-y-1.5">
165165
<Label className="text-xs sm:text-sm">Name</Label>
166-
<Input value={user.name} disabled className="h-9 sm:h-10 text-sm" />
166+
<Input value={user.name} disabled className="h-9 sm:h-10 md:text-sm" />
167167
</div>
168168
<div className="space-y-1.5">
169169
<Label className="text-xs sm:text-sm">Email</Label>
170-
<Input value={user.email} disabled className="h-9 sm:h-10 text-sm" />
170+
<Input value={user.email} disabled className="h-9 sm:h-10 md:text-sm" />
171171
</div>
172172
<Button variant="outline" onClick={() => setEditingProfile(false)} className="h-9 sm:h-10">
173173
Done
@@ -216,7 +216,7 @@ export function AccountSettings() {
216216
value={currentPassword}
217217
onChange={(e) => setCurrentPassword(e.target.value)}
218218
placeholder="Enter current password"
219-
className="h-9 sm:h-10 text-sm"
219+
className="h-9 sm:h-10 md:text-sm"
220220
/>
221221
</div>
222222
<div className="space-y-1.5">
@@ -227,7 +227,7 @@ export function AccountSettings() {
227227
value={newPassword}
228228
onChange={(e) => setNewPassword(e.target.value)}
229229
placeholder="At least 8 characters"
230-
className="h-9 sm:h-10 text-sm"
230+
className="h-9 sm:h-10 md:text-sm"
231231
/>
232232
</div>
233233
<div className="flex gap-2">
@@ -271,7 +271,7 @@ export function AccountSettings() {
271271
placeholder="Passkey name (optional)"
272272
value={passkeyName}
273273
onChange={(e) => setPasskeyName(e.target.value)}
274-
className="h-9 sm:h-10 text-sm"
274+
className="h-9 sm:h-10 md:text-sm"
275275
/>
276276
<Button
277277
onClick={handleAddPasskey}

0 commit comments

Comments
 (0)