mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
* feat: add GitHub Copilot as OAuth-based LLM provider Add GitHub Copilot as a second OAuth provider using the Device Code flow (RFC 8628). Users authenticate via github.com/login/device, and the server polls for token completion. Supports 25+ models through a single Copilot subscription. Key changes: - Device Code OAuth flow in token manager (poll with safety margin) - Custom fetch wrapper injecting Copilot headers + vision detection - Provider factory using createOpenAICompatible for Chat Completions API - Extension UI with template card, auto-create on auth, and disconnect * fix: address PR review comments for GitHub Copilot OAuth - Validate device code response for error fields (GitHub can return 200 with error payload) - Store empty refreshToken instead of access token for GitHub tokens - Add closeButton to Toaster for dismissing device code toast * fix: add github-copilot to agent provider factory The chat route uses a separate provider-factory.ts (agent layer) from the test-provider route (llm/provider.ts). Added createGitHubCopilotFactory to the agent factory so chat works with GitHub Copilot. * fix: add github-copilot to provider icons, models, and dialog - Add Github icon from lucide-react to providerIcons map - Add 8 Copilot models (GPT-4o, Claude, Gemini, Grok) to models.ts - Add github-copilot to NewProviderDialog zod enum, validation skip, canTest check, and OAuth credential message * fix: reorder copilot models with free-tier models first Put models available on Copilot Free at the top (gpt-4o, gpt-4.1, gpt-5-mini, claude-haiku-4.5, grok-code-fast-1), followed by premium models that require paid Copilot subscription. * fix: set correct 64K context window for Copilot models Copilot API enforces a 64K input token limit regardless of the underlying model's native context window. Updated all model entries and the default template to 64000 so compaction triggers correctly. * fix: use actual per-model prompt limits from Copilot /models API Queried api.githubcopilot.com/models for real max_prompt_tokens values. GPT-4o/4.1 have 64K, Claude/gpt-5-mini have 128K, GPT-5.x have 272K. Also updated model list to match what's actually available on the API (e.g. claude-sonnet-4.6 instead of 4.5, added gpt-5.4/5.2-codex). * feat: resize images for Copilot using VS Code's algorithm Large screenshots cause 413 errors on Copilot's API. Resize images following VS Code's approach: max 2048px longest side, 768px shortest side, re-encode as JPEG at 75% quality. Uses sharp for server-side image processing. * fix: address all Greptile P1 review comments - Add .catch() on fire-and-forget pollDeviceCode to prevent unhandled rejection crashes (Node 15+) - Add deduplication guard (activeDeviceFlows Set) to prevent concurrent device code flows for the same provider - Add runtime validation of server response in frontend before calling window.open() and showing toast - Remove dead GITHUB_DEVICE_VERIFICATION constant from urls.ts * fix: upgrade biome to 2.4.8, fix all lint errors, and address review bugs - Upgrade biome from 2.4.5 to 2.4.8 (matches CI) and migrate configs - Fix image resize: only re-encode when dimensions actually change - Fix device code polling: retry on transient network errors instead of aborting - Allow restarting device code flow (clear old flow instead of throwing 500) - Fix pre-existing noNonNullAssertion and noExplicitAny lint errors globally * fix: address Greptile P2 review — image resize and config guard - Fix early-return guard: check max/min sides against their respective limits (MAX_LONG_SIDE/MAX_SHORT_SIDE) instead of both against SHORT - Preserve PNG alpha: detect hasAlpha and keep PNG format instead of unconditionally converting to lossy JPEG - Keep browserosId guard in resolveGitHubCopilotConfig consistent with ChatGPT Pro pattern (safety check that caller context is valid) * feat: update Copilot models to full list from pricing page, default to gpt-5-mini Added all 23 models from GitHub Copilot pricing page. Ordered with free-tier models first (gpt-5-mini, claude-haiku-4.5), then premium. Changed default from gpt-4o to gpt-5-mini since it's unlimited on Pro plan and has 128K context (vs gpt-4o's 64K limit).
505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
import { useQueryClient } from '@tanstack/react-query'
|
|
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { useSessionInfo } from '@/lib/auth/sessionStorage'
|
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
|
import {
|
|
CHATGPT_PRO_OAUTH_COMPLETED_EVENT,
|
|
CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT,
|
|
CHATGPT_PRO_OAUTH_STARTED_EVENT,
|
|
GITHUB_COPILOT_OAUTH_COMPLETED_EVENT,
|
|
GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT,
|
|
GITHUB_COPILOT_OAUTH_STARTED_EVENT,
|
|
} from '@/lib/constants/analyticsEvents'
|
|
import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument'
|
|
import { getQueryKeyFromDocument } from '@/lib/graphql/getQueryKeyFromDocument'
|
|
import { useGraphqlMutation } from '@/lib/graphql/useGraphqlMutation'
|
|
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
|
import {
|
|
getProviderTemplate,
|
|
type ProviderTemplate,
|
|
} from '@/lib/llm-providers/providerTemplates'
|
|
import { testProvider } from '@/lib/llm-providers/testProvider'
|
|
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
|
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
|
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
|
|
import { track } from '@/lib/metrics/track'
|
|
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
|
|
import {
|
|
DeleteRemoteLlmProviderDocument,
|
|
GetRemoteLlmProvidersDocument,
|
|
} from './graphql/aiSettingsDocument'
|
|
import type { IncompleteProvider } from './IncompleteProviderCard'
|
|
import { IncompleteProvidersList } from './IncompleteProvidersList'
|
|
import { LlmProvidersHeader } from './LlmProvidersHeader'
|
|
import { NewProviderDialog } from './NewProviderDialog'
|
|
import { ProviderTemplatesSection } from './ProviderTemplatesSection'
|
|
|
|
/**
|
|
* AI Settings page for managing LLM providers
|
|
* @public
|
|
*/
|
|
export const AISettingsPage: FC = () => {
|
|
const {
|
|
providers,
|
|
defaultProviderId,
|
|
saveProvider,
|
|
setDefaultProvider,
|
|
deleteProvider,
|
|
} = useLlmProviders()
|
|
const { baseUrl: agentServerUrl } = useAgentServerUrl()
|
|
const { sessionInfo } = useSessionInfo()
|
|
const queryClient = useQueryClient()
|
|
|
|
const userId = sessionInfo.user?.id
|
|
|
|
const { data: profileData } = useGraphqlQuery(
|
|
GetProfileIdByUserIdDocument,
|
|
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
|
|
{ userId: userId! },
|
|
{ enabled: !!userId },
|
|
)
|
|
const profileId = profileData?.profileByUserId?.rowId
|
|
|
|
const { data: remoteProvidersData } = useGraphqlQuery(
|
|
GetRemoteLlmProvidersDocument,
|
|
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled
|
|
{ profileId: profileId! },
|
|
{ enabled: !!profileId },
|
|
)
|
|
|
|
const deleteRemoteProviderMutation = useGraphqlMutation(
|
|
DeleteRemoteLlmProviderDocument,
|
|
{
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: [getQueryKeyFromDocument(GetRemoteLlmProvidersDocument)],
|
|
})
|
|
},
|
|
},
|
|
)
|
|
|
|
const incompleteProviders = useMemo<IncompleteProvider[]>(() => {
|
|
if (!remoteProvidersData?.llmProviders?.nodes) return []
|
|
|
|
const localProviderIds = new Set(providers.map((p) => p.id))
|
|
|
|
return remoteProvidersData.llmProviders.nodes
|
|
.filter((node): node is NonNullable<typeof node> => node !== null)
|
|
.filter((node) => !localProviderIds.has(node.rowId))
|
|
}, [remoteProvidersData, providers])
|
|
|
|
const [isNewDialogOpen, setIsNewDialogOpen] = useState(false)
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
|
const [templateValues, setTemplateValues] = useState<
|
|
Partial<LlmProviderConfig> | undefined
|
|
>()
|
|
const [editingProvider, setEditingProvider] =
|
|
useState<LlmProviderConfig | null>(null)
|
|
const [providerToDelete, setProviderToDelete] =
|
|
useState<LlmProviderConfig | null>(null)
|
|
const [incompleteProviderToDelete, setIncompleteProviderToDelete] =
|
|
useState<IncompleteProvider | null>(null)
|
|
const [testingProviderId, setTestingProviderId] = useState<string | null>(
|
|
null,
|
|
)
|
|
|
|
// OAuth status for ChatGPT Plus/Pro
|
|
const {
|
|
status: chatgptProStatus,
|
|
startPolling: startChatGPTProPolling,
|
|
disconnect: disconnectChatGPTPro,
|
|
} = useOAuthStatus('chatgpt-pro')
|
|
|
|
// OAuth status for GitHub Copilot
|
|
const {
|
|
status: copilotStatus,
|
|
startPolling: startCopilotPolling,
|
|
disconnect: disconnectCopilot,
|
|
} = useOAuthStatus('github-copilot')
|
|
|
|
// Track whether user explicitly started an OAuth flow this session
|
|
const oauthFlowStartedRef = useRef(false)
|
|
const copilotOAuthStartedRef = useRef(false)
|
|
|
|
// Auto-create provider only when user actively completed OAuth,
|
|
// not on passive page load when server has old tokens
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
|
useEffect(() => {
|
|
if (!chatgptProStatus?.authenticated) return
|
|
if (!oauthFlowStartedRef.current) return
|
|
|
|
const exists = providers.some((p) => p.type === 'chatgpt-pro')
|
|
if (exists) return
|
|
|
|
const now = Date.now()
|
|
try {
|
|
const template = getProviderTemplate('chatgpt-pro')
|
|
saveProvider({
|
|
id: `chatgpt-pro-${now}`,
|
|
type: 'chatgpt-pro',
|
|
name: `ChatGPT Plus/Pro${chatgptProStatus.email ? ` (${chatgptProStatus.email})` : ''}`,
|
|
modelId: template?.defaultModelId ?? 'gpt-5.3-codex',
|
|
supportsImages: template?.supportsImages ?? true,
|
|
contextWindow: template?.contextWindow ?? 400000,
|
|
temperature: 0.2,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
track(CHATGPT_PRO_OAUTH_COMPLETED_EVENT, {
|
|
email: chatgptProStatus.email,
|
|
})
|
|
toast.success('ChatGPT Plus/Pro Connected', {
|
|
description: chatgptProStatus.email
|
|
? `Authenticated as ${chatgptProStatus.email}`
|
|
: 'Successfully authenticated with ChatGPT Plus/Pro',
|
|
})
|
|
} catch (err) {
|
|
toast.error('Failed to create ChatGPT Plus/Pro provider', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
})
|
|
} finally {
|
|
oauthFlowStartedRef.current = false
|
|
}
|
|
}, [chatgptProStatus?.authenticated])
|
|
|
|
// Auto-create GitHub Copilot provider on successful OAuth
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only trigger on auth status change
|
|
useEffect(() => {
|
|
if (!copilotStatus?.authenticated) return
|
|
if (!copilotOAuthStartedRef.current) return
|
|
|
|
const exists = providers.some((p) => p.type === 'github-copilot')
|
|
if (exists) return
|
|
|
|
const now = Date.now()
|
|
try {
|
|
const template = getProviderTemplate('github-copilot')
|
|
saveProvider({
|
|
id: `github-copilot-${now}`,
|
|
type: 'github-copilot',
|
|
name: 'GitHub Copilot',
|
|
modelId: template?.defaultModelId ?? 'gpt-4o',
|
|
supportsImages: template?.supportsImages ?? true,
|
|
contextWindow: template?.contextWindow ?? 128000,
|
|
temperature: 0.2,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
track(GITHUB_COPILOT_OAUTH_COMPLETED_EVENT)
|
|
toast.success('GitHub Copilot Connected', {
|
|
description: 'Successfully authenticated with GitHub Copilot',
|
|
})
|
|
} catch (err) {
|
|
toast.error('Failed to create GitHub Copilot provider', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
})
|
|
} finally {
|
|
copilotOAuthStartedRef.current = false
|
|
}
|
|
}, [copilotStatus?.authenticated])
|
|
|
|
const handleAddProvider = () => {
|
|
setTemplateValues(undefined)
|
|
setIsNewDialogOpen(true)
|
|
}
|
|
|
|
const handleUseTemplate = (template: ProviderTemplate) => {
|
|
// OAuth providers: trigger OAuth flow instead of opening form dialog
|
|
if (template.id === 'chatgpt-pro') {
|
|
handleStartChatGPTProOAuth()
|
|
return
|
|
}
|
|
if (template.id === 'github-copilot') {
|
|
handleStartGitHubCopilotOAuth()
|
|
return
|
|
}
|
|
|
|
setTemplateValues({
|
|
type: template.id,
|
|
name: template.name,
|
|
baseUrl: template.defaultBaseUrl,
|
|
modelId: template.defaultModelId,
|
|
supportsImages: template.supportsImages,
|
|
contextWindow: template.contextWindow,
|
|
temperature: 0.2,
|
|
})
|
|
setIsNewDialogOpen(true)
|
|
}
|
|
|
|
const handleStartChatGPTProOAuth = () => {
|
|
if (!agentServerUrl) {
|
|
toast.error('Server not available', {
|
|
description: 'Cannot start OAuth flow without server connection.',
|
|
})
|
|
return
|
|
}
|
|
oauthFlowStartedRef.current = true
|
|
|
|
const extensionSettingsUrl = chrome.runtime.getURL('app.html#/ai-settings')
|
|
const startUrl = `${agentServerUrl}/oauth/chatgpt-pro/start?redirect=${encodeURIComponent(extensionSettingsUrl)}`
|
|
window.open(startUrl, '_blank')
|
|
|
|
// Start polling for OAuth completion
|
|
startChatGPTProPolling()
|
|
track(CHATGPT_PRO_OAUTH_STARTED_EVENT)
|
|
toast.info('Authenticating with ChatGPT Plus/Pro', {
|
|
description: 'Complete the login in the opened tab.',
|
|
})
|
|
}
|
|
|
|
const handleStartGitHubCopilotOAuth = async () => {
|
|
if (!agentServerUrl) {
|
|
toast.error('Server not available', {
|
|
description: 'Cannot start OAuth flow without server connection.',
|
|
})
|
|
return
|
|
}
|
|
copilotOAuthStartedRef.current = true
|
|
|
|
try {
|
|
// Device Code flow: get user code from server, then open GitHub
|
|
const res = await fetch(`${agentServerUrl}/oauth/github-copilot/start`)
|
|
if (!res.ok) throw new Error(`Server returned ${res.status}`)
|
|
|
|
const data = (await res.json()) as {
|
|
userCode?: string
|
|
verificationUri?: string
|
|
}
|
|
|
|
if (!data.userCode || !data.verificationUri) {
|
|
throw new Error('Invalid response from server')
|
|
}
|
|
|
|
// Open GitHub device verification page
|
|
window.open(data.verificationUri, '_blank')
|
|
|
|
// Start polling for completion
|
|
startCopilotPolling()
|
|
track(GITHUB_COPILOT_OAUTH_STARTED_EVENT)
|
|
toast.info(`Enter code: ${data.userCode}`, {
|
|
description: 'Paste this code on the GitHub page that just opened.',
|
|
duration: 60_000,
|
|
})
|
|
} catch (err) {
|
|
copilotOAuthStartedRef.current = false
|
|
toast.error('Failed to start GitHub Copilot authentication', {
|
|
description: err instanceof Error ? err.message : 'Unknown error',
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleEditProvider = (provider: LlmProviderConfig) => {
|
|
setEditingProvider(provider)
|
|
setIsEditDialogOpen(true)
|
|
}
|
|
|
|
const handleDeleteProvider = (provider: LlmProviderConfig) => {
|
|
setProviderToDelete(provider)
|
|
}
|
|
|
|
const confirmDeleteProvider = async () => {
|
|
if (providerToDelete) {
|
|
// Clear OAuth tokens on server for OAuth-based providers
|
|
if (providerToDelete.type === 'chatgpt-pro') {
|
|
await disconnectChatGPTPro()
|
|
track(CHATGPT_PRO_OAUTH_DISCONNECTED_EVENT)
|
|
}
|
|
if (providerToDelete.type === 'github-copilot') {
|
|
await disconnectCopilot()
|
|
track(GITHUB_COPILOT_OAUTH_DISCONNECTED_EVENT)
|
|
}
|
|
await deleteProvider(providerToDelete.id)
|
|
deleteRemoteProviderMutation.mutate({ rowId: providerToDelete.id })
|
|
setProviderToDelete(null)
|
|
}
|
|
}
|
|
|
|
const handleAddKeysToIncomplete = (provider: IncompleteProvider) => {
|
|
const timestamp = Date.now()
|
|
setTemplateValues({
|
|
id: provider.rowId,
|
|
type: provider.type as LlmProviderConfig['type'],
|
|
name: provider.name,
|
|
baseUrl: provider.baseUrl ?? undefined,
|
|
modelId: provider.modelId,
|
|
supportsImages: provider.supportsImages,
|
|
contextWindow: provider.contextWindow ?? 128000,
|
|
temperature: provider.temperature ?? 0.2,
|
|
resourceName: provider.resourceName ?? undefined,
|
|
region: provider.region ?? undefined,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
})
|
|
setIsNewDialogOpen(true)
|
|
}
|
|
|
|
const handleDeleteIncompleteProvider = (provider: IncompleteProvider) => {
|
|
setIncompleteProviderToDelete(provider)
|
|
}
|
|
|
|
const confirmDeleteIncompleteProvider = () => {
|
|
if (incompleteProviderToDelete) {
|
|
deleteRemoteProviderMutation.mutate({
|
|
rowId: incompleteProviderToDelete.rowId,
|
|
})
|
|
setIncompleteProviderToDelete(null)
|
|
}
|
|
}
|
|
|
|
const handleSaveProvider = async (provider: LlmProviderConfig) => {
|
|
await saveProvider(provider)
|
|
}
|
|
|
|
const handleSelectProvider = (providerId: string) => {
|
|
setDefaultProvider(providerId)
|
|
}
|
|
|
|
const handleTestProvider = async (provider: LlmProviderConfig) => {
|
|
if (!agentServerUrl) {
|
|
toast.error('Test Failed', {
|
|
description: (
|
|
<span className="text-red-600 text-sm dark:text-red-400">
|
|
Server URL not available
|
|
</span>
|
|
),
|
|
duration: 3000,
|
|
})
|
|
return
|
|
}
|
|
|
|
setTestingProviderId(provider.id)
|
|
|
|
try {
|
|
const result = await testProvider(provider, agentServerUrl)
|
|
|
|
if (result.success) {
|
|
toast.success('Test Successful', {
|
|
description: (
|
|
<span className="text-green-600 text-sm dark:text-green-400">
|
|
{result.message}
|
|
</span>
|
|
),
|
|
duration: 3000,
|
|
})
|
|
} else {
|
|
toast.error('Test Failed', {
|
|
description: (
|
|
<span className="text-red-600 text-sm dark:text-red-400">
|
|
{result.message}
|
|
</span>
|
|
),
|
|
duration: 3000,
|
|
})
|
|
}
|
|
} catch (error) {
|
|
toast.error('Test Failed', {
|
|
description: (
|
|
<span className="text-red-600 text-sm dark:text-red-400">
|
|
{error instanceof Error ? error.message : 'Unknown error'}
|
|
</span>
|
|
),
|
|
duration: 3000,
|
|
})
|
|
}
|
|
|
|
setTestingProviderId(null)
|
|
}
|
|
|
|
return (
|
|
<div className="fade-in slide-in-from-bottom-5 animate-in space-y-6 duration-500">
|
|
<LlmProvidersHeader
|
|
providers={providers}
|
|
defaultProviderId={defaultProviderId}
|
|
onDefaultProviderChange={setDefaultProvider}
|
|
onAddProvider={handleAddProvider}
|
|
/>
|
|
|
|
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
|
|
|
|
<ConfiguredProvidersList
|
|
providers={providers}
|
|
selectedProviderId={defaultProviderId}
|
|
testingProviderId={testingProviderId}
|
|
onSelectProvider={handleSelectProvider}
|
|
onTestProvider={handleTestProvider}
|
|
onEditProvider={handleEditProvider}
|
|
onDeleteProvider={handleDeleteProvider}
|
|
/>
|
|
|
|
<IncompleteProvidersList
|
|
providers={incompleteProviders}
|
|
onAddKeys={handleAddKeysToIncomplete}
|
|
onDelete={handleDeleteIncompleteProvider}
|
|
/>
|
|
|
|
<NewProviderDialog
|
|
open={isNewDialogOpen}
|
|
onOpenChange={setIsNewDialogOpen}
|
|
initialValues={templateValues}
|
|
onSave={handleSaveProvider}
|
|
/>
|
|
|
|
<NewProviderDialog
|
|
open={isEditDialogOpen}
|
|
onOpenChange={setIsEditDialogOpen}
|
|
initialValues={editingProvider ?? undefined}
|
|
onSave={handleSaveProvider}
|
|
/>
|
|
|
|
<AlertDialog
|
|
open={!!providerToDelete}
|
|
onOpenChange={(open) => !open && setProviderToDelete(null)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete "{providerToDelete?.name}"? This
|
|
action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDeleteProvider}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<AlertDialog
|
|
open={!!incompleteProviderToDelete}
|
|
onOpenChange={(open) => !open && setIncompleteProviderToDelete(null)}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Synced Provider</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete "
|
|
{incompleteProviderToDelete?.name}
|
|
"? This will remove it from all your devices.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDeleteIncompleteProvider}>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|