mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
fix(studio): explorer chat and notebook layout refinements (#50453)
Five layout fixes across Explorer chat, notebooks, and the sidebar. ### Chat - **Conversation fade overlapped the scrollbar.** The top and bottom gradients are positioned against the conversation's padding box, which includes the scroll container's scrollbar gutter, so `inset-x-0` painted them over the scrollbar. They now stop at the conversation's content gutter, which `Conversation` owns for both the content and the fades. - **Composer background bled past the input's radius.** The form paints the surface behind the textarea but had no radius of its own, so its square corners showed outside the `rounded-lg` input. It now shares the radius. - **Message parts used two different widths.** Wide parts come down to `max-w-3xl` so every part shares a column, matching `AssistantQueryCell` and `AssistantNotebookPreview`. `isWide` / `isWideMessagePart` stay in place with both widths equal, so a part can diverge again later without rebuilding the mechanism. ### Notebooks - **Cell controls sat at the container edge.** Each cell centred itself at its own max width while the grip and add-cell button stayed at the far left of the full-width row, leaving a large gap. `SortableSection` takes a `sectionWidth` and carries its control gutter twice — once as the controls, once as padding on the other side — so the section stays centred with its controls immediately beside it. Cell widths are unchanged (prose `48rem`, query `72rem`); set them equal and the two cell types' controls line up on their own. The controls stay in flow rather than floating in an outside gutter, so on a viewport narrower than the cap the row just fills the space instead of clipping the controls into the padding. ### Sidebar - **Search icon didn't line up with the menu row icons.** The row box already sits flush with the search input's box, so rows moved from `pl-3` to `pl-2` to put their icons on the same 8px offset the search icon uses. Spacing between the input and the list now matches the 12px side padding. ### Testing `pnpm --filter studio run typecheck`, Prettier, and 378 tests across `Explorer`, `ProjectHome`, `AIAssistantPanel`, and `ExplorerLayout` pass. ESLint warning counts are unchanged from master. These were reasoned from layout rather than checked in a browser, so they're worth a look on a preview before merge. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **UI Improvements** * Updated Explorer layouts with flexible, configurable widths for notebook and query sections. * Refined navigation spacing and padding across Explorer views. * Centered and standardized AI Assistant preview, query, and message content widths. * Improved chat form styling with rounded corners. * Adjusted conversation spacing and fade overlays to avoid overlapping the scrollbar. * Preserved full-width behavior where appropriate while keeping controls aligned. * **Tests** * Updated layout tests to reflect revised width and alignment behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c15b0836d8
commit
7880c2f079
16 files changed
+63
-23
No files matched your search
@@ -53,14 +53,13 @@ export const MarkdownCell = ({ cell, onEdit }: MarkdownCellProps) => {
|
||||
return (
|
||||
<SortableSection
|
||||
id={cell._id}
|
||||
sectionWidth="48rem"
|
||||
actions={<AddCellDropdown cellId={cell._id} />}
|
||||
gripDropdownContent={<MoveCellDropdownContent cellId={cell._id} />}
|
||||
gripClassName="mt-1.5 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
|
||||
>
|
||||
{isEditing ? (
|
||||
<div
|
||||
className={cn('w-full max-w-3xl mx-auto transition', 'overflow-hidden border rounded-md')}
|
||||
>
|
||||
<div className={cn('w-full transition', 'overflow-hidden border rounded-md')}>
|
||||
<CodeEditor
|
||||
hideLineNumbers
|
||||
language="markdown"
|
||||
@@ -113,7 +112,7 @@ export const MarkdownCell = ({ cell, onEdit }: MarkdownCellProps) => {
|
||||
<div
|
||||
onDoubleClick={handleStartEditing}
|
||||
className={cn(
|
||||
'group/mdcell relative w-full max-w-3xl mx-auto px-3 py-2 transition',
|
||||
'group/mdcell relative w-full px-3 py-2 transition',
|
||||
'hover:bg-alternative/50',
|
||||
'border border-transparent rounded-md hover:border-default'
|
||||
)}
|
||||
|
||||
@@ -110,6 +110,7 @@ export const QueryCell = forwardRef<QueryEditorHandle, QueryCellProps>(function
|
||||
return (
|
||||
<SortableSection
|
||||
id={cell._id}
|
||||
sectionWidth="72rem"
|
||||
actions={<AddCellDropdown cellId={cell._id} />}
|
||||
gripDropdownContent={<MoveCellDropdownContent cellId={cell._id} />}
|
||||
gripClassName="mt-2 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition"
|
||||
|
||||
@@ -534,7 +534,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
|
||||
return (
|
||||
<>
|
||||
<Shell className={cn(variant === 'embedded' && 'mx-auto max-w-6xl', className)}>
|
||||
<Shell className={className}>
|
||||
<ExplorerToolbar className={cn(variant === 'viewport' && 'px-4')}>
|
||||
<ExplorerToolbarIcon>
|
||||
<CodeSquare size={16} strokeWidth={2} />
|
||||
|
||||
@@ -131,12 +131,15 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ExplorerNotebookTab', () => {
|
||||
it('hides SQL by default for saved notebooks and caps query cells at 6xl', () => {
|
||||
it('hides SQL by default for saved notebooks and caps query cells from their sortable row', () => {
|
||||
renderNotebookTab()
|
||||
|
||||
const queryCells = Array.from(document.querySelectorAll('[data-slot="explorer-query"]'))
|
||||
expect(queryCells).toHaveLength(2)
|
||||
queryCells.forEach((cell) => expect(cell).toHaveClass('max-w-6xl'))
|
||||
queryCells.forEach((cell) => {
|
||||
expect(cell).not.toHaveClass('max-w-6xl')
|
||||
expect(cell.closest('[style*="max-width"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('textbox', { name: 'SQL editor' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ export const EXPLORER_SECTIONS: Array<{
|
||||
|
||||
export const rowClassName = (isActive: boolean) =>
|
||||
cn(
|
||||
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-3 pr-2 text-sm',
|
||||
'group relative flex h-7 w-full items-center gap-2 rounded-md pl-2 pr-2 text-sm',
|
||||
isActive
|
||||
? 'bg-selection text-foreground'
|
||||
: 'text-foreground-light hover:bg-surface-200 hover:text-foreground'
|
||||
@@ -58,7 +58,7 @@ export const ExplorerNavResourceWrapper = ({
|
||||
transition={LEVEL_TRANSITION}
|
||||
className={cn('absolute inset-0 flex flex-col', className)}
|
||||
>
|
||||
<div className="p-3 pb-2">
|
||||
<div className="px-3 pt-3">
|
||||
<span id="explorer-sidebar-search-label" className="sr-only">
|
||||
{searchPlaceholder}
|
||||
</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ExplorerNavChats = () => {
|
||||
|
||||
return (
|
||||
<ExplorerNavResourceWrapper type="chat" search={search} setSearch={setSearch}>
|
||||
<div className="flex flex-1 flex-col gap-px overflow-y-auto px-3 pb-3">
|
||||
<div className="flex flex-1 flex-col gap-px overflow-y-auto p-3">
|
||||
{chats.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-foreground-lighter">
|
||||
{search ? 'No chats found' : 'No chats created yet'}
|
||||
|
||||
@@ -77,11 +77,11 @@ export const ExplorerNavHome = ({
|
||||
</nav>
|
||||
|
||||
<section className="flex flex-col gap-px">
|
||||
<h3 className="mb-2 px-3 font-mono text-sm font-normal uppercase text-foreground-lighter">
|
||||
<h3 className="mb-2 px-2 font-mono text-sm font-normal uppercase text-foreground-lighter">
|
||||
Recently updated
|
||||
</h3>
|
||||
{recentItems.length === 0 ? (
|
||||
<p className="px-3 text-xs text-foreground-lighter">Nothing edited yet</p>
|
||||
<p className="px-2 text-xs text-foreground-lighter">Nothing edited yet</p>
|
||||
) : (
|
||||
recentItems.map((item) => {
|
||||
const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ExplorerNavNotebooks = () => {
|
||||
|
||||
return (
|
||||
<ExplorerNavResourceWrapper type="notebook" search={search} setSearch={setSearch}>
|
||||
<div className="flex flex-1 min-h-0 flex-col px-3 pb-3">
|
||||
<div className="flex flex-1 min-h-0 flex-col p-3">
|
||||
{isPending ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : notebooks.length === 0 ? (
|
||||
|
||||
@@ -564,7 +564,7 @@ export const AssistantChat = ({
|
||||
})}
|
||||
{hasMessages ? (
|
||||
<Conversation className={cn('flex-1')}>
|
||||
<ConversationContent className="w-full px-7 py-8 mb-10">
|
||||
<ConversationContent className="w-full py-8 mb-10">
|
||||
{renderedMessages}
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
{error && (
|
||||
|
||||
@@ -108,7 +108,7 @@ const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
|
||||
ref={formRef}
|
||||
{...props}
|
||||
onSubmit={handleSubmit}
|
||||
className={cn('relative', className)}
|
||||
className={cn('relative rounded-lg', className)}
|
||||
>
|
||||
{sqlSnippets && sqlSnippets.length > 0 && (
|
||||
<SnippetRow
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('AssistantNotebookPreview', () => {
|
||||
expect(screen.getByRole('toolbar', { name: 'Notebook toolbar' })).toBeInTheDocument()
|
||||
expect(screen.getByText('2 cells')).toBeInTheDocument()
|
||||
expect(screen.getByText('New notebook')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('max-w-6xl')
|
||||
expect(container.firstElementChild).toHaveClass('max-w-3xl')
|
||||
})
|
||||
|
||||
it('surfaces a metadata-only database change after resolving the target', async () => {
|
||||
|
||||
@@ -83,7 +83,7 @@ export const AssistantNotebookPreview = ({
|
||||
expandedOverrides[getEntryKey(entry)] === true
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full min-w-0 max-w-6xl mx-auto flex-col', className)}>
|
||||
<div className={cn('flex w-full min-w-0 max-w-3xl mx-auto flex-col', className)}>
|
||||
<ExplorerToolbar aria-label="Notebook toolbar">
|
||||
<ExplorerToolbarIcon>
|
||||
<NotebookText size={16} strokeWidth={2} />
|
||||
|
||||
@@ -150,7 +150,7 @@ export const AssistantQueryCell = ({
|
||||
return (
|
||||
<Confirm
|
||||
fill
|
||||
className="w-full max-w-6xl mx-auto"
|
||||
className="w-full max-w-3xl mx-auto"
|
||||
state={confirmState}
|
||||
message="Assistant wants to run this query"
|
||||
cancelLabel="Skip"
|
||||
|
||||
@@ -301,6 +301,10 @@ const MessagePart = {
|
||||
NotebookRun: MessagePartNotebookRun,
|
||||
} as const
|
||||
|
||||
// Wide parts share the default width for now; the split stays so a part can diverge again.
|
||||
const MESSAGE_PART_WIDTH = 'max-w-3xl'
|
||||
const WIDE_MESSAGE_PART_WIDTH = 'max-w-3xl'
|
||||
|
||||
function MessagePartContainer({
|
||||
children,
|
||||
isWide = false,
|
||||
@@ -308,7 +312,11 @@ function MessagePartContainer({
|
||||
children: ReactNode
|
||||
isWide?: boolean
|
||||
}) {
|
||||
return <div className={cn('w-full mx-auto', isWide ? 'max-w-6xl' : 'max-w-3xl')}>{children}</div>
|
||||
return (
|
||||
<div className={cn('w-full mx-auto', isWide ? WIDE_MESSAGE_PART_WIDTH : MESSAGE_PART_WIDTH)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isWideMessagePart = (part: NonNullable<VercelMessage['parts']>[number]) =>
|
||||
|
||||
@@ -10,6 +10,14 @@ type ConversationProps = Omit<ComponentProps<typeof StickToBottom>, 'children'>
|
||||
type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>
|
||||
type ConversationScrollButtonProps = ComponentProps<typeof Button>
|
||||
|
||||
/**
|
||||
* Horizontal gutter the conversation reserves around its content. The fades overlay the scroll
|
||||
* container, whose scrollbar runs down the right edge, so they stop at this gutter instead of
|
||||
* spanning the full width.
|
||||
*/
|
||||
const CONTENT_GUTTER = 'px-7'
|
||||
const FADE_GUTTER = 'inset-x-7'
|
||||
|
||||
export const Conversation = ({ className, children, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn('relative flex-1 overflow-y-auto', className)}
|
||||
@@ -20,18 +28,24 @@ export const Conversation = ({ className, children, ...props }: ConversationProp
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-linear-to-b from-card to-transparent"
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-0 z-10 h-8 bg-linear-to-b from-card to-transparent',
|
||||
FADE_GUTTER
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-linear-to-t from-card to-transparent"
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 z-10 h-8 bg-linear-to-t from-card to-transparent',
|
||||
FADE_GUTTER
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
)
|
||||
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn('p-4', className)} {...props} />
|
||||
<StickToBottom.Content className={cn(CONTENT_GUTTER, 'py-4', className)} {...props} />
|
||||
)
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
|
||||
@@ -5,14 +5,21 @@ import type { CSSProperties, PropsWithChildren, ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, cn, DropdownMenu, DropdownMenuTrigger } from 'ui'
|
||||
|
||||
const GRIP_WIDTH_REM = 1.5
|
||||
const ACTIONS_WIDTH_REM = 1.75
|
||||
const CONTROL_GAP_REM = 1
|
||||
|
||||
export const SortableSection = ({
|
||||
id,
|
||||
children,
|
||||
actions,
|
||||
sectionWidth,
|
||||
gripClassName,
|
||||
gripDropdownContent,
|
||||
}: PropsWithChildren<{
|
||||
id: string
|
||||
/** Caps the section at this width and centres it, with the controls alongside it. */
|
||||
sectionWidth?: string
|
||||
gripClassName?: string
|
||||
actions?: ReactNode
|
||||
gripDropdownContent?: ReactNode
|
||||
@@ -43,18 +50,26 @@ export const SortableSection = ({
|
||||
|
||||
useEffect(() => () => clearTimeout(openTimeoutRef.current), [])
|
||||
|
||||
// Carried twice — once as the controls, once as padding — so the section stays centred.
|
||||
const gutterRem = (actions ? ACTIONS_WIDTH_REM : 0) + GRIP_WIDTH_REM + CONTROL_GAP_REM
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: transform
|
||||
? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)`
|
||||
: undefined,
|
||||
transition,
|
||||
...(sectionWidth && {
|
||||
marginInline: 'auto',
|
||||
maxWidth: `calc(${sectionWidth} + ${2 * gutterRem}rem)`,
|
||||
paddingRight: `${gutterRem}rem`,
|
||||
}),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group relative will-change-transform flex items-start gap-x-4 min-w-0"
|
||||
className="group relative will-change-transform flex w-full items-start gap-x-4 min-w-0"
|
||||
>
|
||||
<div className={cn('flex items-center', gripClassName)}>
|
||||
{actions}
|
||||
|
||||
Reference in new issue
Block a user