Files
86105ca5ec feat(studio): align assistant message parts (#49351)
<img width="2252" height="1228" alt="image"
src="https://github.com/user-attachments/assets/5c1165ae-cb65-4495-97dd-427b30ceaefc"
/>


## 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?

Studio UI improvement.

## Stack context

Builds on #49350.

## What is the current behavior?

The Assistant conversation uses one outer width constraint. This leaves
query and notebook previews too narrow, separates consecutive generic
tool rows, and leaves message actions aligned to the far left.

## What is the new behavior?

- Gives Assistant query cells and notebook previews a `max-w-6xl`
container.
- Keeps text and other regular message parts at their existing
`max-w-3xl` width.
- Keeps consecutive generic tool rows such as Reasoned and Ran
load_knowledge compact.
- Aligns message action rows with regular message content.

## To test

1. In the Assistant, produce a response containing text plus a SQL query
or notebook preview. Confirm the preview is wide while regular text
remains at the normal width.
2. Produce a response that reasons and runs consecutive non-preview
tools. Confirm those rows remain close together with their separators.
3. Hover an Assistant response and confirm copy, rating, and branch
actions align with the regular message content.
4. Hover a user message and confirm edit and delete actions use the same
alignment.

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

- **Style**
- Improved AI Assistant message layout with centered, consistent content
widths.
- Expanded notebooks, SQL results, and query-related content where
additional space is helpful.
- Improved alignment and spacing for actions, tool outputs, loading
states, errors, and disclaimers.
  - Improved query editor visibility when switching between cells.
  - Loading indicators now respect reduced-motion preferences.

- **Tests**
- Added coverage for message layouts, tool grouping, and notebook
preview sizing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
2026-08-21 15:09:05 +08:00

250 lines
6.9 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { Check, Copy, Pencil, Split, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
import { useEffect, useState, type PropsWithChildren } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
cn,
Form,
FormControl,
FormField,
Popover,
PopoverContent,
PopoverTrigger,
TextArea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { ButtonTooltip } from '../ButtonTooltip'
export function MessageActions({
children,
alwaysShow = false,
}: PropsWithChildren<{ alwaysShow?: boolean }>) {
return (
<div className="w-full max-w-3xl mx-auto flex items-center gap-4 mt-2 mb-1">
<span className="h-0.5 w-5 bg-muted" />
<div className={cn('group-hover:opacity-100 transition-opacity', !alwaysShow && 'opacity-0')}>
{children}
</div>
</div>
)
}
function MessageActionsEdit({ onClick, tooltip }: { onClick: () => void; tooltip: string }) {
return (
<ButtonTooltip
variant="text"
icon={<Pencil size={14} strokeWidth={1.5} />}
onClick={onClick}
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
aria-label={tooltip}
tooltip={{
content: {
side: 'bottom',
text: tooltip,
},
}}
/>
)
}
MessageActions.Edit = MessageActionsEdit
function MessageActionsDelete({ onClick }: { onClick: () => void }) {
return (
<ButtonTooltip
variant="text"
icon={<Trash2 size={14} strokeWidth={1.5} />}
tooltip={{ content: { side: 'bottom', text: 'Delete message' } }}
onClick={onClick}
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
title="Delete message"
aria-label="Delete message"
/>
)
}
MessageActions.Delete = MessageActionsDelete
function MessageActionsBranch({ onClick }: { onClick: () => void }) {
return (
<ButtonTooltip
variant="text"
icon={<Split size={14} strokeWidth={1.5} />}
onClick={onClick}
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
title="Branch in new chat"
aria-label="Branch in new chat"
tooltip={{ content: { side: 'bottom', text: 'Branch in new chat' } }}
/>
)
}
MessageActions.Branch = MessageActionsBranch
function MessageActionsCopy({ onClick }: { onClick: (onSuccess: () => void) => void }) {
const [copied, setCopied] = useState(false)
useEffect(() => {
if (copied) setTimeout(() => setCopied(false), 1000)
}, [copied])
return (
<ButtonTooltip
variant="text"
icon={copied ? <Check size={14} strokeWidth={2} /> : <Copy size={14} strokeWidth={1.5} />}
onClick={() => onClick(() => setCopied(true))}
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
title="Copy response"
aria-label="Copy response"
tooltip={{ content: { side: 'bottom', text: 'Copy response' } }}
/>
)
}
MessageActions.Copy = MessageActionsCopy
function MessageActionsThumbsUp({
onClick,
isActive,
disabled,
}: {
onClick: () => void
isActive?: boolean
disabled?: boolean
}) {
return (
<Button
variant="text"
disabled={disabled}
icon={
<ThumbsUp
size={14}
strokeWidth={1.5}
className={cn(
isActive
? 'text-brand hover:text-brand-700'
: 'text-foreground-light hover:text-foreground'
)}
/>
}
onClick={onClick}
className={cn(
'p-1 rounded-sm transition-colors',
disabled && 'opacity-50 pointer-events-none'
)}
title="Good response"
aria-label="Good response"
/>
)
}
MessageActions.ThumbsUp = MessageActionsThumbsUp
const feedbackSchema = z.object({
reason: z.string().optional(),
})
type FeedbackFormValues = z.infer<typeof feedbackSchema>
function MessageActionsThumbsDown({
onClick,
isActive,
disabled,
}: {
onClick: (reason?: string) => void
isActive?: boolean
disabled?: boolean
}) {
const [open, setOpen] = useState(false)
const form = useForm<FeedbackFormValues>({
resolver: zodResolver(feedbackSchema),
defaultValues: { reason: '' },
mode: 'onSubmit',
})
const handleOpenChange = (newOpen: boolean) => {
if (disabled) return
// When popover closes, submit the rating if not already submitted
if (!newOpen && open && !form.formState.isSubmitSuccessful) {
onClick()
}
setOpen(newOpen)
if (!newOpen) {
form.reset()
}
}
const onSubmit = (values: FeedbackFormValues) => {
onClick(values.reason || undefined)
}
// Auto-close popover after showing thank you message
useEffect(() => {
if (form.formState.isSubmitSuccessful) {
const timer = setTimeout(() => {
setOpen(false)
}, 2000)
return () => clearTimeout(timer)
}
}, [form.formState.isSubmitSuccessful])
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="text"
disabled={disabled}
onClick={() => !disabled && setOpen(true)}
className={cn(
'p-1 rounded-sm transition-colors',
disabled && 'opacity-50 pointer-events-none'
)}
title="Bad response"
aria-label="Bad response"
>
<ThumbsDown
size={14}
strokeWidth={1.5}
className={cn(
isActive
? 'text-warning hover:text-warning-700'
: 'text-foreground-light hover:text-foreground'
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
{form.formState.isSubmitSuccessful ? (
<p className="text-sm">We appreciate your feedback!</p>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItemLayout label="What went wrong?" labelOptional="optional">
<FormControl>
<TextArea
placeholder="Describe why the response was not helpful..."
autoComplete="off"
rows={4}
autoFocus
{...field}
/>
</FormControl>
</FormItemLayout>
)}
/>
<div className="flex justify-end">
<Button variant="primary" type="submit" size="tiny">
Submit feedback
</Button>
</div>
</form>
</Form>
)}
</PopoverContent>
</Popover>
)
}
MessageActions.ThumbsDown = MessageActionsThumbsDown