chore(studio): refine assistant empty states (#50191)

<img width="1062" height="712" alt="image"
src="https://github.com/user-attachments/assets/44c82e00-94b4-405a-b2ef-cbc08401c4db"
/>

<img width="1583" height="962" alt="image"
src="https://github.com/user-attachments/assets/b19b9fd8-24b1-48d6-8b31-11fba9911b9a"
/>


## What kind of change does this PR introduce?
UI refinement.

## What is the current behavior?
The assistant sidebar and Explorer chat use different empty states.

## What is the new behavior?
Share a centered empty state with chat-focused prompts, use-case icons,
and a “Use your own agent” footer. Simplify prompt cards and align
Explorer Home styling.

## Additional context
Studio typecheck, targeted ESLint, and Prettier pass. E2E selectors
updated; browser verification pending. Local build stopped after showing
no progress during compilation.

I have read CONTRIBUTING.md.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Redesigned Explorer and AI Assistant onboarding with project-focused
chat prompts.
* Added personalized chat template icons and updated suggested actions.
* Added an option to connect and use an external agent through Claude,
OpenAI, or Cursor.
  * Improved chat composer placement and empty-chat guidance.
* **Style**
* Action cards now support layouts without descriptions and adjust
alignment automatically.
* **Tests**
  * Updated end-to-end coverage for the revised assistant interface.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saxon Fletcher authored and GitHub committed 2026-09-10 13:39:21 +08:00
1 parent 1131e3e2ce
commit c33d255eba
8 files changed
+236 -304

No files matched your search

@@ -1,9 +1,11 @@
import { MessageSquarePlus, NotebookText, SquareCode } from 'lucide-react'
import { NotebookText, SquareCode } from 'lucide-react'
import { useState } from 'react'
import { useCreateChat, useCreateNotebook, useCreateQuery } from './hooks'
import { CHAT_TEMPLATES, NOTEBOOK_TEMPLATES } from './templates'
import { NOTEBOOK_TEMPLATES } from './templates'
import { ActionCard } from '@/components/layouts/Tabs/ActionCard'
import { CHAT_TEMPLATES } from '@/components/ui/AIAssistantPanel/AIAssistant.prompts'
import { AssistantAgentHarnessFooter } from '@/components/ui/AIAssistantPanel/AssistantAgentHarnessFooter'
import { AssistantChatForm } from '@/components/ui/AIAssistantPanel/AssistantChatForm'
export const ExplorerHomeTab = () => {
@@ -16,21 +18,19 @@ export const ExplorerHomeTab = () => {
return (
<div className="bg-surface-100 h-full flex flex-col items-center justify-center px-10">
<div className="w-full max-w-2xl">
<div className="flex items-center justify-center flex-col gap-y-1 mb-12">
<h1 className="heading-section">Explore your project</h1>
<p className="text-foreground-lighter text-sm">
Ask the Assistant about your data, or begin with a new resource.
</p>
<div className="text-center mb-6">
<h1 className="heading-section">Run SQL. Chat with your project. Create a Notebook</h1>
</div>
<AssistantChatForm
loading={false}
className="bg"
placeholder="Ask anything about your project"
placeholder="Explore your data, check project health, create a notebook..."
value={value}
onValueChange={(e) => setValue(e.target.value)}
onSubmit={(message) => createChat({ initialMessage: message })}
/>
<AssistantAgentHarnessFooter />
<section className="mt-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -70,9 +70,8 @@ export const ExplorerHomeTab = () => {
{CHAT_TEMPLATES.map((template) => (
<ActionCard
key={template.title}
icon={<MessageSquarePlus className="h-4 w-4 text-foreground" strokeWidth={1.5} />}
icon={<template.icon className="h-4 w-4 text-foreground" strokeWidth={1.5} />}
title={template.title}
description={template.description}
bgColor="bg-blue-500"
onClick={() =>
createChat({ name: template.title, initialMessage: template.initialMessage })
@@ -1,30 +1,6 @@
import { createLogCellSkeleton, createMarkdownCellSkeleton, createQueryCellSkeleton } from './utils'
import type { Notebooks } from '@/types'
export type ChatTemplate = {
title: string
description: string
initialMessage: string
}
export const CHAT_TEMPLATES: ChatTemplate[] = [
{
title: 'Generate sample data',
description: 'Chat template',
initialMessage: 'Generate sample data for a blog with users, posts, and comments tables.',
},
{
title: 'Set up RLS policies',
description: 'Chat template',
initialMessage: 'Create RLS policies to ensure users can only access their own data.',
},
{
title: 'Build a notebook',
description: 'Chat template',
initialMessage: 'Build me a notebook that tracks weekly signups and active users.',
},
]
export type NotebookTemplate = {
title: string
description: string
@@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import { Card } from 'ui'
import { Card, cn } from 'ui'
export const ActionCard = (card: {
icon: ReactNode
title: string
description: string
description?: string
bgColor: string
onClick?: () => void
}) => {
@@ -13,7 +13,7 @@ export const ActionCard = (card: {
className="grow bg-surface-100 p-3 transition-colors hover:bg-surface-200 border hover:border-default cursor-pointer"
onClick={card.onClick}
>
<div className={`relative flex items-start gap-3`}>
<div className={cn('relative flex gap-3', card.description ? 'items-start' : 'items-center')}>
<div
className={`rounded-full ${card.bgColor} w-8 h-8 flex items-center justify-center shrink-0`}
>
@@ -21,7 +21,7 @@ export const ActionCard = (card: {
</div>
<div className="flex flex-col gap-0">
<h3 className="text-sm text-foreground mb-0">{card.title}</h3>
<p className="text-xs text-foreground-light">{card.description}</p>
{card.description && <p className="text-xs text-foreground-light">{card.description}</p>}
</div>
</div>
</Card>
@@ -1,42 +1,51 @@
export const defaultPrompts = [
import {
BookOpen,
Bug,
Database,
Gauge,
NotebookText,
ShieldCheck,
type LucideIcon,
} from 'lucide-react'
export type ChatTemplate = {
title: string
icon: LucideIcon
initialMessage: string
}
export const CHAT_TEMPLATES: ChatTemplate[] = [
{
title: 'Create a back-end',
prompt:
'Create a messaging app with users, messages, and an edge function that uses OpenAI to summarize message threads.',
},
{
title: 'Health check',
prompt: 'Can you check if my database and edge functions are healthy?',
},
{
title: 'Query your data',
prompt: 'Give me a list of new users from the auth.users table who signed up in the past week',
title: 'Generate sample data',
icon: Database,
initialMessage: 'Generate sample data for a blog with users, posts, and comments tables.',
},
{
title: 'Set up RLS policies',
prompt: 'Create RLS policies to ensure users can only access their own data',
icon: ShieldCheck,
initialMessage: 'Create RLS policies to ensure users can only access their own data.',
},
{
title: 'Create a function',
prompt: 'Create an edge function that summarises the contents of a table row using OpenAI',
},
{
title: 'Generate sample data',
prompt: 'Generate sample data for a blog with users, posts, and comments tables',
title: 'Build a notebook',
icon: NotebookText,
initialMessage: 'Build me a notebook that tracks weekly signups and active users.',
},
]
export const codeSnippetPrompts = [
{
title: 'Explain code',
icon: BookOpen,
prompt: 'Explain what this code does and how it works',
},
{
title: 'Improve code',
icon: Gauge,
prompt: 'How can I improve this code for better performance and readability?',
},
{
title: 'Debug issues',
icon: Bug,
prompt: 'Help me debug any potential issues with this code',
},
]
@@ -1,15 +1,12 @@
import { useParams } from 'common'
import { motion } from 'framer-motion'
import { BarChart, FileText, Shield } from 'lucide-react'
import { AiIconAnimation, Button, Skeleton } from 'ui'
import { MessageSquarePlus } from 'lucide-react'
import type { ReactNode } from 'react'
import { codeSnippetPrompts, defaultPrompts } from './AIAssistant.prompts'
import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.utils'
import { useProjectLintsQuery, type Lint } from '@/data/lint/lint-query'
import { CHAT_TEMPLATES, codeSnippetPrompts } from './AIAssistant.prompts'
import { ActionCard } from '@/components/layouts/Tabs/ActionCard'
import type { SqlSnippet } from '@/state/ai-assistant-state'
interface AIOnboardingProps {
children: ReactNode
sqlSnippets?: SqlSnippet[]
suggestions?: {
title?: string
@@ -20,161 +17,64 @@ interface AIOnboardingProps {
}
export const AIOnboarding = ({
children,
sqlSnippets,
suggestions,
onValueChange,
onFocusInput,
}: AIOnboardingProps) => {
const prompts = suggestions?.prompts
? suggestions.prompts.map((suggestion) => ({
title: suggestion.label,
prompt: suggestion.description,
icon: <FileText strokeWidth={1.25} size={14} className="w-4! h-4!" />,
}))
: sqlSnippets && sqlSnippets.length > 0
? codeSnippetPrompts
: defaultPrompts
const suggestionPrompts = suggestions?.prompts ?? []
const hasSuggestions = suggestionPrompts.length > 0
let templates = CHAT_TEMPLATES
const { ref: projectRef } = useParams()
const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery({ projectRef })
const errorLints: Lint[] = (lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ??
[]) as Lint[]
const securityErrorLints = errorLints.filter((lint) => lint.categories?.[0] === 'SECURITY')
const performanceErrorLints = errorLints.filter((lint) => lint.categories?.[0] !== 'SECURITY')
if (hasSuggestions) {
templates = suggestionPrompts.map((suggestion) => ({
title: suggestion.label,
icon: MessageSquarePlus,
initialMessage: suggestion.description,
}))
} else if (sqlSnippets && sqlSnippets.length > 0) {
templates = codeSnippetPrompts.map((prompt) => ({
title: prompt.title,
icon: prompt.icon,
initialMessage: prompt.prompt,
}))
}
return (
<div className="flex-1 overflow-y-auto max-w-3xl mx-auto w-full">
<div className="w-full flex-1 max-h-full min-h-full px-4 flex flex-col gap-0">
<div className="mt-auto w-full space-y-6 py-8 ">
<motion.h2
className="heading-section text-foreground mx-4 flex items-center gap-x-3"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3 }}
>
How can I assist you?
<AiIconAnimation size={18} allowHoverEffect={false} />
</motion.h2>
{suggestions?.prompts?.length ? (
<div>
<h3 className="heading-meta text-foreground-light mb-3 mx-4">Suggestions</h3>
{prompts.map((item, index) => (
<motion.div
key={item.title}
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Button
size="small"
variant="text"
className="w-full justify-start border-b hover:border-b-0 hover:rounded-md rounded-none"
icon={
<FileText strokeWidth={1.5} size={14} className="text-foreground-light" />
}
onClick={() => {
onValueChange(item.prompt)
onFocusInput?.()
}}
>
{item.title}
</Button>
</motion.div>
))}
</div>
) : (
<>
{isLoadingLints ? (
<div className="px-4 flex flex-col gap-2">
<Skeleton className="h-5 w-20" />
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={`loader-${index}`} className="h-7 w-full" />
))}
</div>
) : (
<>
{performanceErrorLints.length > 0 && (
<div className="mb-4">
<h3 className="heading-meta text-foreground-light mb-3 mx-4">
Improve Performance
</h3>
{performanceErrorLints.map((lint, index) => {
return (
<Button
key={`${lint.name}-${index}`}
size="small"
variant="text"
className="w-full justify-start"
icon={
<BarChart
strokeWidth={1.5}
size={14}
className="text-foreground-light"
/>
}
onClick={() => {
onValueChange(createLintSummaryPrompt(lint))
onFocusInput?.()
}}
>
{lint.detail ? lint.detail.replace('\\`', '') : lint.title}
</Button>
)
})}
</div>
)}
{securityErrorLints.length > 0 && (
<div className="mb-4">
<h3 className="heading-meta text-foreground-light mb-3 mx-4">
Improve Security
</h3>
{securityErrorLints.map((lint, index) => {
return (
<Button
key={`${lint.name}-${index}`}
size="small"
variant="text"
className="w-full justify-start"
icon={<Shield strokeWidth={1.5} size={14} className="text-warning" />}
onClick={() => {
onValueChange(createLintSummaryPrompt(lint))
onFocusInput?.()
}}
>
{lint.detail ? lint.detail.replace(/\\`/g, '') : lint.title}
</Button>
)
})}
</div>
)}
<div>
<h3 className="heading-meta text-foreground-light mb-3 mx-4">Ideas</h3>
{prompts.map((item, index) => (
<Button
key={`${item.title}-${index}`}
size="small"
variant="text"
className="w-full justify-start"
icon={
<FileText strokeWidth={1.5} size={14} className="text-foreground-light" />
}
onClick={() => {
onValueChange(item.prompt)
onFocusInput?.()
}}
>
{item.title}
</Button>
))}
</div>
</>
)}
</>
)}
<div className="@container flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="m-auto w-full max-w-2xl">
<div className="mb-6 text-center">
<h2 className="heading-section">Chat with your project</h2>
</div>
{children}
<section className="mt-8 flex flex-col gap-y-3">
{hasSuggestions && suggestions?.title && (
<h3 className="text-sm font-medium text-foreground">{suggestions.title}</h3>
)}
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-3">
{templates.map((template) => (
<button
key={template.title}
type="button"
tabIndex={0}
className="rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
onClick={() => {
onValueChange(template.initialMessage)
onFocusInput?.()
}}
>
<ActionCard
icon={<template.icon className="h-4 w-4 text-foreground" strokeWidth={1.5} />}
title={template.title}
bgColor="bg-brand-400"
/>
</button>
))}
</div>
</section>
</div>
</div>
)
@@ -0,0 +1,30 @@
import { useTheme } from 'next-themes'
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
import { ConnectionIcon } from 'ui-patterns/McpUrlBuilder/components/ConnectionIcon'
export const AssistantAgentHarnessFooter = () => {
const { resolvedTheme } = useTheme()
const theme = resolvedTheme === 'dark' ? 'dark' : 'light'
const [, setConnectParams] = useQueryStates({
showConnect: parseAsBoolean.withDefault(false),
connectTab: parseAsString,
})
return (
<div className="mx-3">
<button
type="button"
tabIndex={0}
className="flex w-full shrink-0 cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-b-lg border-x border-b bg-surface-100 py-2 pr-2 pl-3 text-left text-xs text-foreground-light shadow-xs transition-colors hover:bg-surface-200 hover:border-default"
onClick={() => setConnectParams({ showConnect: true, connectTab: 'mcp' })}
>
<span className="min-w-0 flex-1">Use your own agent</span>
<span className="flex shrink-0 items-center gap-3" aria-hidden="true">
<ConnectionIcon connection="claude" theme={theme} />
<ConnectionIcon connection="openai" theme={theme} hasDistinctDarkIcon />
<ConnectionIcon connection="cursor" theme={theme} hasDistinctDarkIcon />
</span>
</button>
</div>
)
}
@@ -21,6 +21,7 @@ import {
resolvePendingToolApprovalsAsDenied,
} from './AIAssistant.utils'
import { AIOnboarding } from './AIOnboarding'
import { AssistantAgentHarnessFooter } from './AssistantAgentHarnessFooter'
import { AssistantChatForm } from './AssistantChatForm'
import {
Conversation,
@@ -445,9 +446,95 @@ export const AssistantChat = ({
} else if (isSupportChat) {
placeholder = 'Describe your support issue...'
} else {
placeholder = 'Chat to Postgres...'
placeholder = 'Ask about your data, troubleshoot an issue, or explore your project...'
}
const composer = (
<div className={cn('relative z-20 w-full', hasMessages && 'px-7 pb-3')}>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-y-3">
{isSupportChat && !isSupportChatClosed && (
<div>
<div className="mb-3 border-t" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'escalated')}
>
Escalate to human
</Button>
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'user_resolved')}
>
Resolve
</Button>
</div>
</div>
)}
{disablePrompts && (
<Admonition
showIcon={false}
type="default"
title="Assistant has been temporarily disabled"
description="We're currently looking into getting it back online"
/>
)}
{isSuccess && !isApiKeySet && (
<Admonition
type="default"
title="OpenAI API key not set"
description={
<Markdown
content={
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
}
/>
}
/>
)}
<div>
<AssistantChatForm
textAreaRef={inputRef}
className={cn('z-20', !hasMessages && 'bg')}
loading={isChatLoading}
isEditing={!!editingMessageId}
disabled={isChatInputDisabled}
placeholder={placeholder}
value={value}
onValueChange={(e) => {
setValue(e.target.value)
onInputChange?.(e.target.value)
}}
onSubmit={(finalMessage) => {
sendMessageToAssistant(finalMessage)
}}
onStop={() => {
stop()
// to save partial responses from the AI
const lastMessage = chatMessages[chatMessages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
state.updateMessage(lastMessage, chatId)
}
}}
sqlSnippets={composerContext?.sqlSnippets}
onRemoveSnippet={(index) => {
const newSnippets = [...(composerContext?.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
composerContext?.onSetSqlSnippets?.(newSnippets)
}}
includeSnippetsInMessage={includeSnippetsInMessage}
/>
</div>
</div>
</div>
)
return (
<ErrorBoundary
message="Something went wrong with the AI Assistant"
@@ -546,15 +633,7 @@ export const AssistantChat = ({
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : (
<AIOnboarding
key={chatId}
sqlSnippets={composerContext?.sqlSnippets}
suggestions={composerContext?.suggestions}
onValueChange={(val) => setValue(val)}
onFocusInput={() => inputRef.current?.focus()}
/>
)}
) : null}
<AnimatePresence>
{editingMessageId && (
@@ -600,87 +679,23 @@ export const AssistantChat = ({
)}
</AnimatePresence>
<div className="relative z-20 w-full px-7 pb-3">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-y-3">
{isSupportChat && !isSupportChatClosed && (
<div>
<div className="mb-3 border-t" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'escalated')}
>
Escalate to human
</Button>
<Button
variant="outline"
size="tiny"
disabled={!supportConversationId}
onClick={() => state.setSupportLifecycleStatus(chatId, 'user_resolved')}
>
Resolve
</Button>
</div>
</div>
)}
{disablePrompts && (
<Admonition
showIcon={false}
type="default"
title="Assistant has been temporarily disabled"
description="We're currently looking into getting it back online"
/>
)}
{isSuccess && !isApiKeySet && (
<Admonition
type="default"
title="OpenAI API key not set"
description={
<Markdown
content={
'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
}
/>
}
/>
)}
<AssistantChatForm
textAreaRef={inputRef}
className="z-20"
loading={isChatLoading}
isEditing={!!editingMessageId}
disabled={isChatInputDisabled}
placeholder={placeholder}
value={value}
onValueChange={(e) => {
setValue(e.target.value)
onInputChange?.(e.target.value)
}}
onSubmit={(finalMessage) => {
sendMessageToAssistant(finalMessage)
}}
onStop={() => {
stop()
// to save partial responses from the AI
const lastMessage = chatMessages[chatMessages.length - 1]
if (lastMessage && lastMessage.role === 'assistant') {
state.updateMessage(lastMessage, chatId)
}
}}
sqlSnippets={composerContext?.sqlSnippets}
onRemoveSnippet={(index) => {
const newSnippets = [...(composerContext?.sqlSnippets ?? [])]
newSnippets.splice(index, 1)
composerContext?.onSetSqlSnippets?.(newSnippets)
}}
includeSnippetsInMessage={includeSnippetsInMessage}
/>
</div>
</div>
{hasMessages ? (
composer
) : (
<AIOnboarding
key={chatId}
sqlSnippets={composerContext?.sqlSnippets}
suggestions={composerContext?.suggestions}
onValueChange={(prompt) => {
setValue(prompt)
onInputChange?.(prompt)
}}
onFocusInput={() => inputRef.current?.focus()}
>
{composer}
{!isSupportChat && <AssistantAgentHarnessFooter />}
</AIOnboarding>
)}
</div>
</ErrorBoundary>
)
+5 -2
View File
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
@@ -16,10 +17,12 @@ test.describe('AI Assistant', async () => {
await page.locator('#assistant-trigger').click()
// Wait for the assistant panel to be visible
await expect(page.getByRole('heading', { name: 'How can I assist you?' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Chat with your project' })).toBeVisible()
// Type "hello" in the chat input
const chatInput = page.getByRole('textbox', { name: 'Chat to Postgres...' })
const chatInput = page.getByRole('textbox', {
name: 'Ask about your data, troubleshoot an issue, or explore your project...',
})
await chatInput.fill('hello')
const responsePromise = page.waitForResponse(