fix(webui): polish responsive layout

This commit is contained in:
chengyongru 2026-07-23 18:18:34 +08:00 committed by chengyongru
parent 4b1547db7d
commit 6c0f151f6e
20 changed files with 372 additions and 80 deletions

View File

@ -270,6 +270,11 @@ const DEFERRED_MODEL_LIST_PROVIDERS = new Set([
const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2; const DEFERRED_MODEL_LIST_QUERY_MIN_LENGTH = 2;
const CLI_APPS_REFRESH_RETRY_MS = 2_000; const CLI_APPS_REFRESH_RETRY_MS = 2_000;
const CLI_APPS_REFRESH_MAX_RETRIES = 30; 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 = [ const FALLBACK_TIMEZONES = [
"UTC", "UTC",
@ -2200,23 +2205,12 @@ function SettingsSidebar({
hostChromeInset?: boolean; hostChromeInset?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navRef = useRef<HTMLElement>(null); const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
const activeItemRef = useRef<HTMLButtonElement>(null); ?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
useEffect(() => { const activeLabel = t(`settings.nav.${activeItem.key}`, {
const nav = navRef.current; defaultValue: activeItem.fallback,
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]);
return ( return (
<aside <aside
@ -2240,31 +2234,73 @@ function SettingsSidebar({
</div> </div>
<nav <nav
ref={navRef}
aria-label={t("settings.sidebar.ariaLabel")} 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 }) => { <DropdownMenu>
const active = key === activeSection; <DropdownMenuTrigger asChild>
return (
<button <button
ref={active ? activeItemRef : undefined}
key={key}
type="button" type="button"
aria-current={active ? "page" : undefined} aria-label={`${t("settings.sidebar.title")}: ${activeLabel}`}
onClick={() => onSelectSection(key)} 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"
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",
)}
> >
<Icon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden /> <ActiveIcon className="h-4 w-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="truncate">{t(`settings.nav.${key}`, { defaultValue: fallback })}</span> <span className="min-w-0 flex-1 truncate">{activeLabel}</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button> </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> </nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4"> <div className="hidden lg:mt-auto lg:block lg:pt-4">
@ -3511,7 +3547,10 @@ function ProvidersSettings({
value={query} value={query}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.providers.searchPlaceholder", "Search providers")} 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> </div>
<ProviderSection <ProviderSection
@ -4220,7 +4259,10 @@ function AutomationsSettings({
"settings.automations.search", "settings.automations.search",
"Search task, message, linked chat, or schedule", "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> </div>
<DropdownMenu> <DropdownMenu>
@ -5830,7 +5872,10 @@ function ChannelsSettings({
value={query} value={query}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.channels.searchPlaceholder", "Search channels")} 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>
<div className="flex shrink-0 flex-wrap gap-1.5 rounded-[14px] bg-muted/55 p-1"> <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} value={query}
onChange={(event) => onQueryChange(event.target.value)} onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")} 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> </div>
<SegmentedControl <SegmentedControl

View File

@ -1879,7 +1879,7 @@ export function ThreadComposer({
) : null} ) : null}
<div <div
className={cn( 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 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-[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]", : "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} ) : null}
<div <div
className={cn( className={cn(
"flex flex-nowrap items-center", "thread-composer-footer flex flex-nowrap items-center",
isHero 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", : "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 <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -2042,7 +2050,7 @@ export function ThreadComposer({
aria-label={t("thread.composer.attachImage")} aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( 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 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-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", : "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} ) : null}
</div> </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 ? ( {modelLabel && !voiceRecorder.isRecording ? (
<ComposerModelBadge <ComposerModelBadge
label={modelLabel} label={modelLabel}
@ -2097,7 +2110,7 @@ export function ThreadComposer({
onPointerCancel={voiceRecorder.endPress} onPointerCancel={voiceRecorder.endPress}
onClick={voiceRecorder.handleClick} onClick={voiceRecorder.handleClick}
className={cn( 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", isHero ? "h-8 w-8" : "h-9 w-9",
voiceRecorder.isRecording && voiceRecorder.isRecording &&
"bg-red-500 text-white shadow-[0_8px_20px_rgba(239,68,68,0.22)] hover:bg-red-500 hover:text-white", "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} onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
className={cn( className={cn(
"touch-target rounded-full transition-transform", "thread-composer-action touch-target rounded-full transition-transform",
showStopButton 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" ? "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 : isHero
@ -2388,16 +2401,17 @@ function ComposerModelBadge({
<Container <Container
data-fallback={fallbackModelName ? "true" : undefined} data-fallback={fallbackModelName ? "true" : undefined}
title={fallbackModelName || title} title={fallbackModelName || title}
aria-label={label}
type={interactive ? "button" : undefined} type={interactive ? "button" : undefined}
onClick={onClick} onClick={onClick}
className={cn( 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)]", "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground", 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", needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero 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-8 max-w-[min(12.5rem,44vw)] gap-1.5 px-2 text-[11.5px]"
: "h-9 max-w-[min(7.5rem,32vw)] gap-2 px-2.5 text-[12px] sm:max-w-[min(12rem,44vw)]", : "h-9 max-w-[min(12rem,44vw)] gap-2 px-2.5 text-[12px]",
)} )}
> >
<span <span
@ -2441,7 +2455,7 @@ function ComposerModelBadge({
<Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} /> <Sparkles className={cn("text-muted-foreground/65", isHero ? "h-3 w-3" : "h-3 w-3")} />
)} )}
</span> </span>
<span className="truncate">{label}</span> <span className="thread-composer-model-label truncate">{label}</span>
</Container> </Container>
); );
} }

View File

@ -226,6 +226,76 @@ const HERO_GREETING_KEYS = [
"thread.empty.greetings.tackle", "thread.empty.greetings.tackle",
] as const; ] 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] { function randomHeroGreetingKey(): (typeof HERO_GREETING_KEYS)[number] {
const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length); const index = Math.floor(Math.random() * HERO_GREETING_KEYS.length);
return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0]; return HERO_GREETING_KEYS[index] ?? HERO_GREETING_KEYS[0];
@ -890,9 +960,7 @@ export function ThreadShell({
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <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"> <HeroGreeting text={t(heroGreetingKey)} />
{t(heroGreetingKey)}
</h1>
</div> </div>
); );
const sessionInfoAction = historyKey ? ( const sessionInfoAction = historyKey ? (

View File

@ -581,7 +581,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
data-testid="thread-welcome-layout" 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" 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} {emptyState}
</div> </div>
<div className="w-full">{composer}</div> <div className="w-full">{composer}</div>

View File

@ -239,6 +239,13 @@ export function WorkspaceAccessMenu({
const { t } = useTranslation(); const { t } = useTranslation();
const mode = scope.access_mode; const mode = scope.access_mode;
const isFull = mode === "full"; 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) => { const setMode = (value: WorkspaceAccessMode) => {
if (value === "full" && !canUseFullAccess) return; if (value === "full" && !canUseFullAccess) return;
@ -252,9 +259,10 @@ export function WorkspaceAccessMenu({
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")} aria-label={accessAriaLabel}
title={accessLabel}
className={cn( 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]", isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10" ? "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 ? ( {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")}> <span aria-hidden className="thread-composer-access-label-full min-w-0 truncate">
{t(isFull ? "thread.composer.workspace.full" : "thread.composer.workspace.default")} {accessLabel}
</span> </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> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className="w-56">

View File

@ -677,3 +677,98 @@
min-height: 2.75rem; min-height: 2.75rem;
} }
} }
/*
* Composer controls compress against their actual container, not the viewport.
* Permission state has priority over the model label; both retain their full
* accessible names and title text when their visible labels are shortened.
*/
.thread-composer-surface {
container-name: thread-composer;
container-type: inline-size;
}
.thread-composer-footer-actions {
flex: 0 1 auto;
max-width: 58%;
}
.thread-composer-model-badge {
flex-shrink: 1;
}
@container thread-composer (max-width: 21rem) {
.thread-composer-footer {
column-gap: 0.25rem;
padding-inline: 0.5rem;
}
.thread-composer-footer-primary,
.thread-composer-footer-actions {
gap: 0.25rem;
}
.thread-composer-access {
max-width: 6.5rem;
padding-inline: 0.5rem;
font-size: 0.71875rem;
}
.thread-composer-access-icon {
margin-right: 0.25rem;
}
.thread-composer-access-chevron {
margin-left: 0.25rem;
}
.thread-composer-access-label-full {
display: none;
}
.thread-composer-access-label-short {
display: inline;
}
}
@container thread-composer (max-width: 19rem) {
.thread-composer-model-badge {
width: 2rem;
flex: none;
justify-content: center;
gap: 0;
padding-inline: 0;
}
.thread-composer-model-label {
display: none;
}
}
@container thread-composer (max-width: 16rem) {
.thread-composer-access {
width: 2rem;
flex: none;
justify-content: center;
padding-inline: 0;
}
.thread-composer-access-icon {
margin-right: 0;
}
.thread-composer-access-label-short,
.thread-composer-access-chevron {
display: none;
}
}
@container thread-composer (max-width: 15rem) {
.thread-composer-action.touch-target,
.thread-composer-access.touch-target {
width: 2rem;
min-width: 2rem;
height: 2rem;
min-height: 2rem;
}
}

View File

@ -1101,7 +1101,9 @@
"projectAria": "Choose project", "projectAria": "Choose project",
"projectPlaceholder": "Select project", "projectPlaceholder": "Select project",
"default": "Default Permission", "default": "Default Permission",
"full": "Full Access" "defaultShort": "Default",
"full": "Full Access",
"fullShort": "Full"
} }
}, },
"scrollToBottom": "Scroll to bottom", "scrollToBottom": "Scroll to bottom",

View File

@ -1088,7 +1088,9 @@
"projectAria": "Elegir proyecto", "projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto", "projectPlaceholder": "Seleccionar proyecto",
"default": "Permiso predeterminado", "default": "Permiso predeterminado",
"full": "Acceso completo" "defaultShort": "Predeterm.",
"full": "Acceso completo",
"fullShort": "Completo"
} }
}, },
"scrollToBottom": "Desplazarse al final", "scrollToBottom": "Desplazarse al final",

View File

@ -1087,7 +1087,9 @@
"projectAria": "Choisir un projet", "projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet", "projectPlaceholder": "Sélectionner un projet",
"default": "Autorisation par défaut", "default": "Autorisation par défaut",
"full": "Accès complet" "defaultShort": "Par défaut",
"full": "Accès complet",
"fullShort": "Complet"
} }
}, },
"scrollToBottom": "Faire défiler vers le bas", "scrollToBottom": "Faire défiler vers le bas",

View File

@ -1087,7 +1087,9 @@
"projectAria": "Pilih proyek", "projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek", "projectPlaceholder": "Pilih proyek",
"default": "Izin default", "default": "Izin default",
"full": "Akses penuh" "defaultShort": "Default",
"full": "Akses penuh",
"fullShort": "Penuh"
} }
}, },
"scrollToBottom": "Gulir ke bawah", "scrollToBottom": "Gulir ke bawah",

View File

@ -1087,7 +1087,9 @@
"projectAria": "プロジェクトを選択", "projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択", "projectPlaceholder": "プロジェクトを選択",
"default": "既定の権限", "default": "既定の権限",
"full": "フルアクセス" "defaultShort": "既定",
"full": "フルアクセス",
"fullShort": "フル"
} }
}, },
"scrollToBottom": "一番下へスクロール", "scrollToBottom": "一番下へスクロール",

View File

@ -1087,7 +1087,9 @@
"projectAria": "프로젝트 선택", "projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택", "projectPlaceholder": "프로젝트 선택",
"default": "기본 권한", "default": "기본 권한",
"full": "전체 접근 권한" "defaultShort": "기본",
"full": "전체 접근 권한",
"fullShort": "전체"
} }
}, },
"scrollToBottom": "맨 아래로 스크롤", "scrollToBottom": "맨 아래로 스크롤",

View File

@ -1101,7 +1101,9 @@
"projectAria": "Escolher projeto", "projectAria": "Escolher projeto",
"projectPlaceholder": "Selecionar projeto", "projectPlaceholder": "Selecionar projeto",
"default": "Permissão padrão", "default": "Permissão padrão",
"full": "Acesso total" "defaultShort": "Padrão",
"full": "Acesso total",
"fullShort": "Total"
} }
}, },
"scrollToBottom": "Rolar para o final", "scrollToBottom": "Rolar para o final",

View File

@ -1087,7 +1087,9 @@
"projectAria": "Chọn dự án", "projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án", "projectPlaceholder": "Chọn dự án",
"default": "Quyền mặc định", "default": "Quyền mặc định",
"full": "Toàn quyền truy cập" "defaultShort": "Mặc định",
"full": "Toàn quyền truy cập",
"fullShort": "Toàn quyền"
} }
}, },
"scrollToBottom": "Cuộn xuống cuối", "scrollToBottom": "Cuộn xuống cuối",

View File

@ -1101,7 +1101,9 @@
"projectAria": "选择项目", "projectAria": "选择项目",
"projectPlaceholder": "选择项目", "projectPlaceholder": "选择项目",
"default": "默认权限", "default": "默认权限",
"full": "完全访问权限" "defaultShort": "默认",
"full": "完全访问权限",
"fullShort": "完全"
} }
}, },
"scrollToBottom": "滚动到底部", "scrollToBottom": "滚动到底部",

View File

@ -1087,7 +1087,9 @@
"projectAria": "選擇專案", "projectAria": "選擇專案",
"projectPlaceholder": "選擇專案", "projectPlaceholder": "選擇專案",
"default": "預設權限", "default": "預設權限",
"full": "完整存取權" "defaultShort": "預設",
"full": "完整存取權",
"fullShort": "完整"
} }
}, },
"scrollToBottom": "捲動到底部", "scrollToBottom": "捲動到底部",

View File

@ -1608,8 +1608,8 @@ describe("App layout", () => {
expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument(); expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
expect(settingsNav.className).toContain("overflow-x-auto"); expect(settingsNav.className).not.toContain("overflow-x-auto");
expect(settingsNav.className).not.toContain("grid-cols-2"); expect(within(settingsNav).getByRole("button", { name: "Settings: Overview" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute( expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute(
"aria-current", "aria-current",
"page", "page",
@ -1622,10 +1622,13 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument(); expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument(); expect(within(settingsNav).getByRole("button", { name: "Security" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" })); fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Overview" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument(); expect(screen.getByText("Brand logos")).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument(); expect(screen.getByRole("switch", { name: "Brand logos" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); expect(within(settingsNav).getByRole("button", { name: "Settings: Appearance" })).toBeInTheDocument();
fireEvent.pointerDown(within(settingsNav).getByRole("button", { name: "Settings: Appearance" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "Models" }));
expect(screen.queryByText("AI")).not.toBeInTheDocument(); expect(screen.queryByText("AI")).not.toBeInTheDocument();
expect(screen.getByText("Current configuration")).toBeInTheDocument(); expect(screen.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument(); expect(screen.queryByText("Presets")).not.toBeInTheDocument();

View File

@ -1365,7 +1365,10 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "channels" }); renderSettingsView({ initialSection: "channels" });
const emailRow = await screen.findByRole("button", { name: "View Email settings" }); const emailRow = await screen.findByRole("button", { name: "View Email settings" });
expect(screen.getByPlaceholderText("Search channels")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Search channels")).toHaveClass(
"focus-visible:ring-0",
"focus-visible:ring-offset-0",
);
expect(screen.queryByRole("switch", { name: "Email channel" })).not.toBeInTheDocument(); expect(screen.queryByRole("switch", { name: "Email channel" })).not.toBeInTheDocument();
fireEvent.click(emailRow); fireEvent.click(emailRow);

View File

@ -726,7 +726,7 @@ describe("ThreadComposer", () => {
/>, />,
); );
fireEvent.pointerDown(screen.getByRole("button", { name: "Workspace access mode" })); fireEvent.pointerDown(screen.getByRole("button", { name: /Workspace access mode/ }));
fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ })); fireEvent.click(await screen.findByRole("menuitem", { name: /Full Access/ }));
expect(onWorkspaceScopeChange).toHaveBeenCalledWith( expect(onWorkspaceScopeChange).toHaveBeenCalledWith(
@ -738,6 +738,34 @@ describe("ThreadComposer", () => {
); );
}); });
it("exposes full and compact workspace labels for container-driven compression", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
variant="hero"
workspaceScope={{
project_path: "/tmp/project",
project_name: "project",
access_mode: "full",
restrict_to_workspace: false,
}}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={vi.fn()}
/>,
);
const accessButton = screen.getByRole("button", {
name: "Workspace access mode: Full Access",
});
const fullLabel = within(accessButton).getByText("Full Access");
const shortLabel = within(accessButton).getByText("Full");
expect(accessButton).toHaveAttribute("title", "Full Access");
expect(fullLabel).toHaveClass("thread-composer-access-label-full");
expect(shortLabel).toHaveClass("thread-composer-access-label-short");
expect(shortLabel).toHaveClass("hidden");
});
it("keeps project selection as a compact composer dropdown", async () => { it("keeps project selection as a compact composer dropdown", async () => {
const onWorkspaceScopeChange = vi.fn(); const onWorkspaceScopeChange = vi.fn();
const defaultScope = { const defaultScope = {

View File

@ -941,7 +941,9 @@ describe("ThreadShell", () => {
); );
await act(async () => {}); await act(async () => {});
expect(screen.getByText(HERO_GREETING_PATTERN)).toBeInTheDocument(); const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
expect(greeting).toHaveClass("whitespace-nowrap");
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(); expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();