fix: group conversation search and standardize sidebar spacing

Move search to the top of the conversation area, remove its visible heading, and use a white surface in the light theme. Keep the list independently scrollable and sidebar controls reachable in short windows. Standardize footer divider spacing to 16px on both sides and update layout tests and design guidance.

Validation: 307 focused tests, typecheck, targeted ESLint, and four Electron layout scenarios passed.

Release note: Conversation search now sits above the conversation list in a distinct themed area, with consistent divider spacing and improved short-window scrolling.
This commit is contained in:
mesalogo
2026-09-28 19:06:06 +08:00
parent d8f0ef1301
commit 120076a13d
5 changed files with 91 additions and 37 deletions
+3 -1
View File
@@ -293,9 +293,11 @@
- 浅色侧栏使用冰蓝灰表面,与白色主内容画布形成明确边界;深色侧栏使用比主画布略亮的蓝灰表面。
- 一级导航与最近会话之间、最近会话与底部账户区之间必须有可见结构分隔线。浅色主题使用 `--border-default`,深色主题可在可辨前提下使用 `--border-subtle`。
- 当前导航项和当前会话使用一致的淡蓝选中背景、强调色文字和中等字重,不额外描边或添加侧边标记;导航图标随选中状态使用强调色,悬停时保留选中状态。浅深主题遵循相同规则,强制颜色模式保留系统选中边框。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与最近会话分隔线上下各留 `16px`。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `16px`(`--space-4`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称;搜索框固定在区域顶部,仅下方列表滚动。搜索框使用透明背景和 `--border-control` 边框,保留内部清除按钮与焦点环。
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
+4
View File
@@ -3054,6 +3054,10 @@ describe("App", () => {
]);
const { container } = render(<App />);
const search = await screen.findByLabelText("搜索对话");
const conversationSection = search.closest(".sidebar-conversations");
expect(conversationSection).toBeInTheDocument();
expect(search.closest(".conversation-list")).toBeNull();
expect(conversationSection?.querySelector(".conversation-list")).toBeInTheDocument();
const searchControls = within(search.parentElement!);
expect(
searchControls.queryByRole("button", { name: "清除搜索" }),
+29 -26
View File
@@ -8716,31 +8716,6 @@ function App(): React.JSX.Element {
</button>
)}
<div className="sidebar-search">
<Search size={15} />
<input
aria-label={t("sidebar.searchLabel")}
ref={searchInputRef}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
value={searchQuery}
/>
{searchQuery.length > 0 && (
<button
aria-label={t("conversation.clearSearch")}
className="icon-button sidebar-search__clear"
onClick={() => {
setSearchQuery("");
searchInputRef.current?.focus();
}}
title={t("conversation.clearSearch")}
type="button"
>
<X aria-hidden="true" size={14} />
</button>
)}
</div>
<nav className="primary-nav" aria-label={t("navigation.label")}>
<button
aria-current={view === "chat" ? "page" : undefined}
@@ -8800,8 +8775,34 @@ function App(): React.JSX.Element {
</button>
</nav>
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
<div className="sidebar-conversations__header">
<div className="sidebar-search">
<Search aria-hidden="true" size={15} />
<input
aria-label={t("sidebar.searchLabel")}
ref={searchInputRef}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
value={searchQuery}
/>
{searchQuery.length > 0 && (
<button
aria-label={t("conversation.clearSearch")}
className="icon-button sidebar-search__clear"
onClick={() => {
setSearchQuery("");
searchInputRef.current?.focus();
}}
title={t("conversation.clearSearch")}
type="button"
>
<X aria-hidden="true" size={14} />
</button>
)}
</div>
</div>
<div className="conversation-list">
<p className="section-label">{t("sidebar.recent")}</p>
{!conversationLoadError &&
filteredConversations.map((conversation) => {
const conversationTasks =
@@ -9328,6 +9329,8 @@ function App(): React.JSX.Element {
) : null}
</div>
</section>
<div className="sidebar-footer sidebar-footer--applications">
<button
className="nav-item"
+17
View File
@@ -8,6 +8,23 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n')
describe('primary sidebar layout', () => {
it('keeps search above an independently scrolling conversation list', () => {
const section = stylesheet.match(/\.sidebar-conversations\s*\{([^}]*)\}/u)?.[1]
const header = stylesheet.match(/\.sidebar-conversations__header\s*\{([^}]*)\}/u)?.[1]
const list = stylesheet.match(/\.conversation-list\s*\{([^}]*)\}/u)?.[1]
expect(section).toContain('min-height: 0;')
expect(section).toContain('flex-direction: column;')
expect(section).toContain('background: var(--surface-raised);')
expect(header).toContain('flex: 0 0 auto;')
expect(list).toContain('min-width: 0;')
expect(list).toContain('min-height: 0;')
expect(list).toContain('overflow: auto;')
const shortWindow = stylesheet.split('@media (max-height: 600px) {')[1]?.split('@media')[0]
expect(shortWindow).toContain('overflow-y: auto;')
expect(shortWindow).toContain('flex-shrink: 0;')
expect(shortWindow).toContain('min-height: 200px;')
})
it('floats conversation actions outside list layout with viewport limits', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;')
+38 -10
View File
@@ -2587,9 +2587,8 @@ textarea:focus-visible {
.sidebar > .brand,
.sidebar > .project-switcher,
.sidebar > .new-chat,
.sidebar > .sidebar-search,
.sidebar > .primary-nav,
.sidebar > .conversation-list,
.sidebar > .sidebar-conversations,
.sidebar > .sidebar-footer {
min-width: 0;
}
@@ -3527,14 +3526,13 @@ textarea:focus-visible {
.sidebar-search {
position: relative;
display: flex;
min-width: 248px;
min-width: 0;
align-items: center;
padding: 9px 11px;
padding-right: 40px;
border: 1px solid var(--border-default);
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
margin-bottom: var(--space-4);
background: var(--surface-raised);
background: transparent;
color: var(--text-muted);
gap: var(--space-2);
}
@@ -3648,13 +3646,43 @@ textarea:focus-visible {
place-items: center;
}
.sidebar-conversations {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: var(--space-2);
margin-top: var(--space-4);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.sidebar-conversations__header {
flex: 0 0 auto;
padding: var(--space-2) var(--space-1) var(--space-3);
}
.conversation-list {
min-width: 248px;
min-width: 0;
min-height: 0;
flex: 1;
padding-top: var(--space-4);
overflow: auto;
}
@media (max-height: 600px) {
.sidebar {
overflow-y: auto;
}
.sidebar > * {
flex-shrink: 0;
}
.sidebar-conversations {
min-height: 200px;
}
}
@media (forced-colors: none) and (pointer: fine) {
.conversation-list {
scrollbar-width: thin;
@@ -3813,7 +3841,8 @@ textarea:focus-visible {
.sidebar-footer {
min-width: 248px;
padding-top: 12px;
padding-top: var(--space-4);
margin-top: var(--space-4);
border-top: 1px solid var(--border-default);
}
@@ -16427,7 +16456,6 @@ textarea.field-control {
}
:root[data-theme='dark'] :where(
.sidebar-search,
.settings-section,
.settings-tabs,
.assistant-sidebar__row,