feat: better tool parsing ui

This commit is contained in:
Brooklyn Nicholson
2026-05-04 16:08:44 -05:00
parent d1d0ed4016
commit 5f334e86fd
24 changed files with 1865 additions and 244 deletions
+10 -8
View File
@@ -15,33 +15,34 @@ import {
import { $previewTarget } from '@/store/preview'
import { $connection } from '@/store/session'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
interface AppShellProps {
commandCenterOpen: boolean
children: ReactNode
inspectorWidth: string
leftTitlebarTools?: readonly TitlebarTool[]
leftStatusbarItems?: readonly StatusbarItem[]
previewWidth: string
rightRailOpen: boolean
sidebar: ReactNode
statusbarItems?: readonly StatusbarItem[]
titlebarTools?: readonly TitlebarTool[]
onToggleCommandCenter: () => void
onOpenSettings: () => void
overlays?: ReactNode
}
export function AppShell({
commandCenterOpen,
children,
inspectorWidth,
leftTitlebarTools,
leftStatusbarItems,
previewWidth,
rightRailOpen,
sidebar,
statusbarItems,
titlebarTools,
onToggleCommandCenter,
onOpenSettings,
overlays
}: AppShellProps) {
@@ -135,10 +136,8 @@ export function AppShell({
}
>
<TitlebarControls
commandCenterOpen={commandCenterOpen}
leftTools={leftTitlebarTools}
onOpenSettings={onOpenSettings}
onToggleCommandCenter={onToggleCommandCenter}
showInspectorToggle={rightRailOpen}
tools={titlebarTools}
/>
@@ -149,7 +148,8 @@ export function AppShell({
{
'--inspector-col': inspectorColumn,
'--preview-col': previewColumn,
gridTemplateColumns: shellGridColumns
gridTemplateColumns: shellGridColumns,
gridTemplateRows: 'minmax(0,1fr) auto'
} as CSSProperties
}
>
@@ -162,7 +162,7 @@ export function AppShell({
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}
<div className="col-start-1 col-end-2 row-start-1 min-h-0 overflow-hidden">{sidebar}</div>
{sidebarOpen && (
<div
@@ -178,6 +178,8 @@ export function AppShell({
)}
{children}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>
{overlays}
@@ -0,0 +1,187 @@
import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
export interface StatusbarMenuItem {
id: string
icon?: ReactNode
label: string
className?: string
disabled?: boolean
hidden?: boolean
href?: string
onSelect?: () => void
title?: string
to?: string
}
export interface StatusbarItem {
id: string
label?: ReactNode
detail?: ReactNode
icon?: ReactNode
className?: string
disabled?: boolean
hidden?: boolean
href?: string
menuClassName?: string
menuItems?: readonly StatusbarMenuItem[]
onSelect?: () => void
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
}
export type StatusbarItemSide = 'left' | 'right'
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
interface StatusbarControlsProps extends ComponentProps<'footer'> {
leftItems?: readonly StatusbarItem[]
items?: readonly StatusbarItem[]
}
const statusbarItemClass =
'inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[0.69rem] text-muted-foreground/95 transition-colors hover:bg-accent/55 hover:text-foreground disabled:cursor-default disabled:opacity-45'
export function StatusbarControls({ className, leftItems = [], items = [], ...props }: StatusbarControlsProps) {
const navigate = useNavigate()
return (
<footer
className={cn(
'col-span-4 row-start-2 row-end-3 flex h-7 items-center justify-between gap-2 border-t border-border/55 bg-[color-mix(in_srgb,var(--dt-muted)_45%,var(--dt-card))] px-2.5 py-1 text-muted-foreground/95 [-webkit-app-region:no-drag]',
className
)}
{...props}
>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
{leftItems.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
))}
</div>
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
{items.filter(item => !item.hidden).map(item => (
<StatusbarItemView item={item} key={`right:${item.id}`} navigate={navigate} />
))}
</div>
</footer>
)
}
function StatusbarItemView({
item,
navigate
}: {
item: StatusbarItem
navigate: ReturnType<typeof useNavigate>
}) {
const content = (
<>
{item.icon}
{item.label && <span className="truncate">{item.label}</span>}
{item.detail && <span className="truncate text-muted-foreground/80">{item.detail}</span>}
</>
)
const title = item.title ?? (typeof item.label === 'string' ? item.label : undefined)
if (item.variant === 'menu' && item.menuItems && item.menuItems.length > 0) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(statusbarItemClass, item.className)}
disabled={item.disabled}
title={title}
type="button"
>
{content}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn('w-56', item.menuClassName)} side="top" sideOffset={8}>
{item.menuItems
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (
<DropdownMenuItem
className={cn('gap-2 text-foreground focus:bg-accent [&_svg]:size-4', menuItem.className)}
disabled={menuItem.disabled}
key={menuItem.id}
onSelect={() => {
if (menuItem.to) {
navigate(menuItem.to)
}
menuItem.onSelect?.()
}}
>
{menuItem.href ? (
<a
className="inline-flex w-full items-center gap-2"
href={menuItem.href}
rel="noreferrer"
target="_blank"
title={menuItem.title ?? menuItem.label}
>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</a>
) : (
<>
{menuItem.icon}
<span className="truncate">{menuItem.label}</span>
</>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
if (item.variant === 'text' && !item.onSelect && !item.to && !item.href) {
return (
<div className={cn('inline-flex items-center gap-1.5 px-1 text-[0.69rem] text-muted-foreground/90', item.className)}>
{content}
</div>
)
}
if (item.href || item.variant === 'link') {
return (
<a
className={cn(statusbarItemClass, item.className)}
href={item.href}
rel="noreferrer"
target="_blank"
title={title}
>
{content}
</a>
)
}
return (
<button
className={cn(statusbarItemClass, item.className)}
disabled={item.disabled}
onClick={() => {
if (item.to) {
navigate(item.to)
}
item.onSelect?.()
}}
title={title}
type="button"
>
{content}
</button>
)
}
@@ -3,12 +3,12 @@ import type { ComponentProps, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { triggerHaptic } from '@/lib/haptics'
import { Command, NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
import { NotebookTabs, Settings, SlidersHorizontal, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { $inspectorOpen, $sidebarOpen, toggleInspectorOpen, toggleSidebarOpen } from '@/store/layout'
import { TITLEBAR_ICON_SIZE, titlebarButtonClass } from './titlebar'
import { titlebarButtonClass } from './titlebar'
export interface TitlebarTool {
id: string
@@ -28,20 +28,16 @@ export type TitlebarToolSide = 'left' | 'right'
export type SetTitlebarToolGroup = (id: string, tools: readonly TitlebarTool[], side?: TitlebarToolSide) => void
interface TitlebarControlsProps extends ComponentProps<'div'> {
commandCenterOpen: boolean
leftTools?: readonly TitlebarTool[]
showInspectorToggle: boolean
tools?: readonly TitlebarTool[]
onToggleCommandCenter: () => void
onOpenSettings: () => void
}
export function TitlebarControls({
commandCenterOpen,
leftTools = [],
showInspectorToggle,
tools = [],
onToggleCommandCenter,
onOpenSettings
}: TitlebarControlsProps) {
const navigate = useNavigate()
@@ -71,17 +67,6 @@ export function TitlebarControls({
toggleSidebarOpen()
}
},
{
active: commandCenterOpen,
icon: <Command size={TITLEBAR_ICON_SIZE} />,
id: 'command-center',
label: commandCenterOpen ? 'Close command center' : 'Open command center',
title: commandCenterOpen ? 'Close command center' : 'Open command center',
onSelect: () => {
triggerHaptic('tap')
onToggleCommandCenter()
}
},
...leftTools
]
@@ -0,0 +1,39 @@
import { useCallback, useMemo, useState } from 'react'
type Side = 'left' | 'right'
type Groups<T> = Record<Side, Record<string, readonly T[]>>
export type GroupSetter<T> = (id: string, items: readonly T[], side?: Side) => void
interface GroupRegistry<T> {
flat: { left: T[]; right: T[] }
set: GroupSetter<T>
}
export function useGroupRegistry<T>(): GroupRegistry<T> {
const [groups, setGroups] = useState<Groups<T>>({ left: {}, right: {} })
const set = useCallback<GroupSetter<T>>((id, items, side = 'right') => {
setGroups(current => {
const next = { ...current, [side]: { ...current[side] } }
if (items.length === 0) {
delete next[side][id]
} else {
next[side][id] = items
}
return next
})
}, [])
const flat = useMemo(
() => ({
left: Object.values(groups.left).flat(),
right: Object.values(groups.right).flat()
}),
[groups]
)
return { flat, set }
}