fix(web-ui): menu placement and scrolling, tool-row and settings polish

Menus: keep 12px viewport clearance with internal scroll, pin workspace
create actions in a footer, and pre-render portal lists hidden so the
first painted frame is already at its final position (no open jump).
Tool rows: 14px icons, secondary titles, no hover fill, and a hover
chevron preview on in-place expandable rows. Settings: 800x600 layer-2
panel over a blurred mask, hover states, and wrapping selector cubes;
ModelSelect surface tokens now match the Menu primitive.
This commit is contained in:
Yif
2026-07-28 04:25:18 +08:00
parent fed1b23eac
commit 1f79b78045
29 files changed
+367 -175

No files matched your search

@@ -42,6 +42,10 @@
cursor: pointer;
}
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron {
flex: none;
}
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
<ToolRow
variant="think"
icon={<IconThinkOutline14 />}
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
body={text}
@@ -13,16 +13,16 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
think: <IconThinkOutline14 />,
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
code: <IconCodeOutline16 />,
others: <IconSparkle16 />,
think: <IconThinkOutline14 size={14} />,
search: <IconSearchOutline16 size={14} />,
read: <IconBrowseOutline16 size={14} />,
bash: <IconApiOutline14 size={14} />,
write: <IconEditOutline16 size={14} />,
edit: <IconEditOutline16 size={14} />,
code: <IconCodeOutline16 size={14} />,
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
@@ -13,15 +13,12 @@
min-width: 0;
}
/* Clickable rows keep only the cursor affordance — no hover fill. */
.row[data-clickable] {
cursor: pointer;
border-radius: 6px;
}
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
flex: none;
width: 16px;
@@ -65,11 +62,29 @@ button.leading {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon yields to a down
chevron before the row is opened. */
.iconIdle {
display: inline-flex;
}
.chevronHover {
display: none;
}
.row:hover .iconIdle {
display: none;
}
.row:hover .chevronHover {
display: inline-flex;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
color: var(--dsw-alias-label-secondary);
}
.sep {
@@ -1,8 +1,10 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
// TODO(ux): converge every chat-tab tool row on in-place expansion for its
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -66,6 +68,19 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
@@ -84,11 +99,11 @@ export function ToolRow({
aria-expanded={open}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</button>
) : (
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</span>
)}
<span className={css.title}>{title}</span>
@@ -300,6 +300,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
/** Currently active workspace (renders a trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
onPick: (workspaceId: WorkspaceId) => void
onClose: () => void
}
@@ -87,7 +87,7 @@
padding-left: 8px;
}
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
.tab {
position: relative;
padding: 0 0 11px;
@@ -95,7 +95,7 @@
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 510;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
@@ -140,10 +140,19 @@
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
width: min(776px, calc(100% - 48px));
z-index: 1;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
}
.root[data-phase='hero'] {
justify-content: center;
}
@@ -48,7 +48,7 @@ export function ConversationRoot({
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
const heroWorkspaceRow = (
<>
<div className={css.heroWorkspaceRow}>
<WorkspaceChip
buttonRef={pickerAnchor}
label={
@@ -63,6 +63,7 @@ export function ConversationRoot({
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
onPick: (workspaceId) => {
setPickerOpen(false)
setPendingWorkspaceId(workspaceId)
@@ -72,7 +73,7 @@ export function ConversationRoot({
},
onClose: () => { setPickerOpen(false) },
})}
</>
</div>
)
const inputBar = sessionId === undefined
@@ -8,8 +8,7 @@
justify-content: center;
height: 100%;
min-width: 0;
padding: 24px;
margin-bottom: -70px;
padding: 0 24px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
@@ -24,17 +23,15 @@
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
keeps 36px below the headline before the flex gap. */
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-bottom: 36px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
@@ -88,7 +85,7 @@
display: inline-flex;
align-items: center;
gap: 4px;
max-width: fit-content;
max-width: min(100%, 360px);
min-height: 28px;
padding: 0 8px;
border: none;
@@ -9,10 +9,6 @@
border-radius: 6px;
}
.root:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading {
flex: none;
width: 16px;
@@ -39,7 +35,7 @@
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
color: var(--dsw-alias-label-secondary);
}
.sep {
@@ -15,7 +15,7 @@ function leadingFor(state: ToolRowState) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} />
default: return <IconApiOutline14 size={14} />
}
}
@@ -11,10 +11,6 @@
font-size: 13px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.badge {
flex: none;
color: var(--dsw-alias-state-business-primary);
@@ -22,7 +18,7 @@
.title {
flex: none;
font-weight: 510;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary);
}
@@ -72,9 +72,11 @@
max-height: min(360px, calc(100vh - 96px));
overflow: hidden;
padding: 4px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Surface tokens match the Menu primitive card (ui-primitives
* Menu.module.css) so every dropdown reads as the same material. */
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-input-major);
background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary);
}
@@ -132,7 +134,7 @@
top: 0;
z-index: 1;
padding: 5px 8px 3px;
background: var(--dsw-specific-input-major);
background: var(--dsw-specific-menu);
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 18px;
@@ -156,11 +158,16 @@
}
.option:hover:not(:disabled),
.option:focus-visible,
.selected {
.option:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selection marker is the trailing check, not a fill — matches the Menu
* primitive's selected treatment. */
.selected {
background: transparent;
}
.option:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: default;
@@ -201,7 +208,7 @@
display: grid;
place-items: center;
flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side
@@ -18,7 +18,7 @@
.button:disabled {
cursor: not-allowed;
color: var(--dsw-alias-label-dimmed);
opacity: 0.4;
}
.md {
@@ -44,10 +44,6 @@
background: var(--dsw-alias-button-primary-hover);
}
.primary:disabled {
background: var(--dsw-alias-button-primary-dimmed);
}
.ghost:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -66,10 +62,6 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.outline:disabled {
border-color: var(--dsw-alias-border-l1);
}
.toolbar {
background: var(--dsw-alias-button-tool-bar-fill);
}
@@ -26,6 +26,7 @@
left: 0;
z-index: 100;
min-width: 218px;
max-width: 360px;
}
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
@@ -50,6 +51,36 @@
right: 0;
}
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
* this class — the overflow clip would crop the side card, so they rely on
* staying short. */
.scrollable {
max-height: calc(100vh - 24px);
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
}
.scrollable .viewport {
overflow-y: auto;
}
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
* the menu surface) mirrors the .separator spacing. */
.footer {
flex: none;
display: flex;
flex-direction: column;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.itemWrap {
position: relative;
}
@@ -78,7 +109,7 @@
}
.item:disabled {
color: var(--dsw-alias-label-dimmed);
opacity: 0.4;
cursor: not-allowed;
}
+115 -67
View File
@@ -5,6 +5,8 @@
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows.
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
// internally past that; submenu-bearing menus are exempt (see .scrollable).
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
@@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label'
}
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled).
@@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open and on every
* scroll/resize; return null to skip placement for that frame.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
selectedId?: string
footer?: readonly MenuEntry[]
selectedId?: string | undefined
onSelect: (id: string) => void
onClose: () => void
align?: 'start' | 'end'
side?: 'bottom' | 'top'
side?: 'bottom' | 'top' | 'right'
portal?: boolean
closeOnPointerLeave?: boolean
getAnchorRect?: () => DOMRect | null
@@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
r = rootRef.current?.getBoundingClientRect() ?? null
}
if (r === null) return
setFixedPos({
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
})
const MARGIN = 12
const vw = window.innerWidth
const vh = window.innerHeight
const listEl = listRef.current
const lw = listEl?.offsetWidth ?? 0
const lh = listEl?.offsetHeight ?? 0
let x: number
let y: number
if (side === 'right') {
x = r.right + 4
y = r.top
} else if (align === 'start') {
x = r.left
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
} else {
x = r.right - lw
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
}
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
setFixedPos({ left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// end/top alignment and clamping use real dimensions before anything
// paints — no visible jump from a zero-size first guess.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
@@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
}
}, [open, onClose])
const list = open && (!portal || fixedPos !== null) && (
// The submenu card is absolutely positioned outside the list box; the
// scroll clip would crop it, so only submenu-free menus get the height cap.
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
const renderEntry = (entry: MenuEntry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
}
// Portal lists render hidden until placed: the placement effect measures
// this pre-render in the same commit, so the first painted frame is
// already at the final position (with getAnchorRect returning null the
// list simply stays hidden).
const list = open && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
@@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
{items.map((entry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
})}
<div className={css.viewport} role="presentation">
{items.map(renderEntry)}
</div>
{footer !== undefined && footer.length > 0 && (
<div className={css.footer} role="presentation">
{footer.map(renderEntry)}
</div>
)}
</div>
)
@@ -54,7 +54,7 @@
margin: 0;
font-size: 16px;
line-height: 24px;
font-weight: 510;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary);
}
@@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
{cloneElement(children, {
ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})}
@@ -271,14 +271,47 @@ describe('Menu', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('portal mode positions from the opposite edges for align=end / side=top', () => {
it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => {
render(
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
const menu = screen.getByRole('menu')
expect(menu.style.right).not.toBe('')
expect(menu.style.bottom).not.toBe('')
expect(menu.style.left).toBe('')
expect(menu.style.top).toBe('')
expect(menu.style.left).not.toBe('')
expect(menu.style.top).not.toBe('')
expect(menu.style.right).toBe('')
expect(menu.style.bottom).toBe('')
})
it('renders footer rows in a pinned section below the items; they still select', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={items}
footer={[{ id: 'new', label: 'Create new' }]}
onSelect={onSelect}
onClose={() => {}}
/>)
const footerItem = screen.getByRole('menuitem', { name: 'Create new' })
expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull()
expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull()
fireEvent.click(footerItem)
expect(onSelect).toHaveBeenCalledWith('new')
})
it('caps the list height for internal scrolling unless a submenu row is present', () => {
const { rerender } = render(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
rerender(
<Menu
open
anchor={<span>trigger</span>}
items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]}
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
})
})
@@ -81,23 +81,20 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('keeps the bubble while either hover or focus is still active', () => {
it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
render(
<Tooltip label="Sticky">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// Focused AND hovered: leaving with the mouse must not drop the bubble.
// Focused AND hovered: leaving with the mouse drops the bubble at once.
fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
// Re-entering shows it again; blurring while still hovered keeps it.
fireEvent.mouseEnter(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.mouseLeave(anchor)
@@ -72,6 +72,10 @@
cursor: pointer;
}
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled {
cursor: default;
}
@@ -80,18 +84,21 @@
flex: none;
}
/* Tool Call mode cubes share an 8px gap. */
/* Tool Call mode cubes share an 8px gap and wrap to one per row when the
panel is too narrow. */
.cubeRow {
display: flex;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the
* 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10,
* vertical = inner pad 8). */
.modeCube {
box-sizing: border-box;
width: 418px;
flex: 1 1 276px;
display: flex;
flex-direction: column;
justify-content: center;
@@ -101,6 +108,11 @@
border-radius: 16px;
background: transparent;
text-align: left;
cursor: pointer;
}
.modeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
@@ -44,7 +44,8 @@
white-space: nowrap;
}
/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */
/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
.overlay {
position: fixed;
inset: 0;
@@ -58,21 +59,22 @@
position: absolute;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow
(figma effects match --dsw-shadow-lv3 exactly). */
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */
.panel {
position: relative;
z-index: 1;
display: flex;
width: 1080px;
height: 700px;
width: 800px;
height: 600px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
border-radius: 24px;
overflow: hidden;
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3);
}
@@ -20,13 +20,15 @@
display: flex;
align-items: stretch;
gap: 8px;
flex-wrap: wrap;
}
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
* icon-over-label column, gap 4). */
* icon-over-label column, gap 4); flexed down from the figma width so all
* three sit on one row in the 800 panel, wrapping when narrower. */
.themeCube {
box-sizing: border-box;
width: 276px;
flex: 1 1 180px;
display: flex;
flex-direction: column;
align-items: center;
@@ -43,6 +45,10 @@
cursor: pointer;
}
.themeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
* step has no alias-layer name). */
.selected {
@@ -1,3 +1,6 @@
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
font-weight: 500 in this UI — non-variable webfonts snap intermediate
weights unpredictably across platforms. */
body {
--dsw-static-amber-100: rgb(254, 245, 231);
--dsw-static-amber-400: rgb(247, 173, 49);
@@ -20,7 +23,7 @@ body {
--dsw-static-deepseek-300: rgb(183, 200, 254);
--dsw-static-deepseek-400: rgb(103, 158, 254);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(57, 100, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
@@ -95,7 +98,7 @@ body[data-ds-dark-theme] {
--dsw-static-deepseek-300: rgb(183, 200, 254);
--dsw-static-deepseek-400: rgb(103, 158, 254);
--dsw-static-deepseek-450: rgb(86, 134, 254);
--dsw-static-deepseek-500: rgb(57, 100, 254);
--dsw-static-deepseek-500: rgb(65, 118, 230);
--dsw-static-deepseek-50: rgb(237, 243, 254);
--dsw-static-deepseek-600: rgb(72, 104, 178);
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
@@ -302,7 +305,7 @@ body[data-ds-dark-theme] {
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
@@ -129,6 +129,7 @@
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
.rail .sectionHeader {
gap: 0;
padding-left: 0;
margin-bottom: 12px;
}
@@ -265,6 +265,7 @@ export function WorkspaceBrowser({
// states; the menu anchors on this button).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
const wsPlusRef = useRef<HTMLButtonElement>(null)
const composingRef = useRef(false)
// Rail search = expand + land in the search box: the flag arms before the
// expand request; once the shell flips wide the input mounts and takes focus.
@@ -358,7 +359,6 @@ export function WorkspaceBrowser({
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
if (!wide) expandSidebar()
setWsPickerOpen(v => !v)
}}
>
@@ -372,6 +372,8 @@ export function WorkspaceBrowser({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
createOnly
side="right"
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
@@ -459,9 +461,12 @@ export function WorkspaceBrowser({
aria-label="Workspace name"
autoFocus
disabled={renaming}
onFocus={(e) => { e.target.select() }}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !composingRef.current) {
e.preventDefault()
confirmRename()
}
@@ -5,7 +5,7 @@
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps {
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
/** Only show create actions (open folder / create new), hide existing workspaces. */
createOnly?: boolean
/** Menu opening direction relative to the anchor. */
side?: 'bottom' | 'top' | 'right'
/** Currently active workspace (trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
}
/**
@@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({
pickDirectory,
onPick,
onClose,
createOnly = false,
side = 'bottom',
selectedId,
}: WorkspaceCreateFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
@@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({
const [modalError, setModalError] = useState<string | null>(null)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
...workspaces.map(workspace => ({
const createEntries: MenuEntry[] = [
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
const pinCreate = !createOnly && workspaces.length > 0
const items: MenuEntry[] = pinCreate
? workspaces.map(workspace => ({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
]
}))
: createEntries
const closeModal = (): void => {
if (creating) return
@@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({
}
if (id === CREATE_NEW) {
onClose()
setWorkspaceName('workspace')
setWorkspaceName('')
setModalError(null)
setModalKind('create')
return
@@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({
open={open}
anchor={null}
items={items}
{...pinCreate ? { footer: createEntries } : {}}
selectedId={selectedId}
onSelect={handleSelect}
onClose={onClose}
side={side}
portal
getAnchorRect={getAnchorRect}
/>
@@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({
<input
className={css.modalInput}
value={workspaceName}
placeholder="Workspace name"
aria-label="New workspace name"
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
onCompositionStart={() => { composingRef.current = true }}
onCompositionEnd={() => { composingRef.current = false }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.key === 'Enter' && !composingRef.current) {
event.preventDefault()
confirmCreate()
}
@@ -225,6 +245,7 @@ export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
selectedId,
onPick,
onClose,
createWorkspace,
@@ -237,6 +258,7 @@ export function WorkspacePicker({
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}
/>
@@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => {
}
})
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
rerender(b, { wide: true })
// The picker menu is open (anchored on the ); picking starts a session.
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
expect(screen.queryByRole('menu')).toBeNull()
// Wide toggle: open and close without expand requests.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
expect(expandSidebar).not.toHaveBeenCalled()
// createOnly: existing workspaces are not listed, only the create actions.
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(expandSidebar).toHaveBeenCalledTimes(1)
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
@@ -205,6 +205,8 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseItem('Create a new workspace')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')