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