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:
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+6
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user