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
+74 -26
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"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
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"
>
<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
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",
"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>
<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">
+95
View File
@@ -677,3 +677,98 @@
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;
}
}
+3 -1
View File
@@ -1101,7 +1101,9 @@
"projectAria": "Choose project",
"projectPlaceholder": "Select project",
"default": "Default Permission",
"full": "Full Access"
"defaultShort": "Default",
"full": "Full Access",
"fullShort": "Full"
}
},
"scrollToBottom": "Scroll to bottom",
+3 -1
View File
@@ -1088,7 +1088,9 @@
"projectAria": "Elegir proyecto",
"projectPlaceholder": "Seleccionar proyecto",
"default": "Permiso predeterminado",
"full": "Acceso completo"
"defaultShort": "Predeterm.",
"full": "Acceso completo",
"fullShort": "Completo"
}
},
"scrollToBottom": "Desplazarse al final",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "Choisir un projet",
"projectPlaceholder": "Sélectionner un projet",
"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",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "Pilih proyek",
"projectPlaceholder": "Pilih proyek",
"default": "Izin default",
"full": "Akses penuh"
"defaultShort": "Default",
"full": "Akses penuh",
"fullShort": "Penuh"
}
},
"scrollToBottom": "Gulir ke bawah",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "プロジェクトを選択",
"projectPlaceholder": "プロジェクトを選択",
"default": "既定の権限",
"full": "フルアクセス"
"defaultShort": "既定",
"full": "フルアクセス",
"fullShort": "フル"
}
},
"scrollToBottom": "一番下へスクロール",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "프로젝트 선택",
"projectPlaceholder": "프로젝트 선택",
"default": "기본 권한",
"full": "전체 접근 권한"
"defaultShort": "기본",
"full": "전체 접근 권한",
"fullShort": "전체"
}
},
"scrollToBottom": "맨 아래로 스크롤",
+3 -1
View File
@@ -1101,7 +1101,9 @@
"projectAria": "Escolher projeto",
"projectPlaceholder": "Selecionar projeto",
"default": "Permissão padrão",
"full": "Acesso total"
"defaultShort": "Padrão",
"full": "Acesso total",
"fullShort": "Total"
}
},
"scrollToBottom": "Rolar para o final",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "Chọn dự án",
"projectPlaceholder": "Chọn dự án",
"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",
+3 -1
View File
@@ -1101,7 +1101,9 @@
"projectAria": "选择项目",
"projectPlaceholder": "选择项目",
"default": "默认权限",
"full": "完全访问权限"
"defaultShort": "默认",
"full": "完全访问权限",
"fullShort": "完全"
}
},
"scrollToBottom": "滚动到底部",
+3 -1
View File
@@ -1087,7 +1087,9 @@
"projectAria": "選擇專案",
"projectPlaceholder": "選擇專案",
"default": "預設權限",
"full": "完整存取權"
"defaultShort": "預設",
"full": "完整存取權",
"fullShort": "完整"
}
},
"scrollToBottom": "捲動到底部",
+7 -4
View File
@@ -1608,8 +1608,8 @@ describe("App layout", () => {
expect(screen.queryByTestId("overview-logo-nanobot-workspace")).not.toBeInTheDocument();
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
expect(settingsNav.className).toContain("overflow-x-auto");
expect(settingsNav.className).not.toContain("grid-cols-2");
expect(settingsNav.className).not.toContain("overflow-x-auto");
expect(within(settingsNav).getByRole("button", { name: "Settings: Overview" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute(
"aria-current",
"page",
@@ -1622,10 +1622,13 @@ describe("App layout", () => {
expect(within(settingsNav).queryByRole("button", { name: "Apps" })).not.toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Security" })).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.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.getByText("Current configuration")).toBeInTheDocument();
expect(screen.queryByText("Presets")).not.toBeInTheDocument();
+4 -1
View File
@@ -1365,7 +1365,10 @@ describe("SettingsView Apps catalog", () => {
renderSettingsView({ initialSection: "channels" });
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();
fireEvent.click(emailRow);
+29 -1
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/ }));
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 () => {
const onWorkspaceScopeChange = vi.fn();
const defaultScope = {
+3 -1
View File
@@ -941,7 +941,9 @@ describe("ThreadShell", () => {
);
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.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();