mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## What kind of change does this PR introduce? Mechanical cleanup on top of the Button default-variant change (#50160). ## What is the current behavior? Many callsites still pass `variant="default"` even though that is now the component default. ## What is the new behavior? Removes redundant static `variant="default"` from legacy `Button` and `ButtonTooltip` callsites. Keeps explicit defaults where they document the API: - `button-default.tsx` and `button-sizes.tsx` demos - `DocsButton`, which pins neutral styling at the wrapper boundary ## To test Studio: - [Auth → Rate Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits): dirty the form so Cancel appears; Cancel stays neutral, Save stays green - [Project Settings → API Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys): `DocsButton` in the header actions stays neutral Design system: - [Design system → Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button): `button-default` / `button-sizes` still show explicit default styling; Primary (green) is restricted to the Primary section (and `asChild`) WWW: - [www → Brand assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets): Download logo kit / Download button kit stay neutral
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import type { UIMessage as MessageType } from '@ai-sdk/react'
|
|
import { ArrowUpRight } from 'lucide-react'
|
|
import dynamic from 'next/dynamic'
|
|
import Link from 'next/link'
|
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
|
import type { JSX } from 'react'
|
|
import type { StreamdownProps } from 'streamdown'
|
|
import {
|
|
AiIconAnimation,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
cn,
|
|
Skeleton,
|
|
} from 'ui'
|
|
|
|
import {
|
|
ASSISTANT_HANDOFF_QUERY_PARAM,
|
|
buildSupportAssistantPrompt,
|
|
storeAssistantHandoff,
|
|
} from '@/components/interfaces/Support/SupportAssistant.utils'
|
|
import type { SubmittedSupportRequest } from '@/components/interfaces/Support/SupportForm.state'
|
|
import { NO_PROJECT_MARKER } from '@/components/interfaces/Support/SupportForm.utils'
|
|
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import {
|
|
useAiAssistantState,
|
|
useAiAssistantStateSnapshot,
|
|
type AiAssistantState,
|
|
} from '@/state/ai-assistant-state'
|
|
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
|
|
|
type SupportAssistantPreviewChat = AiAssistantState['chatInstances'][string]
|
|
|
|
const EMPTY_MESSAGES: MessageType[] = []
|
|
|
|
const Streamdown = dynamic<StreamdownProps>(
|
|
() => import('streamdown').then((mod) => mod.Streamdown),
|
|
{ ssr: false }
|
|
)
|
|
|
|
interface SupportAssistantSuccessCardContentProps {
|
|
request: SubmittedSupportRequest
|
|
className?: string
|
|
}
|
|
|
|
function hasProjectScopedAssistantContext(projectRef: string | undefined) {
|
|
return projectRef !== undefined && projectRef !== NO_PROJECT_MARKER
|
|
}
|
|
|
|
export function SupportAssistantSuccessCardContent({
|
|
request,
|
|
className,
|
|
}: SupportAssistantSuccessCardContentProps) {
|
|
const hasAssistantContext = hasProjectScopedAssistantContext(request.projectRef)
|
|
const { data: currentProject } = useSelectedProjectQuery()
|
|
// The ticket's resolved project isn't the one in the current URL — hand off to that
|
|
// project's own page instead of faking assistant context in place.
|
|
const isProjectHandoffRequired = hasAssistantContext && currentProject?.ref !== request.projectRef
|
|
|
|
const aiAssistant = useAiAssistantStateSnapshot()
|
|
const aiAssistantState = useAiAssistantState()
|
|
const { openSidebar } = useSidebarManagerSnapshot()
|
|
const track = useTrack()
|
|
const createdChatIdRef = useRef<string | null>(null)
|
|
const [chatId, setChatId] = useState<string>()
|
|
const chat = chatId ? aiAssistant.chatInstances[chatId] : undefined
|
|
|
|
const assistantPrompt = useMemo(() => buildSupportAssistantPrompt(request), [request])
|
|
|
|
// An opaque token for the handoff URL — the actual ticket content never touches the URL
|
|
// (browser history, referrer headers, a copy-pasted link); it's stashed in sessionStorage
|
|
// instead, scoped to this tab and consumed (removed) the moment the destination page reads it.
|
|
const [handoffToken] = useState(() => crypto.randomUUID())
|
|
|
|
useEffect(() => {
|
|
if (!isProjectHandoffRequired) return
|
|
storeAssistantHandoff(handoffToken, request)
|
|
}, [isProjectHandoffRequired, handoffToken, request])
|
|
|
|
useEffect(() => {
|
|
if (!hasAssistantContext || isProjectHandoffRequired) return
|
|
if (createdChatIdRef.current) return
|
|
|
|
const newChatId = aiAssistant.newChat({
|
|
name: 'Support request',
|
|
initialMessage: assistantPrompt,
|
|
})
|
|
|
|
createdChatIdRef.current = newChatId
|
|
setChatId(newChatId)
|
|
}, [aiAssistant, assistantPrompt, hasAssistantContext, isProjectHandoffRequired])
|
|
|
|
const handleOpenAssistant = () => {
|
|
track(
|
|
'support_assistant_follow_up_card_clicked',
|
|
{ ticketCategory: request.category },
|
|
{
|
|
project: request.projectRef,
|
|
organization: request.organizationSlug,
|
|
}
|
|
)
|
|
|
|
if (chatId) {
|
|
// Tag the chat as a support chat on first engagement so its messages and
|
|
// lifecycle sync to Front. Gated on the click (rather than on chat creation)
|
|
// so chats the user never opens don't create Front conversations.
|
|
const chat = aiAssistantState.chats[chatId]
|
|
if (chat && !chat.supportMetadata) {
|
|
chat.supportMetadata = {
|
|
subject: request.subject,
|
|
category: request.category,
|
|
severity: request.severity,
|
|
organizationSlug: request.organizationSlug,
|
|
projectRef: request.projectRef,
|
|
library: request.library,
|
|
affectedServices: request.affectedServices,
|
|
allowSupportAccess: request.allowSupportAccess,
|
|
// Reuse the Front conversation created at submit so AI messages thread into it.
|
|
frontConversationId: request.frontConversationId,
|
|
threadRef: request.threadRef,
|
|
isSupportChat: true,
|
|
lifecycleStatus: 'bot_active',
|
|
lastSyncedMessageCount: 0,
|
|
isSyncing: false,
|
|
isLifecycleSyncing: false,
|
|
}
|
|
|
|
// Flush any messages produced before the user engaged (the initial prompt
|
|
// and any assistant reply). Subsequent turns sync via the onFinish hook.
|
|
void import('@/state/ai-chat-front-sync')
|
|
.then(({ syncSupportChatToFront }) => syncSupportChatToFront(chatId, aiAssistantState))
|
|
.catch(() => {})
|
|
}
|
|
|
|
aiAssistantState.selectChat(chatId)
|
|
}
|
|
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
|
}
|
|
|
|
if (!hasAssistantContext) return null
|
|
|
|
if (isProjectHandoffRequired) {
|
|
return (
|
|
<Card className={cn('bg-muted/50', className)}>
|
|
<CardHeader className="flex-row items-center justify-between gap-4 space-y-0">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
|
|
<AiIconAnimation size={14} />
|
|
</div>
|
|
<div className="min-w-0 space-y-1">
|
|
<CardTitle>While you wait</CardTitle>
|
|
<CardDescription>Continue with the Assistant in your project</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button asChild size="tiny" iconRight={<ArrowUpRight size={14} strokeWidth={1.5} />}>
|
|
<Link
|
|
href={`/project/${request.projectRef}?sidebar=ai-assistant&${ASSISTANT_HANDOFF_QUERY_PARAM}=${handoffToken}`}
|
|
onClick={() =>
|
|
track(
|
|
'support_assistant_follow_up_card_clicked',
|
|
{ ticketCategory: request.category },
|
|
{ project: request.projectRef, organization: request.organizationSlug }
|
|
)
|
|
}
|
|
>
|
|
Open Assistant in project
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="Open assistant response"
|
|
onClick={handleOpenAssistant}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
handleOpenAssistant()
|
|
}
|
|
}}
|
|
className={cn(
|
|
'group cursor-pointer bg-muted/50 transition-colors hover:bg-muted/50 focus-ring',
|
|
className
|
|
)}
|
|
>
|
|
<CardHeader className="flex-row items-center justify-between gap-4 space-y-0">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-background">
|
|
<AiIconAnimation size={14} />
|
|
</div>
|
|
<div className="min-w-0 space-y-1">
|
|
<CardTitle>While you wait</CardTitle>
|
|
<CardDescription>Assistant may be able to help</CardDescription>
|
|
</div>
|
|
</div>
|
|
<ArrowUpRight
|
|
size={14}
|
|
strokeWidth={1.5}
|
|
className="shrink-0 text-foreground-lighter transition-colors group-hover:text-foreground"
|
|
aria-hidden
|
|
/>
|
|
</CardHeader>
|
|
{chat ? (
|
|
<SupportAssistantResponsePreview chat={chat as SupportAssistantPreviewChat} />
|
|
) : (
|
|
<CardContent>
|
|
<SupportAssistantResponseLoadingSkeleton />
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function useChatMessages(chat: SupportAssistantPreviewChat | undefined) {
|
|
const subscribe = useCallback(
|
|
(onStoreChange: () => void) => {
|
|
return chat?.['~registerMessagesCallback']?.(onStoreChange) ?? (() => {})
|
|
},
|
|
[chat]
|
|
)
|
|
|
|
const getSnapshot = useCallback(() => chat?.messages ?? EMPTY_MESSAGES, [chat])
|
|
|
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
}
|
|
|
|
function getAssistantMessageText(message: MessageType) {
|
|
return (
|
|
message.parts
|
|
?.filter((part) => part.type === 'text')
|
|
.map((part) => part.text)
|
|
.join('') ?? ''
|
|
)
|
|
}
|
|
|
|
function SupportAssistantResponsePreview({ chat }: { chat: SupportAssistantPreviewChat }) {
|
|
const messages = useChatMessages(chat)
|
|
|
|
const latestAssistantMessage = [...messages]
|
|
.reverse()
|
|
.find((message) => message.role === 'assistant')
|
|
|
|
if (!latestAssistantMessage) {
|
|
return (
|
|
<CardContent>
|
|
<SupportAssistantResponseLoadingSkeleton />
|
|
</CardContent>
|
|
)
|
|
}
|
|
|
|
const previewText = getAssistantMessageText(latestAssistantMessage)
|
|
|
|
return (
|
|
<CardContent className="relative max-h-48 overflow-hidden">
|
|
<SupportAssistantPreviewMarkdown>{previewText}</SupportAssistantPreviewMarkdown>
|
|
</CardContent>
|
|
)
|
|
}
|
|
|
|
function SupportAssistantPreviewMarkdown({ children }: { children: string }) {
|
|
return (
|
|
<Streamdown
|
|
className="prose prose-sm dark:prose-dark max-w-none space-y-3 text-sm text-foreground-light prose-p:my-0 prose-strong:font-medium prose-strong:text-foreground prose-code:text-xs prose-li:my-0 prose-ul:my-0 prose-ol:my-0"
|
|
components={supportAssistantPreviewMarkdownComponents}
|
|
>
|
|
{children}
|
|
</Streamdown>
|
|
)
|
|
}
|
|
|
|
function SupportAssistantPreviewImage({ src }: JSX.IntrinsicElements['img']) {
|
|
return <span className="font-mono text-foreground-lighter">[Image: {src?.toString()}]</span>
|
|
}
|
|
|
|
const supportAssistantPreviewMarkdownComponents: StreamdownProps['components'] = {
|
|
img: SupportAssistantPreviewImage,
|
|
}
|
|
|
|
function SupportAssistantResponseLoadingSkeleton() {
|
|
return (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-[82%]" />
|
|
<Skeleton className="h-4 w-[92%]" />
|
|
<Skeleton className="h-4 w-[68%]" />
|
|
</div>
|
|
)
|
|
}
|