fix(webui): polish responsive layout

This commit is contained in:
chengyongru
2026-07-23 18:22:02 +08:00
committed by chengyongru
parent 4b1547db7d
commit 6c0f151f6e
20 changed files with 372 additions and 80 deletions
+88 -40
View File
@@ -270,6 +270,11 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
const CLI_APPS_REFRESH_RETRY_MS = 2_000;
const CLI_APPS_REFRESH_MAX_RETRIES = 30;
const SETTINGS_SEARCH_INPUT_CLASS = cn(
"border-border/45 bg-settings-surface transition-colors hover:border-border/70",
"focus-visible:border-border/70 focus-visible:bg-background",
"focus-visible:ring-0 focus-visible:ring-offset-0",
);
const FALLBACK_TIMEZONES = [
"UTC",
@@ -2200,23 +2205,12 @@ function SettingsSidebar({
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const navRef = useRef<HTMLElement>(null);
const activeItemRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const nav = navRef.current;
const activeItem = activeItemRef.current;
if (!nav || !activeItem || nav.scrollWidth <= nav.clientWidth) return;
const navRect = nav.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
const itemCenter = itemRect.left - navRect.left + nav.scrollLeft + itemRect.width / 2;
const targetLeft = Math.max(
0,
Math.min(nav.scrollWidth - nav.clientWidth, itemCenter - nav.clientWidth / 2),
);
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
nav.scrollTo({ left: targetLeft, behavior: reducedMotion ? "auto" : "smooth" });
}, [activeSection]);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
const activeLabel = t(`settings.nav.${activeItem.key}`, {
defaultValue: activeItem.fallback,
});
return (
<aside
@@ -2240,31 +2234,73 @@ function SettingsSidebar({
</div>
<nav
ref={navRef}
aria-label={t("settings.sidebar.ariaLabel")}
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:mx-0 lg:block lg:space-y-1 lg:overflow-visible lg:px-0 lg:pb-0"
className="w-full"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
ref={active ? activeItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-auto shrink-0 items-center gap-2 rounded-full px-3 text-left text-[13px] font-medium transition-colors lg:w-full lg:rounded-[10px] lg:px-2.5",
active
? "bg-sidebar-accent text-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
className="touch-target flex h-11 w-full items-center gap-2.5 rounded-[14px] bg-sidebar-accent px-3 text-left text-[13px] font-medium text-foreground transition-colors hover:bg-sidebar-accent/80 lg:hidden"
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">{t(`settings.nav.${key}`, { defaultValue: fallback })}</span>
<ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">{activeLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
);
})}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={6}
className="w-[var(--radix-dropdown-menu-trigger-width)] max-w-[calc(100vw-1.5rem)] rounded-[16px] p-1.5"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<DropdownMenuItem
key={key}
aria-current={active ? "page" : undefined}
onSelect={() => onSelectSection(key)}
className={cn(
"flex h-10 cursor-default items-center gap-2.5 rounded-[11px] px-2.5 text-[13px] font-medium",
active && "bg-sidebar-accent text-foreground focus:bg-sidebar-accent",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="min-w-0 flex-1 truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
{active ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="hidden space-y-1 lg:block">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
active
? "bg-sidebar-accent text-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">
{t(`settings.nav.${key}`, { defaultValue: fallback })}
</span>
</button>
);
})}
</div>
</nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4">
@@ -3511,7 +3547,10 @@ function ProvidersSettings({
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.providers.searchPlaceholder", "Search providers")}
className="h-10 rounded-full border-border/45 bg-settings-surface pl-9 text-[13px]"
className={cn(
"h-10 rounded-full pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<ProviderSection
@@ -4220,7 +4259,10 @@ function AutomationsSettings({
"settings.automations.search",
"Search task, message, linked chat, or schedule",
)}
className="h-9 w-full rounded-[13px] border-border/45 bg-settings-surface pl-9 text-[13px]"
className={cn(
"h-9 w-full rounded-[13px] pl-9 text-[13px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<DropdownMenu>
@@ -5830,7 +5872,10 @@ function ChannelsSettings({
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")}
className="h-12 rounded-[14px] border-border/45 bg-settings-surface pl-11 text-[15px]"
className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1">
@@ -6069,7 +6114,10 @@ function AppsCatalogSettings({
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
className="h-12 rounded-[14px] border-border/45 bg-settings-surface pl-11 text-[15px]"
className={cn(
"h-12 rounded-[14px] pl-11 text-[15px]",
SETTINGS_SEARCH_INPUT_CLASS,
)}
/>
</div>
<SegmentedControl
+26 -12
View File
@@ -1879,7 +1879,7 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
@@ -2019,13 +2019,21 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"flex flex-nowrap items-center",
"thread-composer-footer flex flex-nowrap items-center",
isHero
? cn("gap-x-1.5 px-3 sm:px-4", showProjectPicker ? "pb-1.5" : "pb-3.5")
? cn(
"gap-x-1.5 px-3 sm:px-4",
showProjectPicker ? "pb-1.5" : "pb-3.5",
)
: "gap-x-2 px-2.5 pb-2 sm:px-3",
)}
>
<div className={cn("flex min-w-0 flex-1 basis-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
<div
className={cn(
"thread-composer-footer-primary flex min-w-0 flex-1 basis-0 items-center",
isHero ? "gap-1.5" : "gap-2",
)}
>
<input
ref={fileInputRef}
type="file"
@@ -2042,7 +2050,7 @@ export function ThreadComposer({
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"touch-target rounded-full text-muted-foreground hover:text-foreground",
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
@@ -2068,7 +2076,12 @@ export function ThreadComposer({
/>
) : null}
</div>
<div className={cn("ml-auto flex min-w-0 shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
<div
className={cn(
"thread-composer-footer-actions ml-auto flex min-w-0 items-center justify-end",
isHero ? "gap-1.5" : "gap-2",
)}
>
{modelLabel && !voiceRecorder.isRecording ? (
<ComposerModelBadge
label={modelLabel}
@@ -2097,7 +2110,7 @@ export function ThreadComposer({
onPointerCancel={voiceRecorder.endPress}
onClick={voiceRecorder.handleClick}
className={cn(
"touch-target rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
"thread-composer-action touch-target rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
isHero ? "h-8 w-8" : "h-9 w-9",
voiceRecorder.isRecording &&
"bg-red-500 text-white shadow-[0_8px_20px_rgba(239,68,68,0.22)] hover:bg-red-500 hover:text-white",
@@ -2140,7 +2153,7 @@ export function ThreadComposer({
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn(
"touch-target rounded-full transition-transform",
"thread-composer-action touch-target rounded-full transition-transform",
showStopButton
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
: isHero
@@ -2388,16 +2401,17 @@ function ComposerModelBadge({
<Container
data-fallback={fallbackModelName ? "true" : undefined}
title={fallbackModelName || title}
aria-label={label}
type={interactive ? "button" : undefined}
onClick={onClick}
className={cn(
"composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"composer-model-badge thread-composer-model-badge inline-flex min-w-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/82",
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero
? "h-8 max-w-[min(7.5rem,32vw)] gap-1.5 px-2 text-[11.5px] sm:max-w-[min(12.5rem,44vw)]"
: "h-9 max-w-[min(7.5rem,32vw)] gap-2 px-2.5 text-[12px] sm:max-w-[min(12rem,44vw)]",
? "h-8 max-w-[min(12.5rem,44vw)] gap-1.5 px-2 text-[11.5px]"
: "h-9 max-w-[min(12rem,44vw)] gap-2 px-2.5 text-[12px]",
)}
>
<span
@@ -2441,7 +2455,7 @@ function ComposerModelBadge({
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
)}
</span>
<span className="truncate">{label}</span>
<span className="thread-composer-model-label truncate">{label}</span>
</Container>
);
}
+71 -3
View File
@@ -226,6 +226,76 @@ const HERO_GREETING_KEYS = [
"thread.empty.greetings.tackle",
] as const;
function HeroGreeting({ text }: { text: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
useLayoutEffect(() => {
const container = containerRef.current;
const heading = headingRef.current;
if (!container || !heading) return;
const fitToWidth = () => {
heading.style.removeProperty("font-size");
const availableWidth = container.clientWidth;
if (availableWidth <= 0) return;
const naturalWidth = heading.scrollWidth;
const maximumFontSize = Number.parseFloat(window.getComputedStyle(heading).fontSize);
if (
naturalWidth <= availableWidth
|| !Number.isFinite(maximumFontSize)
|| maximumFontSize <= 0
) {
return;
}
const fittedFontSize = Math.max(
12,
Math.floor(maximumFontSize * ((availableWidth - 2) / naturalWidth) * 100) / 100,
);
heading.style.fontSize = `${fittedFontSize}px`;
};
fitToWidth();
let lastObservedWidth = container.clientWidth;
const resizeObserver = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(([entry]) => {
const nextWidth = entry?.contentRect.width ?? container.clientWidth;
if (nextWidth === lastObservedWidth) return;
lastObservedWidth = nextWidth;
fitToWidth();
});
resizeObserver?.observe(container);
window.addEventListener("resize", fitToWidth);
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) fitToWidth();
});
return () => {
cancelled = true;
resizeObserver?.disconnect();
window.removeEventListener("resize", fitToWidth);
};
}, [text]);
return (
<div ref={containerRef} className="min-w-0 w-full max-w-[44rem]">
<h1
ref={headingRef}
data-testid="hero-greeting"
className="whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
>
{text}
</h1>
</div>
);
}
function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
@@ -890,9 +960,7 @@ export function ThreadShell({
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="max-w-[44rem] text-balance text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight">
{t(heroGreetingKey)}
</h1>
<HeroGreeting text={t(heroGreetingKey)} />
</div>
);
const sessionInfoAction = historyKey ? (
@@ -581,7 +581,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
data-testid="thread-welcome-layout"
className="relative grid w-full max-w-[58rem] flex-1 grid-rows-[minmax(min-content,1fr)_auto] gap-8 sm:block sm:flex-none"
>
<div className="flex min-h-0 items-center justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+2rem)]">
<div className="flex min-h-0 min-w-0 w-full items-center justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+2rem)]">
{emptyState}
</div>
<div className="w-full">{composer}</div>
@@ -239,6 +239,13 @@ export function WorkspaceAccessMenu({
const { t } = useTranslation();
const mode = scope.access_mode;
const isFull = mode === "full";
const accessLabel = t(
isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default",
);
const shortAccessLabel = t(
isFull ? "thread.composer.workspace.fullShort" : "thread.composer.workspace.defaultShort",
);
const accessAriaLabel = `${t("thread.composer.workspace.accessAria")}: ${accessLabel}`;
const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return;
@@ -252,9 +259,10 @@ export function WorkspaceAccessMenu({
<Button
type="button"
variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")}
aria-label={accessAriaLabel}
title={accessLabel}
className={cn(
"touch-target min-w-0 max-w-[min(7rem,30vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none sm:max-w-[min(12.5rem,42vw)]",
"thread-composer-access touch-target min-w-0 max-w-[min(12.5rem,42vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none",
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
@@ -262,14 +270,17 @@ export function WorkspaceAccessMenu({
)}
>
{isFull ? (
<AlertTriangle className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
<AlertTriangle className={cn("thread-composer-access-icon mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
) : (
<Hand className={cn("mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
<Hand className={cn("thread-composer-access-icon mr-1.5 shrink-0", isHero ? "h-3.5 w-3.5" : "h-3.5 w-3.5")} />
)}
<span className={cn("min-w-0 truncate", isFull && "hidden sm:inline")}>
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")}
<span aria-hidden className="thread-composer-access-label-full min-w-0 truncate">
{accessLabel}
</span>
<ChevronDown className={cn("ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
<span aria-hidden className="thread-composer-access-label-short hidden min-w-0 truncate">
{shortAccessLabel}
</span>
<ChevronDown className={cn("thread-composer-access-chevron ml-1.5 shrink-0", isHero ? "h-3 w-3" : "h-3 w-3")} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">