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 changed files with 367 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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