Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx
T
Saxon Fletcher 4ee43f3585 chore(studio): refine Explorer query UI (#49895)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

UI refinements for Explorer query surfaces.

## What is the current behavior?

- The assistant chat textarea uses a tighter radius than the Run SQL /
Create a notebook cards on Explorer home.
- Chart results sit unevenly in the results pane because axis gutters
stack on card padding, and long Y labels can clip.
- Selecting SQL in the editor changes the primary Run button to Run
selected, which is easy to trigger by accident.
- The Prettify SQL icon in notebook query cells uses Lucide's default
size, so it doesn't match other toolbar actions.

## What is the new behavior?

- Assistant chat form uses `rounded-lg` so it matches the home action
cards everywhere the form is used.
- Query result charts collapse unused axis space, add a little padding
when labels are on, and size the Y axis from formatted ticks so longer
labels fit.
- Run is a default split button that always executes the full query. Run
selected is a secondary menu item, disabled until SQL is selected.
- Notebook cell Prettify SQL icons use `size={16}` and `strokeWidth={2}`
like the rest of the Explorer toolbar.

## Additional context

Cmd+Enter in the editor still runs the current selection when there is
one.

## Test plan

- [ ] Open Explorer home and confirm the assistant chat radius matches
the Run SQL and Create a notebook cards.
- [ ] Run a query, switch to chart view, and check spacing with labels
off and on, including large Y values.
- [ ] With no selection, click Run and confirm the full query runs. Open
the split menu and confirm Run selected is disabled.
- [ ] Select SQL, click Run, and confirm the full query still runs. Use
Run selected from the menu to run only the selection.
- [ ] In a notebook query cell, confirm Prettify SQL matches the size
and stroke of nearby toolbar icons.


Made with [Cursor](https://cursor.com)

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

## Summary by CodeRabbit

- **New Features**
- Added a split Run control in the query editor, with separate actions
for running all content or only selected text.
  - Added support for customizing chart X-axis display settings.
- Improved chart Y-axis sizing, scaling, and tick formatting for clearer
results.

- **Bug Fixes**
  - The “Run selected” action is unavailable when no text is selected.

- **Style**
- Updated toolbar icon sizing and added rounded corners to the assistant
chat input.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-03 09:44:45 +10:00

186 lines
6.2 KiB
TypeScript

import { useBreakpoint } from 'common'
import { ArrowUp, Loader2, Square } from 'lucide-react'
import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, useRef } from 'react'
import { cn, ExpandingTextArea } from 'ui'
import { ButtonTooltip } from '../ButtonTooltip'
import { formatAttachedSnippets } from './AIAssistant.utils'
import { ModelSelector } from './ModelSelector'
import { SnippetRow } from './SnippetRow'
import type { AssistantModelId } from '@/lib/ai/model.utils'
import { type SqlSnippet } from '@/state/ai-assistant-state'
export interface FormProps {
/* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
textAreaRef?: React.RefObject<HTMLTextAreaElement | null>
/* The loading state of the form */
loading: boolean
/* The disabled state of the form */
disabled?: boolean
/* The value of the textarea */
value?: string
/* The function to handle the value change */
onValueChange: (value: ChangeEvent<HTMLTextAreaElement>) => void
/**
* If true, include SQL snippets in the message sent to onSubmit
*/
includeSnippetsInMessage?: boolean
/**
* The function to handle the form submission
*/
onSubmit: (message: string) => void
/**
* The function to handle stopping the stream
*/
onStop?: () => void
/* The placeholder of the textarea */
placeholder?: string
/* SQL snippets to display above the form - can be strings or objects with label and content */
sqlSnippets?: SqlSnippet[]
/* Function to handle removing a SQL snippet */
onRemoveSnippet?: (index: number) => void
/* Additional class name for the snippets container */
snippetsClassName?: string
/* Additional class name for the form wrapper */
className?: string
/* If currently editing an existing message */
isEditing?: boolean
/* The currently selected AI model */
selectedModel?: AssistantModelId
/* Callback when a model is chosen */
onSelectModel?: (model: AssistantModelId) => void
}
const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
(
{
loading = false,
disabled = false,
value = '',
textAreaRef,
onValueChange,
onSubmit,
onStop,
placeholder,
sqlSnippets,
onRemoveSnippet,
snippetsClassName,
includeSnippetsInMessage = false,
className,
isEditing = false,
selectedModel,
onSelectModel,
...props
},
_ref
) => {
const formRef = useRef<HTMLFormElement>(null)
const isMobile = useBreakpoint('md')
const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
if (event) event.preventDefault()
if (disabled || !value || (loading && !isEditing)) return
let finalMessage = value
if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
finalMessage = [value, formatAttachedSnippets(sqlSnippets)].filter(Boolean).join('\n\n')
}
onSubmit(finalMessage)
}
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit()
} else if (event.key === 'Escape') {
event.currentTarget.blur()
}
}
const canSubmit = !disabled && !loading && !!value
const showModelSelector = selectedModel !== undefined && onSelectModel !== undefined
return (
<div className="w-full">
<form
id="assistant-chat"
ref={formRef}
{...props}
onSubmit={handleSubmit}
className={cn('relative', className)}
>
{sqlSnippets && sqlSnippets.length > 0 && (
<SnippetRow
snippets={sqlSnippets}
onRemoveSnippet={onRemoveSnippet}
className="absolute top-0 left-0 right-0 px-1.5 py-1.5"
/>
)}
<ExpandingTextArea
autoFocus={!isMobile}
ref={textAreaRef}
disabled={disabled}
className={cn(
'text-base md:text-sm pr-10 pb-9 max-h-64 rounded-lg',
sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
)}
placeholder={placeholder}
spellCheck={false}
rows={3}
value={value}
onChange={(event) => onValueChange(event)}
onKeyDown={handleKeyDown}
/>
<div
className={cn(
'absolute inset-x-1.5 bottom-1.5 flex items-center pointer-events-none',
showModelSelector ? 'justify-between' : 'justify-end'
)}
>
{showModelSelector && (
<div className="pointer-events-auto">
<ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
</div>
)}
<div className="flex gap-3 items-center pointer-events-auto">
{loading ? (
onStop ? (
<ButtonTooltip
variant="outline"
aria-label="Stop response"
icon={<Square fill="currentColor" className="scale-75" />}
onClick={onStop}
className="w-7 h-7 rounded-full p-0 text-center flex items-center justify-center"
tooltip={{ content: { side: 'top', text: 'Stop response' } }}
/>
) : (
<Loader2 size={22} className="animate-spin size-7 text-muted" strokeWidth={1} />
)
) : (
<ButtonTooltip
type="submit"
variant={canSubmit ? 'primary' : 'default'}
aria-label="Send message"
icon={<ArrowUp />}
disabled={!canSubmit}
className={cn(
'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
!canSubmit ? 'opacity-50' : 'opacity-100'
)}
tooltip={{ content: { side: 'top', text: 'Send message' } }}
/>
)}
</div>
</div>
</form>
</div>
)
}
)
AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
export const AssistantChatForm = memo(AssistantChatFormComponent)