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:
Saxon FletcherandClaude Opus 5 authored and GitHub committed 2026-09-17 11:39:30 +08:00
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 = ({
+16 -1
View File
@@ -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}