feat: better pane management and toolbar api

This commit is contained in:
Brooklyn Nicholson
2026-05-02 15:22:18 -05:00
parent a66303eaef
commit 6dcf5bcbc0
17 changed files with 772 additions and 348 deletions
+47 -19
View File
@@ -17,15 +17,17 @@ import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls } from './titlebar-controls'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
children: ReactNode
inspectorWidth: string
leftTitlebarTools?: readonly TitlebarTool[]
previewWidth: string
rightRailOpen: boolean
settingsOpen: boolean
sidebar: ReactNode
titlebarActions?: ReactNode
titlebarTools?: readonly TitlebarTool[]
onOpenSettings: () => void
overlays?: ReactNode
}
@@ -33,10 +35,12 @@ interface AppShellProps {
export function AppShell({
children,
inspectorWidth,
leftTitlebarTools,
previewWidth,
rightRailOpen,
settingsOpen,
sidebar,
titlebarActions,
titlebarTools,
onOpenSettings,
overlays
}: AppShellProps) {
@@ -50,22 +54,36 @@ export function AppShell({
// and draggable hit-zones are fixed overlays, so keeping an invisible grid
// column for a closed sidebar pushes/clips the actual chat surface.
const displayedSidebarWidth = sidebarOpen ? sidebarWidth : 0
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition)
const titlebarContentInset = sidebarOpen
? 0
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
const showRightRail = rightRailOpen && (inspectorOpen || Boolean(previewTarget))
// Right rail yields to chat min-width before the chat column starts crushing the composer.
const inspectorColumn = showRightRail
? 'min(var(--inspector-width), max(0px, calc(100vw - var(--sidebar-width) - var(--chat-min-width) - 2 * var(--shell-gap))))'
const showPreviewRail = rightRailOpen && Boolean(previewTarget)
const showInspectorRail = rightRailOpen && inspectorOpen
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
// Preview yields first because it is the widest rail; keep chat usable before
// letting the webview consume horizontal space.
const previewColumn = showPreviewRail
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))`
: '0px'
// Always keep the shell as 3 columns because the sidebar and chat are
// always rendered as grid children. Collapsing to a single grid column
// makes the hidden sidebar occupy row 1 and pushes chat into row 2, which
// looks like a blank/white screen when closing the preview with sidebars
// hidden. Centering is handled by setting closed side columns to 0px.
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--inspector-col)'
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
const previewToolbarGap = showPreviewRail
? 'max(0px, calc(var(--shell-right-sidebar-width) - (3 * var(--titlebar-control-size)) + 0.2rem))'
: '0px'
// Always keep the shell as fixed columns because sidebar/chat/preview/inspector
// are always rendered as grid children. Hidden rails collapse to 0px so they
// don't float over the chat surface or reorder into a new row.
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -106,42 +124,52 @@ export function AppShell({
open={sidebarOpen}
style={
{
'--inspector-width': inspectorWidth,
'--preview-width': previewWidth,
'--sidebar-width': `${displayedSidebarWidth}px`,
'--chat-center-offset': '0px',
'--shell-left-sidebar-width': `${displayedSidebarWidth}px`,
'--shell-preview-pane-width': previewColumn,
'--shell-right-sidebar-width': inspectorColumn,
'--shell-right-region-width': 'calc(var(--shell-preview-pane-width) + var(--shell-right-sidebar-width))',
'--shell-preview-toolbar-gap': previewToolbarGap,
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
'--titlebar-content-inset': `${titlebarContentInset}px`,
'--titlebar-controls-left': `${titlebarControls.left}px`,
'--titlebar-controls-top': `${titlebarControls.top}px`
'--titlebar-controls-top': `${titlebarControls.top}px`,
'--titlebar-tools-right': '0.75rem',
'--titlebar-tools-width': `calc(${titlebarToolCount} * var(--titlebar-control-size) + var(--shell-preview-toolbar-gap))`
} as CSSProperties
}
>
<TitlebarControls
leadingActions={titlebarActions}
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
settingsOpen={settingsOpen}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>
<main
className={cn(
'relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
sidebarOpen || showRightRail ? 'gap-(--shell-gap)' : 'gap-0'
hasSideGaps ? 'gap-(--shell-gap)' : 'gap-0'
)}
style={
{
'--inspector-width': inspectorWidth,
'--inspector-col': inspectorColumn,
'--preview-col': previewColumn,
gridTemplateColumns: shellGridColumns
} as CSSProperties
}
>
<div
aria-hidden="true"
className="absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
/>
<div
aria-hidden="true"
className="absolute right-20 top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] [-webkit-app-region:drag]"
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
/>
{sidebar}
+140 -69
View File
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { NotebookTabs, Search, Settings, SlidersHorizontal, Volume2, VolumeX } from 'lucide-react'
import type { ReactNode } from 'react'
import type * as React from 'react'
import { useNavigate } from 'react-router-dom'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
@@ -10,19 +11,39 @@ import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen }
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
label: string
active?: boolean
className?: string
disabled?: boolean
hidden?: boolean
href?: string
icon: ReactNode
onSelect?: () => void
title?: string
to?: string
}
export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends React.ComponentProps<'div'> {
leftTools?: readonly TitlebarTool[]
settingsOpen: boolean
showInspectorToggle: boolean
leadingActions?: ReactNode
tools?: readonly TitlebarTool[]
onOpenSettings: () => void
}
export function TitlebarControls({
leftTools = [],
settingsOpen,
showInspectorToggle,
leadingActions,
tools = [],
onOpenSettings
}: TitlebarControlsProps) {
const navigate = useNavigate()
const hapticsMuted = useStore($hapticsMuted)
const sidebarOpen = useStore($sidebarOpen)
const inspectorOpen = useStore($inspectorOpen)
@@ -39,86 +60,136 @@ export function TitlebarControls({
}
}
const leftToolbarTools: TitlebarTool[] = [
{
icon: <NotebookTabs />,
id: 'sidebar',
label: sidebarOpen ? 'Hide sidebar' : 'Show sidebar',
onSelect: () => {
triggerHaptic('tap')
toggleSidebarOpen()
}
},
{
icon: <Search size={TITLEBAR_ICON_SIZE} />,
id: 'search',
label: 'Search'
},
...leftTools
]
const rightToolbarTools: TitlebarTool[] = [
...tools,
{
active: inspectorOpen,
hidden: !showInspectorToggle,
icon: <SlidersHorizontal />,
id: 'session-details',
label: inspectorOpen ? 'Hide session details' : 'Show session details',
onSelect: () => {
triggerHaptic('tap')
toggleInspectorOpen()
}
},
{
active: hapticsMuted,
icon: hapticsMuted ? <VolumeX /> : <Volume2 />,
id: 'haptics',
label: hapticsMuted ? 'Unmute haptics' : 'Mute haptics',
onSelect: toggleHaptics
},
{
icon: <Settings />,
id: 'settings',
label: 'Open settings',
onSelect: () => {
triggerHaptic('open')
onOpenSettings()
}
}
]
return (
<>
<div
aria-label="Window controls"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-50 grid translate-y-[2px] grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
className="fixed left-(--titlebar-controls-left) top-(--titlebar-controls-top) z-2147483647 flex translate-y-[2px] flex-row items-center gap-px pointer-events-auto [-webkit-app-region:no-drag]"
>
<button
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('tap')
toggleSidebarOpen()
}}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<NotebookTabs />
</button>
<button
aria-label="Search"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent')}
onPointerDown={event => event.stopPropagation()}
type="button"
>
<Search size={TITLEBAR_ICON_SIZE} />
</button>
{leftToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton key={tool.id} navigate={navigate} tool={tool} />
))}
</div>
{!settingsOpen && (
<div
aria-label="App controls"
className="fixed right-3 top-(--titlebar-controls-top) z-1100 grid grid-flow-col auto-cols-(--titlebar-control-size) items-center pointer-events-auto [-webkit-app-region:no-drag]"
className="fixed right-(--titlebar-tools-right) top-(--titlebar-controls-top) z-2147483647 flex flex-row items-center justify-end gap-px pointer-events-auto [-webkit-app-region:no-drag]"
>
{leadingActions}
{showInspectorToggle && (
<button
aria-label={inspectorOpen ? 'Hide session details' : 'Show session details'}
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('tap')
toggleInspectorOpen()
}}
onPointerDown={event => event.stopPropagation()}
title={inspectorOpen ? 'Hide session details' : 'Show session details'}
type="button"
>
<SlidersHorizontal />
</button>
)}
<button
aria-label={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
aria-pressed={hapticsMuted}
className={cn(
titlebarButtonClass,
'grid place-items-center bg-transparent [&_svg]:size-3.5',
hapticsMuted && 'bg-muted text-muted-foreground'
)}
onClick={toggleHaptics}
onPointerDown={event => event.stopPropagation()}
title={hapticsMuted ? 'Unmute haptics' : 'Mute haptics'}
type="button"
>
{hapticsMuted ? <VolumeX /> : <Volume2 />}
</button>
<button
aria-label="Open settings"
className={cn(titlebarButtonClass, 'grid place-items-center bg-transparent [&_svg]:size-3.5')}
onClick={() => {
triggerHaptic('open')
onOpenSettings()
}}
onPointerDown={event => event.stopPropagation()}
title="Settings"
type="button"
>
<Settings />
</button>
{rightToolbarTools
.filter(tool => !tool.hidden)
.map(tool => (
<TitlebarToolButton
key={tool.id}
navigate={navigate}
tool={tool}
/>
))}
</div>
)}
</>
)
}
function TitlebarToolButton({
navigate,
tool
}: {
navigate: ReturnType<typeof useNavigate>
tool: TitlebarTool
}) {
const className = cn(
titlebarButtonClass,
'grid place-items-center bg-transparent [&_svg]:size-3.5',
tool.active && 'bg-muted text-muted-foreground',
tool.className
)
if (tool.href) {
return (
<a
aria-label={tool.label}
className={className}
href={tool.href}
onPointerDown={event => event.stopPropagation()}
rel="noreferrer"
target="_blank"
title={tool.title ?? tool.label}
>
{tool.icon}
</a>
)
}
return (
<button
aria-label={tool.label}
aria-pressed={tool.active}
className={className}
disabled={tool.disabled}
onClick={() => {
if (tool.to) {
navigate(tool.to)
}
tool.onSelect?.()
}}
onPointerDown={event => event.stopPropagation()}
title={tool.title ?? tool.label}
type="button"
>
{tool.icon}
</button>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ export const titlebarButtonClass =
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground hover:bg-accent hover:text-foreground'
export const titlebarHeaderBaseClass =
'relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 bg-background/70 px-[max(0.75rem,var(--titlebar-content-inset,0px))] backdrop-blur-sm'
export const titlebarHeaderShadowClass =
"shadow-header after:pointer-events-none after:absolute after:left-0 after:right-0 after:top-full after:h-10 after:bg-linear-to-b after:from-background after:via-background/80 after:to-transparent after:content-['']"