Przeglądaj źródła

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.
Yif 3 tygodni temu
rodzic
commit
5813c14dee

+ 22 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css

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

+ 2 - 1
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

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

+ 2 - 0
packages/client/ui-model-selection/package.json

@@ -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:^",

+ 37 - 6
packages/client/ui-model-selection/src/client/ModelSelect.module.css

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

+ 56 - 7
packages/client/ui-model-selection/src/client/ModelSelect.tsx

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

+ 37 - 0
packages/client/ui-model-selection/tests/model-select.client.spec.tsx

@@ -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
packages/client/ui-primitives/src/Menu.tsx

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

+ 7 - 2
packages/client/ui-primitives/src/Toast.module.css

@@ -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 - 0
pnpm-lock.yaml

@@ -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/react@18.3.31)
       react:
         specifier: ^18.2.0
         version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
 
   packages/client/ui-permission-presets:
     devDependencies: