/ src / components / providers / provider-sheet.tsx
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                        &times;
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  }