Merge remote-tracking branch 'origin/master' into feat/web-terminal-card

# Conflicts:
#	packages/client/ui-conversation/src/client/chat/ToolRow.tsx
This commit is contained in:
Chinesezjc
2026-07-28 16:43:15 +08:00
183 changed files with 2401 additions and 1402 deletions

View File

@@ -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);
}

View File

@@ -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;
}

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>
)

View File

@@ -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);
}

View File

@@ -1,7 +1,7 @@
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
* not this 450) — component-level var pinned to the static scale instead. */
.dot,
.ring {
.matrix {
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
}
@@ -42,24 +42,24 @@
color: var(--dsw-alias-state-error-primary);
}
.ring {
/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
* holds, no tweening — the retro feel), peaking when the chase hits it and
* decaying over the next three cells. Phase offsets come from per-rect
* animation-delay (index * -125ms) set inline by the component. */
.matrix {
flex: none;
color: var(--dsh-state-ongoing);
animation: dsh-state-dot-spin 1s linear infinite;
}
.stopFrom {
stop-color: currentColor;
stop-opacity: 1;
.cell {
fill: currentColor;
opacity: 0.15;
animation: dsh-state-dot-chase 1s infinite;
}
.stopTo {
stop-color: currentColor;
stop-opacity: 0;
}
@keyframes dsh-state-dot-spin {
to {
transform: rotate(360deg);
}
@keyframes dsh-state-dot-chase {
0%, 12.4% { opacity: 1; }
12.5%, 24.9% { opacity: 0.6; }
25%, 37.4% { opacity: 0.35; }
37.5%, 100% { opacity: 0.15; }
}

View File

@@ -1,15 +1,19 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
import { useId } from 'react'
import clsx from 'clsx'
import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
const MATRIX_CELLS: readonly (readonly [number, number])[] = [
[0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
]
/**
* Render a state dot.
* @param props.state - which of the four states to show.
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
size?: number | undefined
className?: string | undefined
}) {
const gradientId = useId()
if (state === 'ongoing') {
return (
<svg
className={clsx(css.ring, className)}
className={clsx(css.matrix, className)}
data-state="ongoing"
width={size}
height={size}
viewBox="0 0 10 10"
shapeRendering="crispEdges"
aria-hidden="true"
>
<defs>
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */}
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse">
<stop className={css.stopFrom} offset="0" />
<stop className={css.stopTo} offset="1" />
</linearGradient>
</defs>
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} />
{MATRIX_CELLS.map(([x, y], index) => (
<rect
key={`${x}-${y}`}
className={css.cell}
x={x}
y={y}
width="2"
height="2"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
/>
))}
</svg>
)
}

View File

@@ -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() },
})}

View File

@@ -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/)
})
})

View File

@@ -14,16 +14,17 @@ describe('StateDot', () => {
expect(dot.getAttribute('aria-hidden')).toBe('true')
})
it('solid states are spans; ongoing is an svg gradient ring', () => {
it('solid states are spans; ongoing is an svg pixel matrix', () => {
const { container, rerender } = render(<StateDot state="done" />)
expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender(<StateDot state="ongoing" />)
const ring = container.firstElementChild as SVGSVGElement
expect(ring.tagName).toBe('svg')
const circle = ring.querySelector('circle')
expect(circle?.getAttribute('stroke-width')).toBe('1')
expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
expect(ring.querySelector('linearGradient')).not.toBeNull()
const matrix = container.firstElementChild as SVGSVGElement
expect(matrix.tagName).toBe('svg')
const cells = matrix.querySelectorAll('rect')
expect(cells).toHaveLength(8)
// Chase phase: every cell carries its own negative animation delay.
const delays = [...cells].map(cell => (cell).style.animationDelay)
expect(new Set(delays).size).toBe(8)
})
it('sizes via the size prop in both shapes', () => {

View File

@@ -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)