Merge remote-tracking branch 'origin/master' into xtr/sidebar-workspace-controls

This commit is contained in:
_Kerman
2026-08-11 19:58:15 +08:00
383 changed files with 8182 additions and 2256 deletions
@@ -0,0 +1,70 @@
/* Transient top-center banner (DeepSeek Chat toast look): contrast fill,
inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST
agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their
sum, so a mismatched sheet either cuts the fade or leaves an invisible
banner blocking nothing. */
.toast {
position: fixed;
top: 120px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
z-index: 1100;
/* Purely an announcement: it must never intercept clicks — in particular
after the CSS fade finished while a throttled background-tab timer has
not yet unmounted the still-hit-testable fixed element. */
pointer-events: none;
display: flex;
align-items: center;
gap: 10px;
max-width: min(560px, calc(100vw - 48px));
padding: 12px 16px;
border-radius: 14px;
background: var(--dsw-alias-button-contrast-fill);
color: var(--dsw-alias-label-primary-inverted);
font-size: 14px;
line-height: 22px;
box-shadow: var(--dsw-shadow-lv3);
transform: translateX(-50%);
animation:
dsh-toast-in 160ms ease-out,
dsh-toast-fade 1000ms ease 3000ms forwards;
}
.icon {
display: grid;
place-items: center;
flex: none;
color: var(--dsw-alias-state-warn-label);
}
.text {
min-width: 0;
}
@keyframes dsh-toast-in {
from {
opacity: 0;
transform: translate(-50%, -6px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@keyframes dsh-toast-fade {
to {
opacity: 0;
}
}
/* Reduced motion drops the slide-in; the delayed fade (an opacity change,
not movement) still ends the banner before the timed unmount. */
@media (prefers-reduced-motion: reduce) {
.toast {
animation: dsh-toast-fade 1000ms ease 3000ms forwards;
}
}
@@ -0,0 +1,59 @@
import { useEffect, useLayoutEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './Toast.module.css'
/** Full-opacity hold before the fade starts. Must agree with the stylesheet's
* toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */
const HOLD_MS = 3000
/** Fade duration. Must agree with the stylesheet's toast-fade duration. */
const FADE_MS = 1000
/**
* Transient top-center banner: slides in, holds at full opacity, fades out,
* then reports done so the owner can unmount it. Re-showing the same text
* restarts the cycle when the owner remounts the component (key it by a
* per-show sequence). Rendered through a body portal so an owner inside a
* transformed or filtered ancestor cannot trap the fixed banner in that
* ancestor's box.
*
* @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, 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" style={left === null ? undefined : { left }}>
{icon !== undefined && <span className={css.icon} aria-hidden>{icon}</span>}
<span className={css.text}>{text}</span>
</div>,
document.body,
)
}
@@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { Toast } from './Toast.tsx'
export { writeClipboard } from './clipboard.ts'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'