fix(client): portal composer menus over the sidebar and float toasts near the top

ModelSelect renders its dropdown through a body portal with viewport-clamped
fixed placement, so the panel keeps its full width instead of being cut by the
sidebar edge. The preset seat passes Menu an anchor class with an icon-only
min-width floor and moves its label ellipsis onto a dedicated span, keeping the
chip's overflow degradation breakpoint-free. Toasts move to the viewport top
and shrink to fit their text.
This commit is contained in:
Yif committed 2026-09-04 20:19:42 +08:00
1 parent b4ea3efcf6
commit 5813c14dee
9 files changed
+170 -19

No files matched your search

@@ -1,10 +1,25 @@
/* Agent-preset chip on the new-session screen, beside the workspace picker.
Geometry mirrors HeroShell's .workspace so the two read as one row. */
/* Overflow-driven degradation (mirrors HeroShell's .workspace), no
breakpoint: the label shrinks with an ellipsis down to nothing, so the
preset icon and chevron always sit snug against whatever text remains —
full text, ellipsized text, or none. */
/* The Menu anchor wrapper between the row and this chip: its flex
auto-minimum would hold the full label width and the row could never
compress the chip. The explicit floor is the icon-only end state — 8px
paddings + 16px icon + two 4px gaps + 14px chevron — so the glyphs never
crush once the label has ellipsized away to nothing. */
.menuAnchor {
min-width: 54px;
max-width: 100%;
}
.seat {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: min(100%, 240px);
min-height: 28px;
padding: 0 8px;
@@ -16,8 +31,6 @@
line-height: 20px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
@@ -36,6 +49,13 @@
color: var(--dsw-alias-label-primary);
}
.seatLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
then the name's characters fade up on a stagger (delays set inline per
@@ -181,6 +181,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
}}
align="start"
portal
className={css.menuAnchor}
anchor={(
<button
type="button"
@@ -192,7 +193,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
onClick={() => { setOpen(value => !value) }}
>
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
{shownLabel}
<span className={css.seatLabel}>{shownLabel}</span>
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
@@ -58,6 +58,8 @@
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
@@ -49,12 +49,42 @@
white-space: nowrap;
}
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption
tone). Adaptive priority: the huge shrink factor makes the effort absorb
all deficit first — it ellipsizes away to nothing before the model name
loses a single pixel, and the chevron stays snug throughout. */
.triggerEffort {
flex: 0 0 auto;
flex-shrink: 1000;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-caption);
}
/* Stand-in for the whole text below the icon-only cut: the settings page's
Models glyph (IconDataOutline16), hidden while any text fits. */
.triggerIcon {
display: none;
flex: 0 0 auto;
}
/* Icon-only floor: past continuous shrink (effort ellipsizes away first, then
the name), a 360px composer row leaves the 45cqw cap too little for a
legible name, so the text yields to the Models glyph. Same anonymous
container as PermissionSelect's 460px cut (InputBar .row); the full name
stays reachable through the trigger's title and aria-label. */
@container (max-width: 360px) {
.triggerIcon {
display: block;
}
.triggerLabel,
.triggerEffort {
display: none;
}
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
@@ -65,11 +95,12 @@
transform: rotate(180deg);
}
/* Portaled to body and placed from the trigger rect by the component (the
Menu primitive's portal rules), so the sidebar and the columns' overflow
clips cannot crop it; z matches the primitive's .portal layer. */
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
/* Let the visible pane's rows set the card width, but keep pathological ids
@@ -12,14 +12,15 @@
* card; the in-menu strip with Retry remains the catalog-load surface.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
type CSSProperties, type KeyboardEvent, type FocusEvent,
} from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
IconWarningOutline16, Toast,
IconDataOutline16, IconWarningOutline16, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
@@ -35,6 +36,9 @@ interface EffortChoice {
label: string
}
/** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory
@@ -60,6 +64,8 @@ export function ModelSelect(
const toastSeq = useRef(0)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const [menuPos, setMenuPos] = useState<CSSProperties | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
@@ -108,12 +114,45 @@ export function ModelSelect(
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
// The portaled card is outside the trigger subtree; check both.
if (rootRef.current?.contains(event.target as Node) === true) return
if (menuRef.current?.contains(event.target as Node) === true) return
setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
// Portaled placement (the Menu primitive's portal rules: fixed from the
// anchor rect, measured before paint, clamped inside the viewport): above
// the trigger, right edges aligned. Depends on pane and directory state
// because pane switches and async catalog loads resize the card.
useLayoutEffect(() => {
if (!open) { setMenuPos(null); return }
const place = (): void => {
/* v8 ignore next 2 -- the trigger ref is attached whenever the menu is open. */
const rect = triggerRef.current?.getBoundingClientRect()
if (rect === undefined) return
const MARGIN = 12
const lw = menuRef.current?.offsetWidth ?? 0
const lh = menuRef.current?.offsetHeight ?? 0
let x = rect.right - lw
let y = rect.top - 8 - lh
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
setMenuPos({ left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// the card lands placed before anything paints.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, pane, state])
if (!available) return null
const show = (): void => {
@@ -152,7 +191,10 @@ export function ModelSelect(
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
if (event.relatedTarget instanceof Node && (
rootRef.current?.contains(event.relatedTarget) === true
|| menuRef.current?.contains(event.relatedTarget) === true
)) return
close()
}
@@ -232,15 +274,21 @@ export function ModelSelect(
}
}}
>
<IconDataOutline16 className={css.triggerIcon} size={16} />
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
{/* Portaled to body (Menu primitive's portal mode) so the sidebar and
column overflow clips cannot crop the card; synthetic events still
bubble through this React subtree, keeping onKeyDown/onBlur live. */}
{open && createPortal(
<div
ref={menuRef}
id={`${id}-menu`}
className={css.menu}
style={menuPos ?? MEASURE_STYLE}
role="menu"
aria-label={t('menu.aria')}
aria-busy={state.status === 'loading' || busy}
@@ -349,7 +397,8 @@ export function ModelSelect(
))}
</>
)}
</div>
</div>,
document.body,
)}
{toast !== null && (
<Toast
@@ -200,6 +200,43 @@ describe('ModelSelect reasoning effort', () => {
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('portals the placed menu card to body and closes only on truly-outside mousedown', () => {
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
try {
const { container } = render(<ModelSelect
locked={false}
available
directory={createSnapshotStore(state())}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
// Outside the composer subtree — column overflow clips cannot crop it.
expect(container.contains(menu)).toBe(false)
expect(menu.parentElement).toBe(document.body)
// jsdom anchor rects are all zero, so the measured 200x300 card clamps
// to the 12px viewport margin on both axes.
expect(menu.style.left).toBe('12px')
expect(menu.style.top).toBe('12px')
// Interactions inside the trigger subtree or the portaled card stay open.
fireEvent.mouseDown(menu)
fireEvent.mouseDown(trigger)
fireEvent.blur(trigger, { relatedTarget: menu })
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
} finally {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
}
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect
+1 -1
View File
@@ -93,7 +93,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
dense?: boolean
compact?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
className?: string | undefined
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -5,7 +5,7 @@
.toast {
position: fixed;
top: 120px;
top: 40px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
@@ -15,7 +15,12 @@
display: flex;
align-items: center;
gap: 10px;
max-width: min(560px, calc(100vw - 48px));
/* Fixed boxes with `left` set shrink-to-fit against the space RIGHT of
`left` (the -50% translate happens after sizing), so an anchored banner
near the window edge would wrap early. max-content sizes the box from
its text alone, capped by the max-width below. */
width: max-content;
max-width: min(640px, calc(100vw - 48px));
padding: 12px 16px;
border-radius: 14px;
background: var(--dsw-alias-button-contrast-fill);
+6
View File
@@ -2692,9 +2692,15 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/[email protected])
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1([email protected])
packages/client/ui-permission-presets:
devDependencies: