Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ vi.mock('@/state/deviceLinkModelMirror', () => ({
}));

import { ModelSelectorContent } from '@/components/new-chat/ModelSelector';
import type { UnifiedRailItem } from '@/components/new-chat/unifiedModelSelection';
import {
__resetForTest as resetEnginePrefs,
getModelEngineOverride,
Expand Down Expand Up @@ -3243,3 +3244,71 @@ describe('列表样式试用开关(badge · v7 引擎徽标行)', () => {
expect(header.getAttribute('data-group-label')).toBe('Cindy AI');
});
});

// ── classic 左侧 rail 的高度约束(#3516)────────────────────────────────────
// 自定义来源一多,rail 的 34px 格位累计高度会超过面板可用高度:根节点若无
// min-h-0 / overflow-y-auto,子项按 visible 溢出向下绘制,直接叠到底部「添加模型」
// footer 上。这里锁两件事:骨架类保留 + 溢出时格子一个不少、顺序不变。
describe('classic 左侧 rail:超高时内部滚动,不叠压 footer(#3516)', () => {
function railItems(count: number): UnifiedRailItem[] {
return [
{ kind: 'favorites' },
{ kind: 'all' },
...Array.from({ length: count }, (_, index) => ({
kind: 'provider' as const,
providerId: `src-${String(index).padStart(2, '0')}`,
})),
];
}

async function mountRail(items: readonly UnifiedRailItem[]): Promise<HTMLElement> {
const { UnifiedModelRail } = await import('@/components/new-chat/UnifiedModelRail');
render(
React.createElement(UnifiedModelRail, {
items,
active: { kind: 'all' },
onSelect: vi.fn(),
providers: [],
providerLabel: (providerId: string) => providerId,
}),
);
return document.querySelector('[data-unified-model-rail]') as HTMLElement;
}

it('根节点保留 w-12 / shrink-0 骨架,并带 min-h-0 + overflow-y-auto 收缩滚动链', async () => {
const root = await mountRail(railItems(1));
expect(root).not.toBeNull();
// scrollbar-hide:Windows 非 overlay 环境的全局 12px 滚动条槽会挤掉 34px 按钮
// (#3516 review P2),原生槽必须藏;滚轮 / 触控板照常滚。
for (const token of [
'w-12',
'shrink-0',
'min-h-0',
'overflow-y-auto',
'overscroll-contain',
'scrollbar-hide',
]) {
expect(root.className).toContain(token);
}
});

it('格位铺满超出可用高度时全部渲染、顺序不变(不靠裁剪或删项规避)', async () => {
const root = await mountRail(railItems(24));
// ★收藏 → 全部(带唯一的段间分隔线)→ 各来源,定位走 data-rail-item 稳定标记。
const keys = Array.from(root.querySelectorAll('button[data-rail-item]')).map((button) =>
button.getAttribute('data-rail-item'),
);
expect(keys).toHaveLength(26);
expect(keys[0]).toBe('favorites');
expect(keys[1]).toBe('all');
expect(root.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1);
const providerIds = keys.slice(2).map((key) => key?.replace(/^provider:/, ''));
expect(providerIds[0]).toBe('src-00');
expect(providerIds[23]).toBe('src-23');
expect(new Set(providerIds).size).toBe(24);
// 可达性:title/aria-label 跟着 providerLabel 走,不能因为溢出就丢格子文案。
const lastButton = root.querySelector('button[data-rail-item="provider:src-23"]');
expect(lastButton?.getAttribute('title')).toBe('src-23');
expect(lastButton?.getAttribute('aria-label')).toBe('src-23');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,18 @@ export function UnifiedModelRail({
const activeKey = railItemKey(active);
return (
// 设计稿 .rail:宽 48(含 6px 侧距 + 1px 右分隔线)、纵向 8px、格间 2px。
<div className="flex w-12 shrink-0 flex-col items-center gap-0.5 border-r border-[var(--model-dropdown-border)] px-1.5 py-2">
// min-h-0 + overflow-y-auto:自定义来源一多,34px 格位累计高度会超过面板可用高度 ——
// 根节点没有纵向约束时子项按 visible 溢出向下绘制,直接叠到面板底部「添加模型」
// footer 上(#3516)。与右侧列表(UnifiedModelPanel 的 min-h-0 + overflow-y-auto)
// 同一套收缩滚动链:父级高度受限时 rail 自己收缩并内部滚动,w-12 shrink-0 骨架不变,
// 不引入折叠 / 截断等新的产品规则;不用 scrollbar-gutter:stable(48px 窄栏留给 34px 按钮)。
// scrollbar-hide:原生滚动条必须藏 —— Windows 等非 overlay 环境下全局 12px 槽位会把
// 内容区挤到 24px,34px 按钮放不下(#3516 review P2);折叠侧栏窄 rail 同一取舍
// (CCAgentSidebarUpper)。滚轮 / 触控板 / 键盘照常滚动。
<div
data-unified-model-rail="true"
className="flex w-12 min-h-0 shrink-0 flex-col items-center gap-0.5 overflow-y-auto overscroll-contain border-r border-[var(--model-dropdown-border)] px-1.5 py-2 scrollbar-hide"
>
{items.map((item) => {
const key = railItemKey(item);
const isActive = activeKey === key;
Expand All @@ -64,43 +75,46 @@ export function UnifiedModelRail({
return (
<div key={key} className="contents">
{separatorBefore && (
<div aria-hidden className="my-[3px] w-[22px] border-t border-[var(--model-dropdown-border)]" />
<div
aria-hidden
className="my-[3px] w-[22px] border-t border-[var(--model-dropdown-border)]"
/>
)}
<button
type="button"
disabled={interactionDisabled}
onClick={() => onSelect(item)}
title={label}
aria-label={label}
aria-pressed={isActive}
data-rail-item={key}
className={cn(
// 设计稿 .rail-btn:34×34、圆角 9。
'flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[9px] transition-colors',
// 选中态用**反色实心块**(与浮层里的选中分段同一套表达):
// 原先的 --surface-chip 在两种主题下都只比底色深一点点,用户看不出当前
// 停在哪个视图 —— 会话内「同引擎过滤开着没开着」正是靠这一格判断的
// (2026-08-13 实测反馈)。
isActive
? 'bg-[var(--accent-cta-bg)] text-[var(--accent-pure-cta-fg)] shadow-[var(--shadow-menu)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--model-item-hover)] hover:text-[var(--text-secondary)]',
interactionDisabled && 'cursor-not-allowed opacity-50',
)}
>
{item.kind === 'favorites' ? (
// ☆ 未激活与其它格同灰(hover 提亮)—— 常亮金色会在没进收藏视图时也
// 抢视线(2026-08-14 实机自查);激活时整格反色 + 实心星跟随 currentColor。
<Star size={16} fill={isActive ? 'currentColor' : 'none'} />
) : item.kind === 'engine' && engineOption ? (
// 同引擎格用**当前会话引擎自己的品牌 mark**(规格 §1.6),用户一眼知道
// 这个过滤器是按什么筛的。
<engineOption.Mark size={14} className="shrink-0" />
) : item.kind === 'all' ? (
<LayoutGrid size={16} />
) : item.kind === 'provider' ? (
<ProviderRailMark providerId={item.providerId} providers={providers} />
) : null}
</button>
<button
type="button"
disabled={interactionDisabled}
onClick={() => onSelect(item)}
title={label}
aria-label={label}
aria-pressed={isActive}
data-rail-item={key}
className={cn(
// 设计稿 .rail-btn:34×34、圆角 9。
'flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[9px] transition-colors',
// 选中态用**反色实心块**(与浮层里的选中分段同一套表达):
// 原先的 --surface-chip 在两种主题下都只比底色深一点点,用户看不出当前
// 停在哪个视图 —— 会话内「同引擎过滤开着没开着」正是靠这一格判断的
// (2026-08-13 实测反馈)。
isActive
? 'bg-[var(--accent-cta-bg)] text-[var(--accent-pure-cta-fg)] shadow-[var(--shadow-menu)]'
: 'text-[var(--text-tertiary)] hover:bg-[var(--model-item-hover)] hover:text-[var(--text-secondary)]',
interactionDisabled && 'cursor-not-allowed opacity-50',
)}
>
{item.kind === 'favorites' ? (
// ☆ 未激活与其它格同灰(hover 提亮)—— 常亮金色会在没进收藏视图时也
// 抢视线(2026-08-14 实机自查);激活时整格反色 + 实心星跟随 currentColor。
<Star size={16} fill={isActive ? 'currentColor' : 'none'} />
) : item.kind === 'engine' && engineOption ? (
// 同引擎格用**当前会话引擎自己的品牌 mark**(规格 §1.6),用户一眼知道
// 这个过滤器是按什么筛的。
<engineOption.Mark size={14} className="shrink-0" />
) : item.kind === 'all' ? (
<LayoutGrid size={16} />
) : item.kind === 'provider' ? (
<ProviderRailMark providerId={item.providerId} providers={providers} />
) : null}
</button>
</div>
);
})}
Expand Down