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:
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user