From 7880c2f079b4a37c59069a26d39feefc41feff3c Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 17 Sep 2026 13:39:30 +1000 Subject: [PATCH] fix(studio): explorer chat and notebook layout refinements (#50453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. ## 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. --------- Co-authored-by: Claude Opus 5 --- .../interfaces/Explorer/MarkdownCell.tsx | 7 +++---- .../interfaces/Explorer/QueryCell/index.tsx | 1 + .../interfaces/Explorer/QueryEditor/index.tsx | 2 +- .../__tests__/ExplorerNotebookTab.test.tsx | 7 +++++-- .../ExplorerLayout.constants.tsx | 4 ++-- .../ExplorerLayout/ExplorerNavChats.tsx | 2 +- .../ExplorerLayout/ExplorerNavHome.tsx | 4 ++-- .../ExplorerLayout/ExplorerNavNotebooks.tsx | 2 +- .../ui/AIAssistantPanel/AssistantChat.tsx | 2 +- .../ui/AIAssistantPanel/AssistantChatForm.tsx | 2 +- .../AssistantNotebookPreview.test.tsx | 2 +- .../AssistantNotebookPreview.tsx | 2 +- .../AIAssistantPanel/AssistantQueryCell.tsx | 2 +- .../ui/AIAssistantPanel/Message.Parts.tsx | 10 +++++++++- .../elements/Conversation.tsx | 20 ++++++++++++++++--- apps/studio/components/ui/SortableSection.tsx | 17 +++++++++++++++- 16 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx index 42319c09ea4..f9dc2cad062 100644 --- a/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx +++ b/apps/studio/components/interfaces/Explorer/MarkdownCell.tsx @@ -53,14 +53,13 @@ export const MarkdownCell = ({ cell, onEdit }: MarkdownCellProps) => { return ( } gripDropdownContent={} gripClassName="mt-1.5 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" > {isEditing ? ( -
+
{
(function return ( } gripDropdownContent={} gripClassName="mt-2 sm:opacity-0 group-hover:opacity-100 has-[[data-state=open]]:opacity-100 transition" diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx index f4a4f56d764..1255b081ae2 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor/index.tsx @@ -534,7 +534,7 @@ export const QueryEditor = forwardRef(funct return ( <> - + diff --git a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx index b114f5dbb43..003458f2df5 100644 --- a/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx +++ b/apps/studio/components/interfaces/Explorer/__tests__/ExplorerNotebookTab.test.tsx @@ -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() }) diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx index 9efa4538449..bfac9c16336 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerLayout.constants.tsx @@ -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)} > -
+
{searchPlaceholder} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx index 15e1e76582e..f6473b6038b 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavChats.tsx @@ -29,7 +29,7 @@ export const ExplorerNavChats = () => { return ( -
+
{chats.length === 0 ? (

{search ? 'No chats found' : 'No chats created yet'} diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx index 547e3d3470f..4b969b9f64b 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavHome.tsx @@ -77,11 +77,11 @@ export const ExplorerNavHome = ({

-

+

Recently updated

{recentItems.length === 0 ? ( -

Nothing edited yet

+

Nothing edited yet

) : ( recentItems.map((item) => { const Icon = EXPLORER_SECTIONS.find((section) => section.type === item.type)?.icon diff --git a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx index e605504e443..27861712c45 100644 --- a/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx +++ b/apps/studio/components/layouts/ExplorerLayout/ExplorerNavNotebooks.tsx @@ -75,7 +75,7 @@ export const ExplorerNavNotebooks = () => { return ( -
+
{isPending ? ( ) : notebooks.length === 0 ? ( diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx index a1f5a2b0b62..636122430ac 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChat.tsx @@ -564,7 +564,7 @@ export const AssistantChat = ({ })} {hasMessages ? ( - + {renderedMessages}
{error && ( diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx index 1f8b9edd767..e5a6c8a4c9f 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantChatForm.tsx @@ -108,7 +108,7 @@ const AssistantChatFormComponent = forwardRef( ref={formRef} {...props} onSubmit={handleSubmit} - className={cn('relative', className)} + className={cn('relative rounded-lg', className)} > {sqlSnippets && sqlSnippets.length > 0 && ( { 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 () => { diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx index 75c793a11c4..aac3e2e589d 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.tsx @@ -83,7 +83,7 @@ export const AssistantNotebookPreview = ({ expandedOverrides[getEntryKey(entry)] === true return ( -
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx index 9f4cbb9e7b0..44e847305ce 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AssistantQueryCell.tsx @@ -150,7 +150,7 @@ export const AssistantQueryCell = ({ return ( {children}
+ return ( +
+ {children} +
+ ) } const isWideMessagePart = (part: NonNullable[number]) => diff --git a/apps/studio/components/ui/AIAssistantPanel/elements/Conversation.tsx b/apps/studio/components/ui/AIAssistantPanel/elements/Conversation.tsx index 5260059ad25..74b430795ed 100644 --- a/apps/studio/components/ui/AIAssistantPanel/elements/Conversation.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/elements/Conversation.tsx @@ -10,6 +10,14 @@ type ConversationProps = Omit, 'children'> type ConversationContentProps = ComponentProps type ConversationScrollButtonProps = ComponentProps +/** + * 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) => (
{children} ) export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( - + ) export const ConversationScrollButton = ({ diff --git a/apps/studio/components/ui/SortableSection.tsx b/apps/studio/components/ui/SortableSection.tsx index 1c31fcaa49f..d1822680921 100644 --- a/apps/studio/components/ui/SortableSection.tsx +++ b/apps/studio/components/ui/SortableSection.tsx @@ -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 (
{actions}