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.
This commit is contained in:
creatixchu
2026-08-11 18:00:56 +08:00
parent 87e3c95027
commit dc42e6b822
17 files changed
+158 -42

No files matched your search

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