provider-sheet.tsx
1 'use client' 2 3 import { useEffect, useMemo, useState } from 'react' 4 import { useAppStore } from '@/stores/use-app-store' 5 import { BottomSheet } from '@/components/shared/bottom-sheet' 6 import { ConfirmDialog } from '@/components/shared/confirm-dialog' 7 import { toast } from 'sonner' 8 import { errorMessage } from '@/lib/shared-utils' 9 import { 10 useCheckProviderConnectionMutation, 11 useDeleteProviderMutation, 12 useProviderConfigsQuery, 13 useProviderModelDiscoveryMutation, 14 useProvidersQuery, 15 useResetProviderModelsMutation, 16 useSaveBuiltinProviderMutation, 17 useSaveCustomProviderMutation, 18 } from '@/features/providers/queries' 19 import { useCreateCredentialMutation, useCredentialsQuery } from '@/features/credentials/queries' 20 21 export function ProviderSheet() { 22 const open = useAppStore((s) => s.providerSheetOpen) 23 const setOpen = useAppStore((s) => s.setProviderSheetOpen) 24 const editingId = useAppStore((s) => s.editingProviderId) 25 const setEditingId = useAppStore((s) => s.setEditingProviderId) 26 const providerConfigsQuery = useProviderConfigsQuery({ enabled: open }) 27 const providersQuery = useProvidersQuery({ enabled: open }) 28 const credentialsQuery = useCredentialsQuery({ enabled: open }) 29 const saveBuiltinProviderMutation = useSaveBuiltinProviderMutation() 30 const saveCustomProviderMutation = useSaveCustomProviderMutation() 31 const deleteProviderMutation = useDeleteProviderMutation() 32 const resetProviderModelsMutation = useResetProviderModelsMutation() 33 const checkProviderConnectionMutation = useCheckProviderConnectionMutation() 34 const providerModelDiscoveryMutation = useProviderModelDiscoveryMutation() 35 const createCredentialMutation = useCreateCredentialMutation() 36 37 const providerConfigs = providerConfigsQuery.data ?? [] 38 const providers = providersQuery.data ?? [] 39 const credentials = useMemo(() => credentialsQuery.data ?? {}, [credentialsQuery.data]) 40 41 const [name, setName] = useState('') 42 const [baseUrl, setBaseUrl] = useState('') 43 const [models, setModels] = useState('') 44 const [requiresApiKey, setRequiresApiKey] = useState(true) 45 const [credentialId, setCredentialId] = useState<string | null>(null) 46 const [isEnabled, setIsEnabled] = useState(true) 47 const [addingKey, setAddingKey] = useState(false) 48 const [newKeyName, setNewKeyName] = useState('') 49 const [newKeyValue, setNewKeyValue] = useState('') 50 const [savingKey, setSavingKey] = useState(false) 51 const [newModel, setNewModel] = useState('') 52 53 // Test connection state 54 const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle') 55 const [testMessage, setTestMessage] = useState('') 56 const [testModel, setTestModel] = useState('') 57 58 const [liveModels, setLiveModels] = useState<string[]>([]) 59 const [liveLoading, setLiveLoading] = useState(false) 60 const [liveMessage, setLiveMessage] = useState('') 61 const [liveCached, setLiveCached] = useState(false) 62 const [confirmDelete, setConfirmDelete] = useState(false) 63 const [deleting, setDeleting] = useState(false) 64 65 // Find editing provider in custom configs OR built-in list 66 const editingCustom = editingId ? providerConfigs.find((c) => c.id === editingId && c.type === 'custom') : null 67 const editingBuiltinOverride = editingId ? providerConfigs.find((c) => c.id === editingId && c.type === 'builtin') : null 68 const editingBuiltin = editingId ? providers.find((p) => p.id === editingId) : null 69 const isBuiltin = !!editingBuiltin && !editingCustom 70 const editing = editingCustom || editingBuiltin 71 72 useEffect(() => { 73 if (open) { 74 setNewModel('') 75 setLiveModels([]) 76 setLiveMessage('') 77 setLiveCached(false) 78 setTestStatus('idle') 79 setTestMessage('') 80 if (editingCustom) { 81 setName(editingCustom.name) 82 setBaseUrl(editingCustom.baseUrl || '') 83 setModels(editingCustom.models.join(', ')) 84 setRequiresApiKey(editingCustom.requiresApiKey) 85 setCredentialId(editingCustom.credentialId || null) 86 setIsEnabled(editingCustom.isEnabled) 87 } else if (editingBuiltin) { 88 setName(editingBuiltin.name) 89 setBaseUrl(editingBuiltinOverride?.baseUrl || editingBuiltin.defaultEndpoint || '') 90 setModels(editingBuiltin.models.join(', ')) 91 setRequiresApiKey(editingBuiltin.requiresApiKey) 92 // Default to existing credential for this provider 93 const existingCred = Object.values(credentials).find((c) => c.provider === editingBuiltin.id) 94 setCredentialId(existingCred?.id || null) 95 setIsEnabled(editingBuiltinOverride?.isEnabled !== false) 96 } else { 97 setName('') 98 setBaseUrl('') 99 setModels('') 100 setRequiresApiKey(true) 101 setCredentialId(null) 102 setIsEnabled(true) 103 } 104 } 105 }, [open, editingId, credentials, editingBuiltin, editingBuiltinOverride, editingCustom]) 106 107 // Reset test status when connection params change 108 useEffect(() => { 109 setTestStatus('idle') 110 setTestMessage('') 111 }, [credentialId, baseUrl]) 112 113 useEffect(() => { 114 setLiveModels([]) 115 setLiveMessage('') 116 setLiveCached(false) 117 setTestModel('') 118 }, [editingId, credentialId, baseUrl, requiresApiKey]) 119 120 const handleTestConnection = async () => { 121 if (!isBuiltin) return 122 setTestStatus('testing') 123 setTestMessage('') 124 try { 125 const result = await checkProviderConnectionMutation.mutateAsync({ 126 provider: editingId || 'custom', 127 credentialId, 128 endpoint: baseUrl, 129 model: testModel || undefined, 130 }) 131 if (result.ok) { 132 setTestStatus('pass') 133 setTestMessage(result.message) 134 toast.success('Connection successful') 135 } else { 136 setTestStatus('fail') 137 setTestMessage(result.message) 138 toast.error(result.message || 'Connection failed') 139 } 140 } catch (err: unknown) { 141 const msg = err instanceof Error ? err.message : 'Connection test failed' 142 setTestStatus('fail') 143 setTestMessage(msg) 144 toast.error(msg) 145 } 146 } 147 148 const onClose = () => { 149 setConfirmDelete(false) 150 setDeleting(false) 151 setOpen(false) 152 setEditingId(null) 153 } 154 155 const handleSave = async () => { 156 try { 157 if (isBuiltin) { 158 const modelList = models.split(',').map((m) => m.trim()).filter(Boolean) 159 await saveBuiltinProviderMutation.mutateAsync({ 160 id: editingId || '', 161 models: modelList, 162 isEnabled, 163 baseUrl: baseUrl.trim() || undefined, 164 }) 165 toast.success('Built-in provider updated') 166 onClose() 167 return 168 } 169 const modelList = models.split(',').map((m) => m.trim()).filter(Boolean) 170 const data = { 171 name: name.trim() || 'Custom Provider', 172 baseUrl: baseUrl.trim(), 173 models: modelList, 174 requiresApiKey, 175 credentialId, 176 isEnabled, 177 } 178 await saveCustomProviderMutation.mutateAsync({ 179 id: editingCustom?.id, 180 data, 181 }) 182 toast.success(editingCustom ? 'Provider updated' : 'Provider created') 183 onClose() 184 } catch (err: unknown) { 185 toast.error(err instanceof Error ? err.message : 'Failed to save provider') 186 } 187 } 188 189 const handleDelete = async () => { 190 if (editingCustom) { 191 setDeleting(true) 192 try { 193 await deleteProviderMutation.mutateAsync(editingCustom.id) 194 toast.success('Provider deleted') 195 setConfirmDelete(false) 196 onClose() 197 } catch (err: unknown) { 198 toast.error(err instanceof Error ? err.message : 'Failed to delete provider') 199 } finally { 200 setDeleting(false) 201 } 202 } 203 } 204 205 const handleResetModels = async () => { 206 if (!isBuiltin || !editingId) return 207 await resetProviderModelsMutation.mutateAsync(editingId) 208 const refreshedProviders = (await providersQuery.refetch()).data ?? [] 209 const updated = refreshedProviders.find((provider) => provider.id === editingId) 210 if (updated) setModels(updated.models.join(', ')) 211 } 212 213 const handleCreateCredential = async () => { 214 const cred = await createCredentialMutation.mutateAsync({ 215 provider: editingId || name || 'custom', 216 name: newKeyName.trim() || `${name || editingId || 'Custom'} key`, 217 apiKey: newKeyValue.trim(), 218 }) 219 await credentialsQuery.refetch() 220 setCredentialId(cred.id) 221 setAddingKey(false) 222 setNewKeyName('') 223 setNewKeyValue('') 224 } 225 226 const handleLoadLiveModels = async (force = false) => { 227 if (!open || !isBuiltin) return 228 const providerId = editingId || 'custom' 229 setLiveLoading(true) 230 setLiveMessage('') 231 try { 232 const result = await providerModelDiscoveryMutation.mutateAsync({ 233 providerId, 234 credentialId, 235 endpoint: baseUrl, 236 force, 237 requiresApiKey: isBuiltin ? undefined : requiresApiKey, 238 }) 239 setLiveModels(result.models) 240 setLiveCached(result.cached) 241 setLiveMessage(result.message || '') 242 if (!result.ok) { 243 toast.message(result.message || 'Live model discovery is unavailable.') 244 return 245 } 246 setModels(result.models.join(', ')) 247 toast.success(`Loaded ${result.models.length} live model${result.models.length === 1 ? '' : 's'}`) 248 } catch (err: unknown) { 249 const message = err instanceof Error ? err.message : 'Failed to load live models' 250 setLiveMessage(message) 251 toast.error(message) 252 } finally { 253 setLiveLoading(false) 254 } 255 } 256 257 const handleAddModel = () => { 258 if (!newModel.trim()) return 259 const current = models ? models + ', ' + newModel.trim() : newModel.trim() 260 setModels(current) 261 setNewModel('') 262 } 263 264 const handleRemoveModel = (index: number) => { 265 const list = models.split(',').map((m) => m.trim()).filter(Boolean) 266 list.splice(index, 1) 267 setModels(list.join(', ')) 268 } 269 270 const credList = Object.values(credentials) 271 const modelList = models.split(',').map((m) => m.trim()).filter(Boolean) 272 const showApiKey = isBuiltin ? editingBuiltin?.requiresApiKey || editingBuiltin?.optionalApiKey : requiresApiKey 273 const canDiscoverModels = Boolean(isBuiltin && editingBuiltin?.supportsModelDiscovery) 274 const showTestButton = Boolean(isBuiltin && showApiKey && credentialId) 275 276 const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow" 277 278 return ( 279 <BottomSheet open={open} onClose={onClose} wide> 280 <div className="mb-10"> 281 <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2"> 282 {isBuiltin ? editing?.name : editing ? 'Edit Provider' : 'New Provider'} 283 </h2> 284 <p className="text-[14px] text-text-3"> 285 {isBuiltin ? 'Manage models and API key for this built-in provider' : 'Add an OpenAI-compatible provider (OpenRouter, Together, Groq, etc.)'} 286 </p> 287 </div> 288 289 {/* Name */} 290 <div className="mb-8"> 291 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Name</label> 292 <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. OpenRouter" 293 disabled={isBuiltin} className={`${inputClass} ${isBuiltin ? 'opacity-50' : ''}`} style={{ fontFamily: 'inherit' }} /> 294 </div> 295 296 {/* Base URL — for custom providers and built-ins with endpoints (Ollama, OpenClaw) */} 297 {(!isBuiltin || editingBuiltin?.requiresEndpoint || editingBuiltin?.optionalEndpoint) && ( 298 <div className="mb-8"> 299 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"> 300 {isBuiltin ? 'Endpoint' : 'Base URL'} 301 </label> 302 <input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} 303 placeholder={editingBuiltin?.defaultEndpoint || 'https://openrouter.ai/api/v1'} 304 className={`${inputClass} font-mono text-[14px]`} /> 305 <p className="text-[11px] text-text-3/70 mt-2"> 306 {isBuiltin ? `Default: ${editingBuiltin?.defaultEndpoint || 'none'}` : 'OpenAI-compatible API endpoint (without /chat/completions)'} 307 </p> 308 </div> 309 )} 310 311 {/* Models — chip editor for built-in, textarea for custom */} 312 <div className="mb-8"> 313 <div className="flex items-center justify-between mb-3"> 314 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">Models</label> 315 <div className="flex items-center gap-3"> 316 {canDiscoverModels && ( 317 <button 318 onClick={() => { void handleLoadLiveModels(Boolean(liveModels.length)) }} 319 disabled={liveLoading} 320 className="text-[11px] text-accent-bright hover:brightness-110 transition-colors cursor-pointer bg-transparent border-none disabled:opacity-50 disabled:cursor-default" 321 style={{ fontFamily: 'inherit' }} 322 > 323 {liveLoading ? 'Loading live models...' : liveModels.length > 0 ? 'Refresh live list' : 'Load live models'} 324 </button> 325 )} 326 {isBuiltin && ( 327 <button onClick={() => { void handleResetModels() }} 328 className="text-[11px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none" 329 style={{ fontFamily: 'inherit' }}> 330 Reset to defaults 331 </button> 332 )} 333 </div> 334 </div> 335 336 {(liveMessage || liveCached) && ( 337 <p className="text-[11px] text-text-3/70 mb-3"> 338 {liveMessage} 339 {liveCached ? ' Cached.' : ''} 340 </p> 341 )} 342 343 {isBuiltin ? ( 344 <> 345 <div className="flex flex-wrap gap-1.5 mb-3"> 346 {modelList.map((model, i) => { 347 const isLive = liveModels.includes(model) 348 return ( 349 <div key={`${model}-${i}`} className={`group/model flex items-center gap-1.5 px-2.5 py-1.5 rounded-[8px] border 350 ${isLive ? 'bg-emerald-500/[0.08] border-emerald-500/20' : 'bg-white/[0.04] border-white/[0.06]'}`}> 351 <span className="text-[12px] text-text-2 font-mono">{model}</span> 352 {isLive && ( 353 <span className="text-[9px] font-600 px-1.5 py-0.5 rounded-[4px] bg-emerald-500/15 text-emerald-400 uppercase tracking-wider">live</span> 354 )} 355 <button 356 onClick={() => handleRemoveModel(i)} 357 className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] text-text-3 358 opacity-0 group-hover/model:opacity-100 hover:bg-red-500/20 hover:text-red-400 359 transition-all cursor-pointer bg-transparent border-none" 360 > 361 × 362 </button> 363 </div> 364 ) 365 })} 366 </div> 367 368 <div className="flex gap-2"> 369 <input 370 type="text" 371 value={newModel} 372 onChange={(e) => setNewModel(e.target.value)} 373 placeholder="Add model ID..." 374 className={`${inputClass} flex-1 font-mono text-[14px]`} 375 style={{ fontFamily: 'inherit' }} 376 onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddModel() } }} 377 /> 378 <button 379 onClick={handleAddModel} 380 disabled={!newModel.trim()} 381 className="px-4 py-3 rounded-[14px] border-none bg-accent-soft text-accent-bright text-[13px] font-600 382 cursor-pointer disabled:opacity-30 hover:brightness-110 transition-all shrink-0" 383 style={{ fontFamily: 'inherit' }} 384 > 385 Add 386 </button> 387 </div> 388 </> 389 ) : ( 390 <> 391 <textarea 392 value={models} 393 onChange={(e) => setModels(e.target.value)} 394 placeholder="model-1, model-2, model-3" 395 rows={3} 396 className={`${inputClass} resize-y min-h-[80px] font-mono text-[14px]`} 397 style={{ fontFamily: 'inherit' }} 398 /> 399 <p className="text-[11px] text-text-3/70 mt-2">Comma-separated model IDs. Custom providers are saved as-is, so add the models you want manually.</p> 400 </> 401 )} 402 </div> 403 404 {/* Requires API Key toggle — only for custom */} 405 {!isBuiltin && ( 406 <div className="mb-8"> 407 <label className="flex items-center gap-3 cursor-pointer"> 408 <div 409 onClick={() => setRequiresApiKey(!requiresApiKey)} 410 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer 411 ${requiresApiKey ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 412 > 413 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 414 ${requiresApiKey ? 'left-[22px]' : 'left-0.5'}`} /> 415 </div> 416 <span className="font-display text-[14px] font-600 text-text-2">Requires API Key</span> 417 </label> 418 </div> 419 )} 420 421 {/* API Key section */} 422 {showApiKey && ( 423 <div className="mb-8"> 424 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"> 425 {isBuiltin ? 'API Key' : 'Linked API Key'} 426 {isBuiltin && editingBuiltin?.optionalApiKey && !editingBuiltin?.requiresApiKey && ( 427 <span className="normal-case tracking-normal font-normal text-text-3 ml-1">(optional)</span> 428 )} 429 </label> 430 {credList.length > 0 && !addingKey ? ( 431 <div className="flex gap-2"> 432 <select value={credentialId || ''} onChange={(e) => { 433 if (e.target.value === '__add__') { 434 setAddingKey(true) 435 setNewKeyName('') 436 setNewKeyValue('') 437 } else { 438 setCredentialId(e.target.value || null) 439 } 440 }} className={`${inputClass} appearance-none cursor-pointer flex-1`} style={{ fontFamily: 'inherit' }}> 441 <option value="">Select a key...</option> 442 {credList.map((c) => ( 443 <option key={c.id} value={c.id}>{c.name} ({c.provider})</option> 444 ))} 445 <option value="__add__">+ Add new key...</option> 446 </select> 447 <button 448 type="button" 449 onClick={() => { setAddingKey(true); setNewKeyName(''); setNewKeyValue('') }} 450 className="shrink-0 px-3 py-2.5 rounded-[10px] bg-accent-soft/50 text-accent-bright text-[12px] font-600 hover:bg-accent-soft transition-colors cursor-pointer border border-accent-bright/20" 451 > 452 + New 453 </button> 454 </div> 455 ) : ( 456 <div className="space-y-3 p-4 rounded-[12px] border border-accent-bright/15 bg-accent-soft/20"> 457 <input 458 type="text" 459 value={newKeyName} 460 onChange={(e) => setNewKeyName(e.target.value)} 461 placeholder="Key name (optional)" 462 className={inputClass} 463 style={{ fontFamily: 'inherit' }} 464 /> 465 <input 466 type="password" 467 value={newKeyValue} 468 onChange={(e) => setNewKeyValue(e.target.value)} 469 placeholder="Paste API key..." 470 className={inputClass} 471 style={{ fontFamily: 'inherit' }} 472 /> 473 <div className="flex gap-2 justify-end"> 474 {credList.length > 0 && ( 475 <button type="button" onClick={() => setAddingKey(false)} className="px-3 py-1.5 text-[12px] text-text-3 hover:text-text-2 transition-colors cursor-pointer bg-transparent border-none" style={{ fontFamily: 'inherit' }}>Cancel</button> 476 )} 477 <button 478 type="button" 479 disabled={savingKey || !newKeyValue.trim()} 480 onClick={async () => { 481 setSavingKey(true) 482 try { 483 await handleCreateCredential() 484 } catch (err: unknown) { 485 toast.error(`Failed to save: ${errorMessage(err)}`) 486 } finally { 487 setSavingKey(false) 488 } 489 }} 490 className="px-4 py-1.5 rounded-[8px] bg-accent-bright text-white text-[12px] font-600 cursor-pointer border-none hover:brightness-110 transition-all disabled:opacity-40" 491 style={{ fontFamily: 'inherit' }} 492 > 493 {savingKey ? 'Saving...' : 'Save Key'} 494 </button> 495 </div> 496 </div> 497 )} 498 </div> 499 )} 500 501 {/* Enabled toggle */} 502 {(isBuiltin || editingCustom) && ( 503 <div className="mb-8"> 504 <label className="flex items-center gap-3 cursor-pointer"> 505 <div 506 onClick={() => setIsEnabled(!isEnabled)} 507 className={`w-11 h-6 rounded-full transition-all duration-200 relative cursor-pointer 508 ${isEnabled ? 'bg-accent-bright' : 'bg-white/[0.08]'}`} 509 > 510 <div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all duration-200 511 ${isEnabled ? 'left-[22px]' : 'left-0.5'}`} /> 512 </div> 513 <span className="font-display text-[14px] font-600 text-text-2">Enabled</span> 514 {isBuiltin && ( 515 <span className="text-[12px] text-text-3">Hidden from the agent sheet when off.</span> 516 )} 517 </label> 518 </div> 519 )} 520 521 {/* Test model selector */} 522 {showTestButton && ( 523 <div className="mb-4"> 524 <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"> 525 Test Model 526 <span className="normal-case tracking-normal font-normal text-text-3 ml-1">(optional)</span> 527 </label> 528 <select 529 value={testModel} 530 onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage('') }} 531 className={`${inputClass} appearance-none cursor-pointer`} 532 style={{ fontFamily: 'inherit' }} 533 > 534 <option value="">Auto-detect</option> 535 {modelList.map((m) => ( 536 <option key={m} value={m}>{m}</option> 537 ))} 538 </select> 539 </div> 540 )} 541 542 {/* Test connection result */} 543 {isBuiltin && testStatus === 'fail' && ( 544 <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20"> 545 <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p> 546 </div> 547 )} 548 {isBuiltin && testStatus === 'pass' && ( 549 <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20"> 550 <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p> 551 </div> 552 )} 553 554 <div className="flex gap-3 pt-2 border-t border-white/[0.04]"> 555 {editingCustom && ( 556 <button onClick={() => setConfirmDelete(true)} className="py-3.5 px-6 rounded-[14px] border border-red-500/20 bg-transparent text-red-400 text-[15px] font-600 cursor-pointer hover:bg-red-500/10 transition-all" style={{ fontFamily: 'inherit' }}> 557 Delete 558 </button> 559 )} 560 <button onClick={onClose} className="flex-1 py-3.5 rounded-[14px] border border-white/[0.08] bg-transparent text-text-2 text-[15px] font-600 cursor-pointer hover:bg-surface-2 transition-all" style={{ fontFamily: 'inherit' }}> 561 Cancel 562 </button> 563 {showTestButton && ( 564 <button 565 onClick={handleTestConnection} 566 disabled={testStatus === 'testing'} 567 className="py-3.5 px-6 rounded-[14px] border-none bg-emerald-600 text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-30 transition-all shadow-[0_4px_20px_rgba(16,185,129,0.2)] hover:brightness-110" 568 style={{ fontFamily: 'inherit' }} 569 > 570 {testStatus === 'testing' ? 'Testing...' : testStatus === 'fail' ? 'Retry Connection' : 'Test Connection'} 571 </button> 572 )} 573 <button 574 onClick={handleSave} 575 disabled={isBuiltin ? false : (!name.trim() || !baseUrl.trim())} 576 className="flex-1 py-3.5 rounded-[14px] border-none bg-accent-bright text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-30 transition-all shadow-[0_4px_20px_rgba(99,102,241,0.25)] hover:brightness-110" 577 style={{ fontFamily: 'inherit' }} 578 > 579 {editing ? 'Save' : 'Create'} 580 </button> 581 </div> 582 <ConfirmDialog 583 open={confirmDelete} 584 title="Delete Provider?" 585 message={editingCustom ? `Delete custom provider "${editingCustom.name}"?` : 'Delete this provider?'} 586 confirmLabel={deleting ? 'Deleting...' : 'Delete'} 587 confirmDisabled={deleting} 588 cancelDisabled={deleting} 589 danger 590 onConfirm={() => { void handleDelete() }} 591 onCancel={() => { if (!deleting) setConfirmDelete(false) }} 592 /> 593 </BottomSheet> 594 ) 595 }