Browse Source

feat(web): toast anchoring and model-selection rejection banner

The toast sits 120px from the viewport top and centers over its anchor —
the composer card, so it centers on the chat column rather than the window;
a rejected model selection (e.g. picking a text-only model while the
session holds images) announces through the same banner while the in-menu
strip with Retry stays the catalog-load surface. The attachment rail
consumes every wheel tick with a vertical component: a diagonal pan keeps
its horizontal intent and nothing scrolls the conversation behind the
composer.
creatixchu 1 month ago
parent
commit
dc42e6b822

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md
-2026-08-11-web-attachment-display-alignment.md: 0c41b337d90293525c31afd60aa16bbc3f7cf16c
-2026-08-11-web-attachment-display-alignment.zh.md: c2ca9cf546cdb62f3674867ae420e8a4f071f98f
+2026-08-11-web-attachment-display-alignment.md: 18c732c078ef6efd17e5c83708ba6e68ac663ce6
+2026-08-11-web-attachment-display-alignment.zh.md: 8a4222c31cd1fa2ad75ca314513c057fdb780d07

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md

@@ -16,7 +16,7 @@ All of this UI also lived inside `dsh-client-ui-conversation` — the rail inlin
 
 Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot.
 
-Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage.
+Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (120px from the viewport top, horizontally centered over its optional anchor — the composer card, so it sits over the chat column — `role="alert"`, `pointer-events: none`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips, and `ModelSelect` routes rejected model selections through the same atom while its in-menu strip with Retry stays the catalog-load surface; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md

@@ -16,7 +16,7 @@ Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248
 
 附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。
 
-两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。
+两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(距视口顶部 120px,水平中心跟随可选锚点——composer 卡片,因此横幅在聊天列上居中——`role="alert"`、`pointer-events: none`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条,`ModelSelect` 的模型选择被拒也走同一原子,其菜单内带 Retry 的错误条仍是目录加载的呈现面;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。
 
 ## 备选方案
 

+ 2 - 2
packages/client/ui-attachment/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md
-README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1
-README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e
+README.md: 9fab9c23b958606030b1e87fcbfa45130c980947
+README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5

+ 1 - 1
packages/client/ui-attachment/README.md

@@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac
 
 ## Attachment rail
 
-`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
+`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist.
 
 ## Message images and the lightbox
 

+ 1 - 1
packages/client/ui-attachment/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 附件栏
 
-`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件,同一次滚动不会同时滚动会话记录;LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
+`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。附件栏只允许横向滚动:非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进(LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。
 
 ## 消息图片与灯箱
 

+ 13 - 8
packages/client/ui-attachment/src/AttachmentRail.tsx

@@ -109,20 +109,25 @@ export function AttachmentRail<T extends AttachmentRailItem>({ items, labels, on
       observer.observe(el)
       disconnect = () => { observer.disconnect() }
     }
-    // A vertical wheel pans the rail horizontally and is consumed: without
-    // preventDefault the same tick would also scroll the conversation behind
-    // the composer. React's root wheel listener is passive, so the exclusive
-    // conversion needs this manually attached non-passive listener. LINE and
-    // PAGE deltas (Firefox notch wheels) are normalized to pixels before the
-    // per-tick clamp that keeps a fast wheel followable.
+    // The rail scrolls horizontally ONLY: any wheel tick with a vertical
+    // component is consumed — without preventDefault it would also scroll the
+    // conversation behind the composer, and React's root wheel listener is
+    // passive, so the exclusion needs this manually attached non-passive
+    // listener. A diagonal trackpad pan keeps its horizontal intent; a pure
+    // vertical wheel converts to a horizontal step, with LINE and PAGE deltas
+    // (Firefox notch wheels) normalized to pixels before the per-tick clamp
+    // that keeps a fast wheel followable. A purely horizontal pan stays
+    // native.
     const onWheel = (event: globalThis.WheelEvent): void => {
-      if (event.deltaX !== 0 || event.deltaY === 0) return
+      if (event.deltaY === 0) return
       const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE
         ? WHEEL_LINE_PX
         : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1
       event.preventDefault()
       el.scrollBy({
-        left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
+        left: event.deltaX !== 0
+          ? event.deltaX * scale
+          : Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60),
         behavior: 'auto',
       })
     }

+ 7 - 3
packages/client/ui-attachment/tests/attachment-rail.spec.tsx

@@ -128,10 +128,14 @@ describe('AttachmentRail', () => {
     expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' })
     fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE })
     expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' })
-    // A trackpad pan (deltaX) and a zero-delta wheel keep native behavior.
-    expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true)
+    // A diagonal pan is consumed too — nothing vertical may escape the rail —
+    // and keeps its horizontal intent.
+    expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false)
+    expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' })
+    // A purely horizontal pan and a zero-delta wheel keep native behavior.
+    expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true)
     fireEvent.wheel(rail, { deltaY: 0 })
-    expect(scrollBy).toHaveBeenCalledTimes(5)
+    expect(scrollBy).toHaveBeenCalledTimes(6)
   })
 
   it('pages instantly under a reduced-motion preference, smoothly otherwise', () => {

+ 3 - 0
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -89,6 +89,7 @@ export function InputBar({
     if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
   }, [promptError, showToast])
   const inputRef = useRef<HTMLTextAreaElement | null>(null)
+  const cardRef = useRef<HTMLDivElement | null>(null)
   const dragDepthRef = useRef(0)
   const scrollRef = useRef<HTMLDivElement | null>(null)
   const mirrorRef = useRef<HTMLDivElement | null>(null)
@@ -577,6 +578,7 @@ export function InputBar({
           key={toast.seq}
           text={toast.text}
           icon={<IconWarningOutline16 />}
+          anchor={cardRef.current}
           onDone={dismissToast}
         />
       )}
@@ -591,6 +593,7 @@ export function InputBar({
           pointerdown stops here so the Menu's outside-close cannot race the
           click's reopen (close-then-open flickers the chip's open echo). */}
       <div
+        ref={cardRef}
         className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
         data-composer-card
         onClick={workspaceTrigger ? onRequestWorkspace : undefined}

+ 51 - 14
packages/client/ui-model/src/client/ModelSelect.tsx

@@ -7,7 +7,9 @@
  * ToggleButton) shows both: model name + effort in the caption tone.
  * Data and submission ride the SAME per-session ModelDirectory as the
  * /model popup; exact-model reasoning metadata and the selected effort come
- * from the Host rather than a client-owned vocabulary.
+ * from the Host rather than a client-owned vocabulary. A rejected selection
+ * announces through the shared transient Toast anchored to the composer
+ * card; the in-menu strip with Retry remains the catalog-load surface.
  */
 import {
   useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
@@ -17,6 +19,7 @@ import clsx from 'clsx'
 import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-client-connection/client'
 import {
   IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
+  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'
@@ -49,6 +52,13 @@ export function ModelSelect(
   )
   const [open, setOpen] = useState(false)
   const [pane, setPane] = useState<Pane>('root')
+  // The in-menu error strip serves catalog loads (its Retry re-runs the
+  // load); a rejected SELECTION announces through the transient toast
+  // instead, so the strip renders only while the latest failure-capable
+  // action was a load.
+  const lastActionRef = useRef<'load' | 'select'>('load')
+  const [toast, setToast] = useState<{ seq: number; text: string } | null>(null)
+  const toastSeq = useRef(0)
   const rootRef = useRef<HTMLDivElement | null>(null)
   const triggerRef = useRef<HTMLButtonElement | null>(null)
   const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
@@ -92,9 +102,17 @@ export function ModelSelect(
     ], [reasoning, t])
   const busy = state.status === 'selecting'
 
+  const reload = (): void => {
+    lastActionRef.current = 'load'
+    load()
+  }
+
   // Mount-time load resolves the trigger label; every open refreshes.
   useEffect(() => {
-    if (available) load()
+    if (available) {
+      lastActionRef.current = 'load'
+      load()
+    }
   }, [available, load])
 
   useEffect(() => {
@@ -111,7 +129,7 @@ export function ModelSelect(
   const show = (): void => {
     setPane('root')
     setOpen(true)
-    load()
+    reload()
   }
 
   const close = (restoreFocus = false): void => {
@@ -148,14 +166,25 @@ export function ModelSelect(
     close()
   }
 
+  const settleSelection = (accepted: boolean): void => {
+    if (accepted) {
+      if (rootRef.current !== null) close(true)
+      return
+    }
+    const message = directory.getSnapshot().error
+    if (message !== null) {
+      toastSeq.current += 1
+      setToast({ seq: toastSeq.current, text: t('error.action', { message }) })
+    }
+  }
+
   const choose = (selection: ModelSelection): void => {
     if (state.current?.provider === selection.provider && state.current.model === selection.model) {
       close(true)
       return
     }
-    void select(selection).then((accepted) => {
-      if (accepted && rootRef.current !== null) close(true)
-    })
+    lastActionRef.current = 'select'
+    void select(selection).then(settleSelection)
   }
 
   const chooseEffort = (effort: string | undefined): void => {
@@ -169,9 +198,8 @@ export function ModelSelect(
       model: state.current.model,
       ...effort === undefined ? {} : { reasoningEffort: effort },
     }
-    void select(selection).then((accepted) => {
-      if (accepted && rootRef.current !== null) close(true)
-    })
+    lastActionRef.current = 'select'
+    void select(selection).then(settleSelection)
   }
 
   const modelLabel = currentChoice?.model.name ?? t('trigger.fallback')
@@ -243,16 +271,16 @@ export function ModelSelect(
               {state.status === 'loading' && (
                 <div className={css.status}>{t('status.loading')}</div>
               )}
-              {state.error !== null && (
+              {state.error !== null && lastActionRef.current === 'load' && (
                 <div className={css.error}>
                   <span>{t('error.action', { message: state.error })}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
+                  <button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
                 </div>
               )}
               {state.failures.map(failure => (
                 <div className={css.warning} key={failure.id}>
                   <span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
+                  <button type="button" className={css.retry} onClick={reload}>{t('retry')}</button>
                 </div>
               ))}
               <div className={clsx(css.groups, 'scrollable')}>
@@ -299,10 +327,10 @@ export function ModelSelect(
 
           {pane === 'effort' && (
             <>
-              {state.error !== null && (
+              {state.error !== null && lastActionRef.current === 'load' && (
                 <div className={css.error}>
                   <span>{t('error.action', { message: state.error })}</span>
-                  <button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
+                  <button type="button" className={css.retry} onClick={reload}>{t('action.reload')}</button>
                 </div>
               )}
               {effortChoices.length === 0
@@ -333,6 +361,15 @@ export function ModelSelect(
           )}
         </div>
       )}
+      {toast !== null && (
+        <Toast
+          key={toast.seq}
+          text={toast.text}
+          icon={<IconWarningOutline16 />}
+          anchor={rootRef.current?.closest<HTMLElement>('[data-composer-card]') ?? null}
+          onDone={() => { setToast(null) }}
+        />
+      )}
     </div>
   )
 }

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

@@ -135,6 +135,38 @@ describe('ModelSelect reasoning effort', () => {
     expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy()
   })
 
+  it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => {
+    const groups = [{
+      id: 'deepseek-official',
+      name: 'DeepSeek',
+      models: [
+        { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning },
+        { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
+      ],
+    }]
+    const directory = createSnapshotStore<ModelDirectoryState>(state({ groups }))
+    const select = vi.fn(async () => {
+      directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' }))
+      return false
+    })
+    render(<ModelSelect
+      locked={false}
+      available
+      directory={directory}
+      load={vi.fn()}
+      select={select}
+      t={t}
+    />)
+
+    fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: /模型/ }))
+    fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ }))
+    const toast = await screen.findByRole('alert')
+    expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images')
+    // The selection failure does not render the in-menu load strip (no Retry).
+    expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
+  })
+
   it('renders no Agent-bound control for an addressed subagent session', () => {
     const load = vi.fn()
     render(<ModelSelect

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 6e2cfed2578a59eec10f6d50b2bb5da3c7019764
-README.zh.md: d651d9fac1c597b33ec95ffe9a0b69b3a69455a1
+README.md: f96e931472a0946bd023b97b034643f079a67e44
+README.zh.md: 0db2ac1bccbdc4793c488e1ce621ee5c674d9895

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
 
 ## Toast
 
-`Toast` is the transient top-center banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable.
+`Toast` is the transient top banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). It body-portals with `pointer-events: none`, sits 120px from the viewport top, and centers horizontally over the optional `anchor` element (re-measured on window resizes) — the composer passes its card so the banner centers over the chat column rather than the whole window — falling back to the viewport center without one. Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. Under `prefers-reduced-motion: reduce` the slide-in is dropped and only the delayed fade remains. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable.
 
 ## Markdown rendering
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -10,7 +10,7 @@
 
 ## Toast
 
-`Toast` 是顶部居中的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop(零 cordis,由持有方本地化)。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key,让相同文案重新走完停留与淡出,而不是静默复用已淡出的横幅。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。
+`Toast` 是顶部的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop(零 cordis,由持有方本地化)。它经 body portal 渲染且 `pointer-events: none`,距视口顶部 120px,水平中心跟随可选的 `anchor` 元素(窗口尺寸变化时重测)——composer 传入自己的卡片,横幅因此在聊天列而非整个窗口上居中——不传则回退到视口居中。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key,让相同文案重新走完停留与淡出,而不是静默复用已淡出的横幅。`prefers-reduced-motion: reduce` 下去掉滑入,只保留延迟淡出。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。
 
 ## Markdown 渲染
 

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

@@ -6,7 +6,7 @@
 
 .toast {
   position: fixed;
-  top: 80px;
+  top: 120px;
   left: 50%;
   /* Above the 1000 the image lightbox backdrop uses: a failure reported while
      a preview is open must stay readable. */

+ 21 - 3
packages/client/ui-primitives/src/Toast.tsx

@@ -1,4 +1,4 @@
-import { useEffect } from 'react'
+import { useEffect, useLayoutEffect, useState } from 'react'
 import type { ReactNode } from 'react'
 import { createPortal } from 'react-dom'
 import css from './Toast.module.css'
@@ -19,20 +19,38 @@ const FADE_MS = 1000
  *
  * @param props.text - resolved banner copy; the owner passes localized text.
  * @param props.icon - optional leading glyph (e.g. a warning icon).
+ * @param props.anchor - optional element whose horizontal center the banner
+ * follows (e.g. the composer card, so the banner centers over the chat column
+ * rather than the whole window); omitted, it centers on the viewport.
  * @param props.onDone - called once the fade completes; unmount the toast here.
  * @returns the floating banner.
  */
-export function Toast({ text, icon, onDone }: {
+export function Toast({ text, icon, anchor, onDone }: {
   text: string
   icon?: ReactNode
+  anchor?: HTMLElement | null
   onDone: () => void
 }) {
   useEffect(() => {
     const timer = setTimeout(onDone, HOLD_MS + FADE_MS)
     return () => { clearTimeout(timer) }
   }, [onDone])
+  // Anchor-centered placement re-measures on window resizes; the banner lives
+  // four seconds, so sub-window layout drift within that span stays out of
+  // scope.
+  const [left, setLeft] = useState<number | null>(null)
+  useLayoutEffect(() => {
+    if (anchor == null) return
+    const measure = (): void => {
+      const rect = anchor.getBoundingClientRect()
+      setLeft(rect.left + rect.width / 2)
+    }
+    measure()
+    window.addEventListener('resize', measure)
+    return () => { window.removeEventListener('resize', measure) }
+  }, [anchor])
   return createPortal(
-    <div className={css.toast} role="alert">
+    <div className={css.toast} role="alert" style={left === null ? undefined : { left }}>
       {icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
       <span className={css.text}>{text}</span>
     </div>,

+ 18 - 1
packages/client/ui-primitives/tests/toast.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render } from '@testing-library/react'
+import { cleanup, fireEvent, render } from '@testing-library/react'
 import { Toast } from '../src/Toast.tsx'
 
 afterEach(cleanup)
@@ -24,6 +24,23 @@ describe('Toast', () => {
     }
   })
 
+  it('centers over its anchor and re-measures on window resize', () => {
+    vi.useFakeTimers()
+    try {
+      const anchor = document.createElement('div')
+      document.body.appendChild(anchor)
+      anchor.getBoundingClientRect = () => ({ left: 100, width: 400 }) as DOMRect
+      const view = render(<Toast text="anchored" anchor={anchor} onDone={vi.fn()} />)
+      expect(view.getByRole('alert').style.left).toBe('300px')
+      anchor.getBoundingClientRect = () => ({ left: 200, width: 400 }) as DOMRect
+      fireEvent(window, new Event('resize'))
+      expect(view.getByRole('alert').style.left).toBe('400px')
+      anchor.remove()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('renders without an icon and cancels its timer on unmount', () => {
     vi.useFakeTimers()
     try {