mirror of
https://github.com/supabase/supabase.git
synced 2026-09-11 04:21:47 +08:00
Fixes FE-4200, FE-4206. ## What is the current behavior? Submitting a support ticket from an org-level page (with no project in the URL) shows a "While you wait" AI assistant card. However, the assistant is built around project-scoped context from the URL, so making it work here required adding project-context fallbacks across several features. Two previous PRs addressed individual issues, but testing continued to surface the same underlying problem in other areas, including chat persistence, message rating, table browsing, and SQL editor actions. - **#49244** - **#49430** Rather than keep adding fallbacks, this PR removes the underlying context mismatch. ## What is the new behavior? When a support ticket is submitted from an org-level page, the "While you wait" card now links to the relevant project instead of trying to run the AI Assistant without real project context. The link opens the project with the AI Assistant sidebar and hands off the support ticket. The chat is created there, so project-scoped features like schema browsing, SQL actions, message rating, and chat persistence work natively without special-casing. If there’s no relevant project, the card isn’t shown. The ticket form also restores the "No specific project" option for cases where the auto-selected project isn't relevant. ## Additional context Also fixes ?sidebar= deep links not opening the sidebar after client-side navigation. LayoutSidebarProvider now reacts to URL param changes instead of only checking on initial load. ## How to test 1. Submit a project-related support ticket from an org-level page. 2. Confirm the "While you wait" card shows "Open Assistant in project". 3. Click it and confirm the correct project opens with the AI Assistant sidebar and support chat active. 4. Verify project-scoped features work, such as schema questions and Edit query / Run. 5. Refresh and confirm the chat persists. 6. Select "No specific project" and confirm no assistant card is shown. 7. Submit a ticket from a project support page and confirm the existing inline assistant behavior is unchanged. 8. Verify a project ?sidebar=ai-assistant deep link still opens the sidebar normally. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support handoff links when a submitted ticket belongs to another project. * Handoff links securely preserve support request details without exposing them in the URL. * Opening a valid handoff link creates and selects a support chat with the submitted request context. * **Bug Fixes** * Improved sidebar behavior when URL state changes. * Project selector validation messages now remain visible. * Invalid, expired, or mismatched handoffs now fall back to a new chat and display an error message. * Handoff details are securely handled only once and cleared after use. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
305 lines
10 KiB
TypeScript
305 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
|
|
variant="default"
|
|
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>
|
|
)
|
|
}
|