Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface OverlayIconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// Overlay chrome icon action — same titlebar-sized ghost button as the overlay
|
||||
// close (X), so footer/header actions read identically across breakpoints.
|
||||
export function OverlayIconButton({ children, className, type = 'button', ...props }: OverlayIconButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className={cn('text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground', className)}
|
||||
size="icon-titlebar"
|
||||
type={type}
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Fragment, memo, type ReactNode } from 'react'
|
||||
|
||||
import { TabDropdown } from '@/components/ui/tab-dropdown'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { PAGE_MAX_W } from '../layout-constants'
|
||||
|
||||
import { OVERLAY_TOP_CLEARANCE } from './overlay-view'
|
||||
|
||||
// The wide rail and the narrow dropdown swap at exactly the width where
|
||||
// OverlaySplitLayout drops to a single column, so the rail never stacks.
|
||||
const RAIL_HIDDEN = 'max-[47.5rem]:hidden'
|
||||
const BAR_HIDDEN = 'hidden max-[47.5rem]:flex'
|
||||
|
||||
interface OverlaySplitLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlaySidebarProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlayMainProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface OverlayNavItemProps {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
/** Stable identity for the row, used as its `data-tour` handle. */
|
||||
id?: string
|
||||
label: string
|
||||
// Renders as an indented child of another nav item: smaller icon and a
|
||||
// lighter active state so it never competes with the boxed parent item.
|
||||
nested?: boolean
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlaySplitLayout({ children, className }: OverlaySplitLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Narrow: one column, and pin rows to [nav-bar auto | main 1fr] — without
|
||||
// an explicit template the grid's default align-content:stretch splits the
|
||||
// height evenly across the two rows, shoving the content to mid-screen.
|
||||
'grid h-full min-h-0 flex-1 grid-cols-[13rem_minmax(0,1fr)] overflow-hidden bg-transparent max-[47.5rem]:grid-cols-1 max-[47.5rem]:grid-rows-[auto_minmax(0,1fr)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlaySidebar({ children, className }: OverlaySidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
// The left links sit beside (not under) the floating close button, so
|
||||
// they ride up via the shorter shared OVERLAY_TOP_CLEARANCE (same line
|
||||
// as a Panel header) instead of main's taller X-clearance. The bg still
|
||||
// fills from the card's top edge, so there's no gap above the sidebar.
|
||||
'flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-(--ui-sidebar-surface-background) px-2.5 pb-3',
|
||||
OVERLAY_TOP_CLEARANCE,
|
||||
className
|
||||
)}
|
||||
// Every overlay's left nav (settings, cron, profiles, agents) answers to
|
||||
// one name, so a tour can point at "the nav" without knowing which
|
||||
// overlay is open. See lib/tour.
|
||||
data-tour="overlay-nav"
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
// Main sits UNDER the floating close button (top-right), so it keeps the
|
||||
// taller top pad to clear the X — unlike the sidebar / Panel header,
|
||||
// which sit to its left and ride up via OVERLAY_TOP_CLEARANCE. All four
|
||||
// paddings are 1/3 tighter than the raw values (×2/3): the wide/narrow
|
||||
// top clearance, the bottom gutter, and the horizontal clamp gutter
|
||||
// (inlined from PAGE_INSET_X so only overlay panes tighten, not the
|
||||
// shared page gutter). Narrow top drops toward the OverlayNav bar.
|
||||
'mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-transparent pb-2 pt-[calc((var(--titlebar-height)/2+1rem)*2/3)] max-[47.5rem]:pt-[calc(0.5rem*2/3)] px-[clamp(0.8333rem,2.6667vw,2.6667rem)]',
|
||||
PAGE_MAX_W,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export const OverlayNavItem = memo(function OverlayNavItem({
|
||||
active,
|
||||
icon: Icon,
|
||||
id,
|
||||
label,
|
||||
nested,
|
||||
onClick,
|
||||
trailing
|
||||
}: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
|
||||
nested
|
||||
? active
|
||||
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
: active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
// Names the row by its own id, so a tour can address one link
|
||||
// (`[data-tour="nav-models"]`) instead of guessing at nth-child.
|
||||
data-tour={id ? `nav-${id}` : undefined}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
nested ? 'size-3.5' : 'size-4',
|
||||
active ? 'text-foreground/80' : 'text-muted-foreground/80'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
export interface OverlayNavLink {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
id: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface OverlayNavGroup extends OverlayNavLink {
|
||||
/** Sub-links: expanded under the active group on the rail, always listed
|
||||
* (flattened + indented) in the narrow dropdown. */
|
||||
children?: OverlayNavLink[]
|
||||
/** Visual break before this group — a spacer on the rail, a separator in
|
||||
* the dropdown. */
|
||||
gapBefore?: boolean
|
||||
}
|
||||
|
||||
// Data-driven pane nav: one model renders a persistent left rail on wide
|
||||
// viewports and a single dropdown bar on narrow ones (matching the tab
|
||||
// dropdown in PageSearchShell), so every OverlaySplitLayout pane degrades the
|
||||
// same way instead of stacking its whole sidebar. Drop it in as the first
|
||||
// child of an OverlaySplitLayout, before OverlayMain.
|
||||
export function OverlayNav({ footer, groups }: { footer?: ReactNode; groups: OverlayNavGroup[] }) {
|
||||
return (
|
||||
<>
|
||||
<OverlaySidebar className={RAIL_HIDDEN}>
|
||||
{groups.map(group => (
|
||||
<Fragment key={group.id}>
|
||||
{group.gapBefore && <div aria-hidden className="h-2" />}
|
||||
<OverlayNavItem
|
||||
active={group.active}
|
||||
icon={group.icon}
|
||||
id={group.id}
|
||||
label={group.label}
|
||||
onClick={group.onSelect}
|
||||
/>
|
||||
{group.children && group.active && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
{group.children.map(child => (
|
||||
<OverlayNavItem
|
||||
active={child.active}
|
||||
icon={child.icon}
|
||||
id={child.id}
|
||||
key={child.id}
|
||||
label={child.label}
|
||||
nested
|
||||
onClick={child.onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
{footer && <div className="mt-auto flex items-center gap-1 pt-2">{footer}</div>}
|
||||
</OverlaySidebar>
|
||||
|
||||
{/* Narrow: ride the OverlayView titlebar strip so the dropdown shares the
|
||||
close button's row instead of taking its own. The bar is
|
||||
pointer-events-none (children opt back in) so the floating X underneath
|
||||
stays clickable; pr clears it, no-drag beats the strip's drag region,
|
||||
and the height matches the strip so the trigger lines up with the X. */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none relative z-20 h-[calc(var(--titlebar-height)+0.1875rem)] items-center justify-between gap-2 pl-3 pr-12',
|
||||
BAR_HIDDEN
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto min-w-0 [-webkit-app-region:no-drag]">
|
||||
<TabDropdown
|
||||
align="start"
|
||||
items={groups.flatMap(group => [
|
||||
{
|
||||
active: group.active && !group.children?.some(child => child.active),
|
||||
icon: group.icon,
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
onSelect: group.onSelect,
|
||||
separatorBefore: group.gapBefore
|
||||
},
|
||||
...(group.children ?? []).map(child => ({
|
||||
active: child.active,
|
||||
icon: child.icon,
|
||||
id: child.id,
|
||||
indent: true,
|
||||
label: child.label,
|
||||
onSelect: child.onSelect
|
||||
}))
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="pointer-events-auto flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { type CSSProperties, type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { TitlebarIcon } from '@/app/shell/titlebar-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Shared top clearance for overlay content that sits *beside* the floating
|
||||
// close button (which is absolute at `0.1875rem + titlebar/2`, -translate-y-1/2,
|
||||
// so it costs no layout space): a Panel's header and the split layout's left
|
||||
// sidebar links. They ride up next to the X on the same line across every
|
||||
// overlay (settings, system, agents, cron, …) — change it here, not per-surface.
|
||||
// Main content sits *under* the X (top-right) and keeps its own taller pad.
|
||||
export const OVERLAY_TOP_CLEARANCE = 'pt-[calc(var(--titlebar-height)/2-0.4375rem)]'
|
||||
|
||||
interface OverlayViewProps {
|
||||
children: ReactNode
|
||||
onClose: () => void
|
||||
closeLabel?: string
|
||||
contentClassName?: string
|
||||
/** Chrome pinned to the card's top edge, horizontally centered and riding
|
||||
* the border half-in half-out (e.g. the Settings search pill). Rendered
|
||||
* beside the card, not inside it — the card clips its own overflow. */
|
||||
edgeBadge?: ReactNode
|
||||
headerContent?: ReactNode
|
||||
rootClassName?: string
|
||||
/** Controls rendered on the close button's row, to its left. They ride the
|
||||
* titlebar strip, so keep them titlebar-sized and quiet. */
|
||||
titlebarActions?: ReactNode
|
||||
}
|
||||
|
||||
export function OverlayView({
|
||||
children,
|
||||
onClose,
|
||||
closeLabel = translateNow('common.close'),
|
||||
contentClassName,
|
||||
edgeBadge,
|
||||
headerContent,
|
||||
rootClassName,
|
||||
titlebarActions
|
||||
}: OverlayViewProps) {
|
||||
const closeOverlay = () => {
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Esc dismisses every OverlayView-based overlay. Nested Radix dialogs
|
||||
// stop propagation themselves, so opening (e.g.) the model picker inside
|
||||
// Settings still closes the picker first instead of the underlying overlay.
|
||||
useEffect(() => {
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
triggerHaptic('close')
|
||||
onClose()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/22 backdrop-blur-[0.125rem]',
|
||||
// Equidistant inset on every side. The top value is driven by the
|
||||
// titlebar height so the card clears the OS traffic-lights vertically;
|
||||
// since the card top already sits below them, the left needs no extra
|
||||
// inset — keeping all sides equal so the card is ~full-width at any size.
|
||||
'p-[calc(var(--titlebar-height)+0.625rem)]',
|
||||
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
|
||||
)}
|
||||
// Every OverlayView-based overlay (settings, command-center, agents, cron,
|
||||
// profiles, star map, …) covers the chat while the composer stays mounted
|
||||
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
|
||||
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
|
||||
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
|
||||
// e.g. star map's Space, keep working).
|
||||
data-overlay-surface=""
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeOverlay()
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
// Window-level chrome: overlays always clear the real titlebar. The
|
||||
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
|
||||
// below its in-flow title bar), and CSS vars inherit through the DOM —
|
||||
// so a fixed overlay mounted inside a zone would read 0 and bleed to
|
||||
// the edges. Re-pin the real height at the overlay root.
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<div className="relative h-full min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full min-h-0 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-surface-background) shadow-md',
|
||||
rootClassName
|
||||
)}
|
||||
// Marks the card as a RAISED surface for window glass: while the field
|
||||
// behind it thins to show the desktop, this card stays near-opaque
|
||||
// (see the [data-glass-raised] rules in styles.css). Inert otherwise.
|
||||
data-glass-raised=""
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-[calc(var(--titlebar-height)+0.1875rem)] [-webkit-app-region:drag]">
|
||||
{headerContent && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[calc(0.5rem+var(--titlebar-height)/2)] -translate-x-1/2 -translate-y-1/2 [-webkit-app-region:no-drag]">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pointer-events-auto absolute right-3 top-[calc(0.1875rem+var(--titlebar-height)/2)] flex -translate-y-1/2 items-center gap-1.5 [-webkit-app-region:no-drag]">
|
||||
{titlebarActions}
|
||||
|
||||
<Button
|
||||
aria-label={closeLabel}
|
||||
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
|
||||
onClick={closeOverlay}
|
||||
size="icon-titlebar"
|
||||
variant="ghost"
|
||||
>
|
||||
<TitlebarIcon name="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No top padding here: the split-layout columns own their own
|
||||
titlebar clearance so their backgrounds run flush to the card top
|
||||
(otherwise the card surface shows as a gap above the sidebar). */}
|
||||
<div className={cn('min-h-0 flex flex-1 flex-col', contentClassName)}>{children}</div>
|
||||
</div>
|
||||
|
||||
{/* Sibling of the card, not a child: the card clips its own overflow
|
||||
(rounded corners), and the badge deliberately straddles the top
|
||||
border — half above, half below. */}
|
||||
{edgeBadge && <div className="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">{edgeBadge}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PanelRowMenu } from './panel'
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
HTMLElement.prototype.scrollIntoView ??= () => undefined
|
||||
})
|
||||
|
||||
describe('PanelRowMenu', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('opens its actions menu from the kebab without a tooltip', async () => {
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(<PanelRowMenu items={[{ label: 'Rename', onSelect }]} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'Actions' })
|
||||
|
||||
expect(trigger.closest('[data-slot="tooltip-trigger"]')).toBeNull()
|
||||
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Rename' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { RowButton } from '@/components/ui/row-button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { OVERLAY_TOP_CLEARANCE, OverlayView } from './overlay-view'
|
||||
|
||||
// Overlay "panel" primitive — the centered, capped card + framed chrome lifted
|
||||
// straight from the trace / agents overlay so every non-settings overlay (cron,
|
||||
// profiles, …) speaks the same visual language: tight type scale, muted
|
||||
// opacities, NO container borders (rows separate via the row-hover/active bg
|
||||
// vars + gaps, exactly like the trace waterfall labels).
|
||||
//
|
||||
// Compose it as:
|
||||
// <Panel onClose>
|
||||
// <PanelHeader title subtitle actions={…} />
|
||||
// <PanelBody> // master/detail row
|
||||
// <PanelList>…</PanelList>
|
||||
// <PanelDetail>…</PanelDetail>
|
||||
// </PanelBody>
|
||||
// </Panel>
|
||||
//
|
||||
// Single-column views drop their content straight after the header.
|
||||
|
||||
interface PanelProps {
|
||||
children: ReactNode
|
||||
// Root layout override (the card already fills the equidistant inset).
|
||||
className?: string
|
||||
closeLabel?: string
|
||||
contentClassName?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
children,
|
||||
className,
|
||||
closeLabel = translateNow('common.close'),
|
||||
contentClassName,
|
||||
onClose
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<OverlayView
|
||||
closeLabel={closeLabel}
|
||||
// Header title rides up next to the floating close button — see
|
||||
// OVERLAY_TOP_CLEARANCE, the shared clearance every overlay column uses.
|
||||
contentClassName={cn('flex h-full min-h-0 flex-col px-4 pb-4 sm:px-5', OVERLAY_TOP_CLEARANCE, contentClassName)}
|
||||
onClose={onClose}
|
||||
rootClassName={cn('flex h-full w-full flex-col', className)}
|
||||
>
|
||||
{children}
|
||||
</OverlayView>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelHeaderProps {
|
||||
// Right-aligned controls (search, "+ New", segmented control, …).
|
||||
actions?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
export function PanelHeader({ actions, subtitle, title }: PanelHeaderProps) {
|
||||
return (
|
||||
// The overlay's close (X) is absolutely positioned at right-3 and costs no
|
||||
// layout space, so header actions would otherwise slide right up against it.
|
||||
// Reserve clearance (button footprint from the card edge + a small gap) on
|
||||
// the right whenever actions are present.
|
||||
<header className={cn('mb-3 flex shrink-0 items-start justify-between gap-3', actions ? 'pr-8' : undefined)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{subtitle ? <p className="truncate text-xs text-muted-foreground/80">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-1.5">{actions}</div> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelBody({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Side-by-side master/detail on a wide card; once it narrows (same
|
||||
// threshold the other overlays collapse at) stack the list above the
|
||||
// detail so the detail keeps full width instead of being squished.
|
||||
'flex min-h-0 flex-1 flex-col gap-4 overflow-hidden min-[47.5rem]:flex-row min-[47.5rem]:gap-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelListProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
// Pass an onSearchChange to bake a full-bleed filter field in above the items
|
||||
// (pinned; the rows scroll under it). Controlled via searchValue.
|
||||
onSearchChange?: (value: string) => void
|
||||
searchLabel?: string
|
||||
searchPlaceholder?: string
|
||||
/** Data-derived rotating placeholder nudges (see SearchField.hints). */
|
||||
searchHints?: string[]
|
||||
searchValue?: string
|
||||
}
|
||||
|
||||
// Left master list. Dense + borderless, like the trace waterfall's label tree:
|
||||
// single-line rows that touch, separated from the detail only by the body gap.
|
||||
// An optional search field pins to the top, full-bleed, above the scroll.
|
||||
export function PanelList({
|
||||
children,
|
||||
className,
|
||||
onSearchChange,
|
||||
searchLabel,
|
||||
searchPlaceholder,
|
||||
searchHints,
|
||||
searchValue
|
||||
}: PanelListProps) {
|
||||
return (
|
||||
// Full-width and height-capped when stacked (narrow); a fixed 13rem rail
|
||||
// beside the detail when wide.
|
||||
<div className={cn('flex w-full shrink-0 flex-col max-[47.5rem]:max-h-[40%] min-[47.5rem]:w-52', className)}>
|
||||
{onSearchChange ? (
|
||||
<SearchField
|
||||
aria-label={searchLabel ?? searchPlaceholder ?? ''}
|
||||
containerClassName="mb-1 w-full shrink-0"
|
||||
hints={searchHints}
|
||||
onChange={onSearchChange}
|
||||
placeholder={searchPlaceholder ?? ''}
|
||||
value={searchValue ?? ''}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelListRowProps {
|
||||
active: boolean
|
||||
// Leading status dot color class (e.g. 'bg-emerald-500'); omit for none.
|
||||
dotClassName?: string
|
||||
// Leading codicon glyph name (used when there's no lead/dot).
|
||||
icon?: string
|
||||
// Custom leading element (colored swatch, avatar, …). Wins over dot/icon.
|
||||
lead?: ReactNode
|
||||
// Per-row actions. Pass `menuItems` to get BOTH the hover kebab and a matching
|
||||
// right-click menu from one array (preferred). `menu` takes a raw node for the
|
||||
// rare custom trigger; it gets no right-click parity.
|
||||
menu?: ReactNode
|
||||
menuItems?: PanelMenuItem[]
|
||||
// aria/tooltip label for the kebab + right-click menu built from `menuItems`.
|
||||
menuLabel?: string
|
||||
// Short always-visible trailing meta (a tag/time, like the trace label's duration).
|
||||
meta?: ReactNode
|
||||
onSelect: () => void
|
||||
rowKey?: string
|
||||
title: ReactNode
|
||||
}
|
||||
|
||||
// A row is a container (not a <button>) so it can host both the select target
|
||||
// and a kebab menu without nesting interactive elements. Hover/active bg lives
|
||||
// on the wrapper so the whole row highlights as one. When `menuItems` is passed,
|
||||
// the whole row also answers right-click with the same actions as its kebab.
|
||||
export function PanelListRow({
|
||||
active,
|
||||
dotClassName,
|
||||
icon,
|
||||
lead,
|
||||
menu,
|
||||
menuItems,
|
||||
menuLabel,
|
||||
meta,
|
||||
onSelect,
|
||||
rowKey,
|
||||
title
|
||||
}: PanelListRowProps) {
|
||||
const row = (
|
||||
<div
|
||||
className={cn(
|
||||
'group/row row-hover relative flex h-7 w-full items-center rounded-md text-[0.78rem] hover:text-foreground',
|
||||
active ? 'bg-(--ui-row-active-background) text-foreground' : 'text-(--ui-text-secondary)'
|
||||
)}
|
||||
data-panel-row={rowKey}
|
||||
>
|
||||
<RowButton
|
||||
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-md pl-2 pr-1 text-left"
|
||||
onClick={onSelect}
|
||||
>
|
||||
{lead ??
|
||||
(dotClassName ? (
|
||||
<span aria-hidden="true" className={cn('size-1.5 shrink-0 rounded-full', dotClassName)} />
|
||||
) : icon ? (
|
||||
<Codicon className="shrink-0 text-muted-foreground/55" name={icon} size="0.85rem" />
|
||||
) : null)}
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground/85">{title}</span>
|
||||
</RowButton>
|
||||
{meta ? <span className="shrink-0 pr-2 text-[0.62rem] tabular-nums text-muted-foreground/45">{meta}</span> : null}
|
||||
{menuItems ? (
|
||||
<div className="shrink-0 pr-1">
|
||||
<PanelRowMenu items={menuItems} label={menuLabel} />
|
||||
</div>
|
||||
) : menu ? (
|
||||
<div className="shrink-0 pr-1">{menu}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Right-click parity: same items as the kebab. `disabled` (no actionable
|
||||
// items) renders the row bare.
|
||||
return menuItems ? (
|
||||
<ActionsContextMenu
|
||||
ariaLabel={menuLabel}
|
||||
contentClassName="w-40"
|
||||
disabled={menuItems.length === 0}
|
||||
items={renderPanelMenuItems(menuItems)}
|
||||
>
|
||||
{row}
|
||||
</ActionsContextMenu>
|
||||
) : (
|
||||
row
|
||||
)
|
||||
}
|
||||
|
||||
export interface PanelMenuItem {
|
||||
disabled?: boolean
|
||||
icon?: string
|
||||
label: string
|
||||
onSelect: () => void
|
||||
tone?: 'danger' | 'default'
|
||||
}
|
||||
|
||||
// Bridge PanelMenuItem[] → the shared actions-menu render fn, so a panel row's
|
||||
// kebab and its right-click menu render from one source.
|
||||
function renderPanelMenuItems(items: PanelMenuItem[]) {
|
||||
return (kit: MenuKit) =>
|
||||
items.map(item =>
|
||||
renderActionItem(kit, {
|
||||
disabled: item.disabled,
|
||||
icon: item.icon,
|
||||
key: item.label,
|
||||
label: item.label,
|
||||
onSelect: item.onSelect,
|
||||
variant: item.tone === 'danger' ? 'destructive' : 'default'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Per-row "⋮" actions menu — mirrors the sidebar session row's settled pattern
|
||||
// (size-5 ghost trigger + kebab-vertical codicon + w-40 content). Hidden until
|
||||
// the row is hovered/focused (or the menu is open). Returns null with no items
|
||||
// (e.g. the default profile, which can't be renamed/deleted).
|
||||
export function PanelRowMenu({ items, label = 'Actions' }: { items: PanelMenuItem[]; label?: string }) {
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionsMenu ariaLabel={label} contentClassName="w-40" items={renderPanelMenuItems(items)}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="size-5 rounded-[4px] bg-transparent text-(--ui-text-tertiary) opacity-0 transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:opacity-100 focus-visible:ring-0 group-hover/row:opacity-100 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground data-[state=open]:opacity-100 [&_svg]:size-3.5!"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="kebab-vertical" size="0.875rem" />
|
||||
</Button>
|
||||
</ActionsMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Scrolling detail region. Fills the column (no right rail here, unlike the
|
||||
// trace inspector), so the content stretches the full available width.
|
||||
export function PanelDetail({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain', className)}>
|
||||
<div className="space-y-4 pb-6 pl-1 pr-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PanelEmptyProps {
|
||||
action?: ReactNode
|
||||
description?: ReactNode
|
||||
// Codicon glyph name (e.g. 'hubot', 'warning', 'loading~spin').
|
||||
icon?: string
|
||||
title?: ReactNode
|
||||
}
|
||||
|
||||
export function PanelEmpty({ action, description, icon = 'inbox', title }: PanelEmptyProps) {
|
||||
return (
|
||||
<div className="grid flex-1 place-items-center px-6 py-10 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Codicon className="text-muted-foreground/50" name={icon} size="1.25rem" />
|
||||
{title ? <p className="text-sm font-medium text-foreground/90">{title}</p> : null}
|
||||
{description ? (
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground/70">{description}</p>
|
||||
) : null}
|
||||
{action ? <div className="mt-2">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PanelSectionLabel({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('text-[0.6rem] font-medium uppercase tracking-wider text-muted-foreground/50', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inspector-style key/value grid (mirrors the trace span inspector's <dl>).
|
||||
export interface PanelMetaRow {
|
||||
label: ReactNode
|
||||
value: ReactNode
|
||||
}
|
||||
|
||||
export function PanelMeta({ className, rows }: { className?: string; rows: PanelMetaRow[] }) {
|
||||
return (
|
||||
<dl className={cn('grid grid-cols-[5rem_1fr] gap-x-2 gap-y-1 text-[0.7rem]', className)}>
|
||||
{rows.map((row, i) => (
|
||||
<div className="contents" key={typeof row.label === 'string' ? row.label : i}>
|
||||
<dt className="truncate text-muted-foreground/55">{row.label}</dt>
|
||||
<dd className="min-w-0 break-words text-foreground/85">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
// Monospace content block (job prompt, etc.) — mirrors the inspector's
|
||||
// input/output <pre> blocks: subtle bg, no border.
|
||||
export function PanelBlock({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
'max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-foreground/5 p-2.5 text-[0.68rem] leading-relaxed text-foreground/80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export type PanelPillTone = 'bad' | 'good' | 'muted' | 'warn'
|
||||
|
||||
const PILL_TONE: Record<PanelPillTone, string> = {
|
||||
bad: 'bg-destructive/10 text-destructive',
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-foreground/10 text-muted-foreground',
|
||||
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||
}
|
||||
|
||||
export function PanelPill({ children, tone = 'muted' }: { children: ReactNode; tone?: PanelPillTone }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.62rem] font-medium capitalize',
|
||||
PILL_TONE[tone]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Self-describing centered "+" that sits as the LAST item in a PanelList. The
|
||||
// label rides aria/title only — no visible text.
|
||||
export function PanelAddButton({
|
||||
icon = 'add',
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
icon?: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="h-7 w-full shrink-0 justify-center text-muted-foreground/70 hover:bg-(--ui-row-hover-background) hover:text-foreground"
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
// Visible action for a detail header (cron pause/resume/trigger, …). Ghost by
|
||||
// default; `primary` promotes the header's main action to a filled button.
|
||||
export function PanelAction({
|
||||
children,
|
||||
disabled,
|
||||
icon,
|
||||
onClick,
|
||||
primary
|
||||
}: {
|
||||
children: ReactNode
|
||||
disabled?: boolean
|
||||
icon: string
|
||||
onClick: () => void
|
||||
primary?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'gap-1.5',
|
||||
!primary && 'text-muted-foreground hover:bg-(--ui-row-hover-background) hover:text-foreground'
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant={primary ? 'default' : 'ghost'}
|
||||
>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The peek is scoped with :has() so only the settings overlay that owns the
|
||||
* translucency row pays for an opacity transition. jsdom implements :has() in
|
||||
* matches(), so the scoping claim is checkable directly: the marked overlay
|
||||
* matches the peek selector and an unmarked one does not.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const PEEK_SELECTOR = '[data-overlay-surface]:has([data-translucency-peek-scope])'
|
||||
|
||||
describe('peek scoping', () => {
|
||||
it('matches only the overlay that armed the interaction', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-overlay-surface id="settings">
|
||||
<div data-translucency-peek-scope></div>
|
||||
</div>
|
||||
<div data-overlay-surface id="command-center">
|
||||
<div></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const settings = document.getElementById('settings')
|
||||
const commandCenter = document.getElementById('command-center')
|
||||
|
||||
expect(settings?.matches(PEEK_SELECTOR)).toBe(true)
|
||||
// The regression this guards: an unscoped rule gave EVERY overlay in the
|
||||
// app a 420ms opacity transition for the life of the process.
|
||||
expect(commandCenter?.matches(PEEK_SELECTOR)).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user