Files
Monica KhouryandClaude Sonnet 5 d5ee11bea0 fix: hand off AI assistant to the project page in org view (#49477)
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>
2026-08-24 21:37:25 +03:00

180 lines
6.9 KiB
TypeScript

import type { UIMessage as MessageType } from '@ai-sdk/react'
import { useParams } from 'common/hooks'
import { useRouter } from 'next/router'
import { useEffect, useEffectEvent } from 'react'
import { toast } from 'sonner'
import { AIAssistantHeader } from './AIAssistantHeader'
import { AssistantChat } from './AssistantChat'
import { resolveSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
import {
ASSISTANT_HANDOFF_QUERY_PARAM,
buildSupportAssistantPrompt,
consumeAssistantHandoff,
} from '@/components/interfaces/Support/SupportAssistant.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import type { SqlSnippet } from '@/state/ai-assistant-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
interface AIAssistantProps {
initialMessages?: MessageType[] | undefined
className?: string
}
type CurrentQuerySnippet = Exclude<SqlSnippet, string>
const isSameSnippet = (snippet: SqlSnippet, currentQuery: CurrentQuerySnippet) =>
typeof snippet !== 'string' &&
snippet.label === currentQuery.label &&
snippet.content === currentQuery.content &&
snippet.source === currentQuery.source
export const AIAssistant = ({ className }: AIAssistantProps) => {
const router = useRouter()
const { id: entityId, source: sourceParam, ref: routeRef } = useParams()
const snap = useAiAssistantStateSnapshot()
const state = useAiAssistantState()
const { snippets } = useSqlEditorV2StateSnapshot()
const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
const shortcutsEnabled = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT
const handleNewChat = () => state.newChat()
useShortcut(SHORTCUT_IDS.AI_ASSISTANT_NEW_CHAT, handleNewChat, {
enabled: shortcutsEnabled,
})
const isInSQLEditor = router.pathname.includes('/sql/[id]')
const snippet = snippets[entityId ?? '']
const snippetContent = snippet?.snippet?.content?.unchecked_sql
const openSnippetSource = isInSQLEditor
? resolveSnippetSource(snippet?.snippet, sourceParam)
: undefined
const processAssistantHandoff = useEffectEvent((handoffToken: string) => {
const request = consumeAssistantHandoff(handoffToken)
// A handoff link binds its target project to `request.projectRef`. Reject (and still
// clean up) any handoff that ends up on a different project's page — a stale link, or
// one edited/replayed by hand — rather than creating a chat tagged with the wrong project.
if (request && request.projectRef === routeRef) {
const newChatId = state.newChat({
name: 'Support request',
initialMessage: buildSupportAssistantPrompt(request),
})
const chat = state.chats[newChatId]
if (chat) {
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,
frontConversationId: request.frontConversationId,
threadRef: request.threadRef,
isSupportChat: true,
lifecycleStatus: 'bot_active',
lastSyncedMessageCount: 0,
isSyncing: false,
isLifecycleSyncing: false,
}
void import('@/state/ai-chat-front-sync')
.then(({ syncSupportChatToFront }) => syncSupportChatToFront(newChatId, state))
.catch(() => {})
}
state.selectChat(newChatId)
} else {
// The token didn't resolve (e.g. the link was opened in a new tab, so this tab's
// sessionStorage never had it) or belonged to a different project. Start a new chat
// rather than silently leaving whichever chat happened to be active for this project —
// landing on an unrelated older conversation with no indication anything went wrong
// is worse than an empty one.
state.newChat()
toast.error("Couldn't load your ticket context here — started a new chat instead.")
}
const { [ASSISTANT_HANDOFF_QUERY_PARAM]: _handledParam, ...restQuery } = router.query
router.replace({ pathname: router.pathname, query: restQuery }, undefined, { shallow: true })
})
// Picks up a support chat handed off from an org-level support page. Waits for
// `isInitialized` since the assistant state's IndexedDB restore overwrites
// `state.chats`/`activeChatId` wholesale once it resolves, which would wipe out a chat
// created here if this ran first.
useEffect(() => {
if (!snap.isInitialized) return
const handoffToken = router.query[ASSISTANT_HANDOFF_QUERY_PARAM]
if (typeof handoffToken !== 'string') return
processAssistantHandoff(handoffToken)
}, [snap.isInitialized, router.query[ASSISTANT_HANDOFF_QUERY_PARAM]])
useEffect(() => {
if (!shortcutsEnabled || !isInSQLEditor || !snippetContent) return
const currentQuery = {
label: 'Current Query',
content: snippetContent,
source: openSnippetSource,
}
state.setSqlSnippets([currentQuery])
return () => {
const currentSnippets = state.sqlSnippets
const remainingSnippets = currentSnippets?.filter(
(snippet) => !isSameSnippet(snippet, currentQuery)
)
if (currentSnippets && remainingSnippets?.length !== currentSnippets.length) {
state.setSqlSnippets(remainingSnippets ?? [])
}
}
}, [shortcutsEnabled, isInSQLEditor, snippetContent, openSnippetSource, state])
if (!snap.activeChatId) return null
return (
<AssistantChat
chatId={snap.activeChatId}
className={className}
shortcutsEnabled={shortcutsEnabled}
onNewChat={handleNewChat}
onSelectChat={(chatId) => state.selectChat(chatId)}
onBranchChat={(messageId) => state.branchChat(messageId)}
composerContext={{
initialInput: snap.initialInput,
sqlSnippets: snap.sqlSnippets as SqlSnippet[] | undefined,
suggestions: snap.suggestions
? {
title: snap.suggestions.title,
prompts: snap.suggestions.prompts?.map((prompt) => ({ ...prompt })),
}
: undefined,
onSetSqlSnippets: state.setSqlSnippets,
onClearSqlSnippets: state.clearSqlSnippets,
}}
renderHeader={(props) => (
<AIAssistantHeader
{...props}
shortcutsEnabled={shortcutsEnabled}
onNewChat={handleNewChat}
onCloseAssistant={() => closeSidebar(SIDEBAR_KEYS.AI_ASSISTANT)}
/>
)}
/>
)
}
export { SupportAssistantSuccessCardContent } from './SupportAssistantSuccessCardContent'