diff --git a/docs/specs/2026-09-20-sidebar-spacing-design.md b/docs/specs/2026-09-20-sidebar-spacing-design.md new file mode 100644 index 00000000..0b301651 --- /dev/null +++ b/docs/specs/2026-09-20-sidebar-spacing-design.md @@ -0,0 +1,584 @@ +# ACECode 左侧侧边栏间距调整设计方案 + +## 1. 背景与目标 + +ACECode 左侧侧边栏当前的问题不是单纯“整体太松”或“整体太紧”,而是纵向间距缺少稳定语义: + +- 分组标题上方留白较大,标题下方却因负 margin 显得拥挤; +- 同类任务行连续贴合,缺少稳定的扫描节奏; +- 工作区父行、子任务和下一工作区使用不同的间距规则; +- 局部负 margin 会让标题、工作区行和子任务行的布局范围发生重叠。 + +本次调整只重新定义侧边栏的纵向节奏,并允许轻微调整分组标题字号。必须保留现有信息结构、交互行为、左侧基线、右侧基线和列宽关系。 + +核心原则: + +> 只重新定义纵向关系,不重新设计横向网格。 + +## 2. 范围 + +### 2.1 包含 + +- 统一普通列表行之间的纵向间距; +- 统一分组之间的纵向间距; +- 移除侧边栏列表中的负 margin 重叠; +- 将分组标题调整为更紧凑的高度; +- 将分组标题字号从 11px token 调整为 12px token; +- 固化现有横向对齐规则,防止后续修改造成回归; +- 补充相应的结构测试和视觉验收。 + +### 2.2 不包含 + +- 不修改侧边栏宽度; +- 不修改 TopBar 与侧边栏的水平对应关系; +- 不改变任务、工作区、置顶任务或扩展的信息结构; +- 不删除会话左侧状态槽; +- 不隐藏会话时间; +- 不改变右侧时间列宽度; +- 不修改图标大小、操作按钮位置或折叠按钮显示逻辑; +- 不修改会话拖拽、工作区展开或数据加载行为; +- 不重构 Sidebar 数据流; +- 不编辑导出的 `ACECode.html` 或构建产物。 + +## 3. 不可破坏的横向对齐契约 + +以下数值是现有布局契约,不是本次可自由调整的参数。 + +### 3.1 树形行三列结构 + +会话行、工作区行、空状态行和“展开显示”行继续使用: + +```text +24px 图标/状态列 +7px 列间距 +minmax(0, 1fr) 标题列 +7px 列间距 +76px 右侧信息/操作列 +``` + +对应 class 必须继续保持: + +```jsx +grid-cols-[24px_minmax(0,1fr)_76px] +gap-x-[7px] +mx-1.5 +pl-[13px] +pr-2 +``` + +主要位置: + +- `SessionRow`; +- `WorkspaceGroup`; +- 工作区和无工作区空状态; +- 工作区和无工作区“展开显示”行。 + +禁止: + +- 将 `76px` 改为 `auto`; +- 修改 `gap-x-[7px]`; +- 删除 `mx-1.5`; +- 修改 `pl-[13px] pr-2`; +- 让标题跨列; +- 根据内容动态改变右侧列宽。 + +该契约保证: + +- 所有会话标题左对齐; +- 工作区名称与会话标题左对齐; +- “展开显示”与标题列左对齐; +- 时间、计数和操作按钮右对齐; +- 时间、状态、悬浮操作切换时标题不会水平跳动。 + +### 3.2 一级导航左侧基线 + +一级导航继续保持: + +```jsx +pl-[19px] +pr-3 +w-6 +gap-[7px] +justify-start +``` + +其文字起点保持为: + +```text +19px 左边距 + 24px 图标槽 + 7px gap = 50px +``` + +适用于: + +- `SidebarNavItem`; +- `CustomSidebarItem`; +- 扩展入口。 + +### 3.3 分组标题左侧基线 + +分组标题中的折叠图标槽继续保留: + +```jsx +pl-[19px] +w-6 +gap-[7px] +``` + +即使折叠图标默认透明,也不得删除这个槽位。它负责让“任务”“工作区”等分组标题与标题列共享既有基线。 + +### 3.4 右侧基线 + +以下元素继续使用固定的 76px 右侧列: + +- 会话时间; +- 权限请求状态; +- 等待回复状态; +- 工作区菜单和新建任务按钮区域; +- 扩展数量; +- 空状态对应的第三列。 + +右侧容器继续保持: + +```jsx +w-full +justify-end +``` + +不得为了缩小空白移除固定右侧列。 + +## 4. 目标纵向节奏 + +采用两档间距系统: + +| 关系 | 目标值 | +|---|---:| +| 普通行高度 | 32px | +| 同类行之间 | 2px | +| 分组标题高度 | 24px | +| 分组标题到首行 | 2px | +| 前一组到下一分组标题 | 8px | +| 展开工作区结束到下一工作区 | 8px | +| 品牌区 | 保持现状 | + +统一规则: + +```text +组内:2px +组间:8px +普通行:32px +标题行:24px +``` + +不再使用负 margin 表达层级关系。 + +## 5. 详细设计 + +### 5.1 品牌区 + +品牌区保持现状: + +```jsx +gap-[11px] +pl-[19px] +pr-[18px] +py-3 +``` + +Logo、品牌名称、版本号和品牌区总高度都不调整。 + +### 5.2 一级导航 + +以下项目继续使用 `h-8`: + +- 新建任务; +- 定时任务; +- 扩展; +- 扩展展开后的子项。 + +由列表父容器统一提供 2px 间隔: + +```jsx +
+``` + +不要在各个导航项上单独增加 margin。 + +扩展子项也应维持 32px 行高和 2px 行间距。如果现有扩展列表不是 flex column,应只在其现有容器上补充 `flex flex-col gap-0.5`,不要引入新的布局模块。 + +### 5.3 分组标题 + +`SidebarSectionHeader` 从: + +```jsx +h-8 mt-2 +``` + +调整为: + +```jsx +h-6 mt-2 +``` + +要求: + +- 标题行高度从 32px 调整为 24px; +- 标题上方 8px 组间距保持不变; +- 标题颜色、字重、左右 padding 和内部图标槽保持不变; +- 标题内部按钮继续保留 `gap-[7px]` 和 `w-6` 图标槽。 + +`globals.css` 中: + +```css +.ace-sidebar .ace-sidebar-section-title { + font-size: var(--ace-font-size-11) !important; +} +``` + +调整为: + +```css +.ace-sidebar .ace-sidebar-section-title { + font-size: var(--ace-font-size-12) !important; +} +``` + +不要修改 `.ace-sidebar-primary-text` 和 `.ace-sidebar-meta-text` 的字号规则。 + +### 5.4 置顶任务列表 + +当前置顶任务容器使用 `my-1`。调整为: + +```jsx +
+``` + +含义: + +- 标题到第一行 2px; +- 任务行之间 2px; +- 不提供额外底部 margin,下一分组标题自己的 `mt-2` 负责 8px 组间距。 + +### 5.5 无工作区任务列表 + +移除: + +```jsx +-mt-1 +``` + +容器调整为: + +```jsx +
+``` + +不要保留额外 `mb-2`,避免与下一分组标题的 `mt-2` 叠加为 16px。 + +目标关系: + +```text +任务标题 24px + 2px +任务行 32px + 2px +任务行 32px + 8px +工作区标题 24px +``` + +### 5.6 工作区列表外层 + +移除: + +```jsx +-mt-1 +``` + +调整为: + +```jsx +
+``` + +含义: + +- 工作区标题到第一个工作区 2px; +- 折叠工作区之间 2px; +- 展开的工作区由其内部组尾间距与下一个工作区分隔。 + +### 5.7 工作区子任务列表 + +移除: + +```jsx +-mt-1 +``` + +调整为: + +```jsx +
+``` + +含义: + +- 工作区父行到第一个子任务 2px; +- 子任务之间 2px; +- 展开工作区组结束后到下一个工作区 8px。 + +不得以负 margin 或 transform 模拟父子贴合。 + +### 5.8 普通行 + +以下行继续使用 `h-8`: + +- `SessionRow`; +- `WorkspaceGroup`; +- `SidebarNavItem`; +- `CustomSidebarItem`; +- 扩展入口。 + +普通行不改为 30px 或 34px。目标是 `32px 行高 + 2px 行间距`。 + +这可以保持现有点击目标、拖拽命中范围、悬浮背景高度和内部控件垂直居中。 + +### 5.9 空状态及展开/折叠显示 + +“暂无任务”“加载中……”“展开显示”“折叠显示”继续使用现有三列 grid。父容器已有 `gap-0.5` 后,不再给这些行添加额外上下 margin。 + +“展开显示”按钮必须继续位于第二列: + +```jsx +
+ + + diff --git a/web/scripts/check-session-loading-motion.py b/web/scripts/check-session-loading-motion.py index 4ee134a7..3abd39f0 100644 --- a/web/scripts/check-session-loading-motion.py +++ b/web/scripts/check-session-loading-motion.py @@ -6,7 +6,6 @@ import argparse import json -import math import subprocess from html.parser import HTMLParser from pathlib import Path @@ -66,13 +65,13 @@ def production_markup(): SNAPSHOT = '''() => { const host = document.querySelector('.ace-session-loading'); - const orbit = host.querySelector('.ace-session-loading-orbit'); const dots = [...host.querySelectorAll('.ace-session-loading-dot')]; const center = node => { const r = node.getBoundingClientRect(); return [r.x+r.width/2, r.y+r.height/2]; }; return { centers:dots.map(center), hostCenter:center(host), diameter:dots.map(node => parseFloat(getComputedStyle(node).width)), - slot:[host.offsetWidth, host.offsetHeight], opacity:parseFloat(getComputedStyle(orbit).opacity), - transforms:[orbit, ...dots].map(node => getComputedStyle(node).transform), + slot:[host.offsetWidth, host.offsetHeight], + opacity:dots.map(node => parseFloat(getComputedStyle(node).opacity)), + transforms:dots.map(node => getComputedStyle(node).transform), label:host.getAttribute('aria-label') }; }''' @@ -119,23 +118,18 @@ def mount(theme='light', motion='no-preference', width=390): }''', SNAPSHOT) for frame in frames: assert frame['slot'] == [16, 16], frame - assert len(frame['centers']) == 4 and frame['label'] == 'Running' - assert all(abs(d - 4.3) < 0.01 for d in frame['diameter']) - assert all(math.dist(frame['centers'][a], frame['centers'][b]) > 4.3 - for a in range(4) for b in range(a + 1, 4)) - centroid = [sum(c[axis] for c in frame['centers']) / 4 for axis in [0, 1]] - assert math.dist(centroid, frame['hostCenter']) < 0.02 + assert len(frame['centers']) == 12 and frame['label'] == 'Running' + assert all(abs(d - 2) < 0.01 for d in frame['diameter']) + assert len({tuple(c) for c in frame['centers']}) == 12 if motion == 'reduce': assert all(frame['transforms'] == frames[0]['transforms'] for frame in frames) - opacity = [frame['opacity'] for frame in frames] - assert min(opacity) >= 0.599 and max(opacity) <= 1 - assert max(opacity) - min(opacity) > 0.39, 'Reduced motion must retain gentle running feedback' + assert all(opacity == 1 for frame in frames for opacity in frame['opacity']) else: - assert all(math.dist(a['centers'][0], b['centers'][0]) > 0.02 - for a, b in zip(frames, frames[1:])), 'Running motion must not stall' + assert any(a['opacity'] != b['opacity'] + for a, b in zip(frames, frames[1:])), 'Dot-matrix animation must not stall' results.append({'browser': browser.version, 'theme': theme, 'width': width, 'motion': motion, 'passed': True}) - # Live preference changes must work without manually controlling animations. + # Live preference changes must stop and resume the dot pulse. mount() for motion in ['no-preference', 'reduce', 'no-preference']: page.emulate_media(reduced_motion=motion) @@ -144,16 +138,15 @@ def mount(theme='light', motion='no-preference', width=390): last = page.evaluate(SNAPSHOT) if motion == 'reduce': assert first['transforms'] == last['transforms'] - assert abs(first['opacity'] - last['opacity']) > 0.01 + assert first['opacity'] == last['opacity'] == [1] * 12 else: - assert first['transforms'] != last['transforms'] + assert first['opacity'] != last['opacity'] - # Defined resting positions keep all four dots separate before animation starts. + # The resting dot matrix remains an abstract A with twelve distinct points. mount() - page.add_style_tag(content='.ace-session-loading * { animation: none !important; }') + page.add_style_tag(content='.ace-session-loading * { animation: none !important; opacity: 1 !important; }') resting = page.evaluate(SNAPSHOT) - assert all(math.dist(c, resting['hostCenter']) > 3.4 for c in resting['centers']) - assert len({tuple(c) for c in resting['centers']}) == 4 + assert len({tuple(c) for c in resting['centers']}) == 12 if args.screenshot_dir: args.screenshot_dir.mkdir(parents=True, exist_ok=True) page.locator('.ace-session-loading').screenshot( diff --git a/web/src/components/DesktopContextMenu.jsx b/web/src/components/DesktopContextMenu.jsx index 8c7b7bf1..e08efd4d 100644 --- a/web/src/components/DesktopContextMenu.jsx +++ b/web/src/components/DesktopContextMenu.jsx @@ -45,12 +45,9 @@ const ACTION_LABELS = { [DESKTOP_CONTEXT_ACTIONS.COPY_SESSION_ID]: '复制ID', [DESKTOP_CONTEXT_ACTIONS.EXPORT_SESSION]: '导出', [DESKTOP_CONTEXT_ACTIONS.ARCHIVE_SESSION]: '归档', - [DESKTOP_CONTEXT_ACTIONS.ACTIVATE_WORKSPACE]: '切换到项目', - [DESKTOP_CONTEXT_ACTIONS.EXPAND_WORKSPACE]: '展开项目', - [DESKTOP_CONTEXT_ACTIONS.COLLAPSE_WORKSPACE]: '折叠项目', - [DESKTOP_CONTEXT_ACTIONS.NEW_WORKSPACE_SESSION]: '新建会话', + [DESKTOP_CONTEXT_ACTIONS.NEW_WORKSPACE_SESSION]: '新建任务', [DESKTOP_CONTEXT_ACTIONS.IMPORT_OPENCODE_SESSIONS]: '从opencode导入会话', - [DESKTOP_CONTEXT_ACTIONS.RENAME_WORKSPACE]: '重命名项目', + [DESKTOP_CONTEXT_ACTIONS.RENAME_WORKSPACE]: '重命名', [DESKTOP_CONTEXT_ACTIONS.COPY_WORKSPACE_PATH]: '复制项目路径', [DESKTOP_CONTEXT_ACTIONS.REMOVE_WORKSPACE]: '从项目列表移除', [DESKTOP_CONTEXT_ACTIONS.PREVIEW_FILE]: '预览文件', diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index 3b182bde..5cf62489 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -61,7 +61,6 @@ import { } from '../lib/sessionListEvents.js'; import { sessionHasPendingQuestion } from '../lib/pendingQuestions.js'; import { sessionHasPendingPermission } from '../lib/permissionRequestQueue.js'; -import { pickExistingWorkspace } from '../lib/workspacePicker.js'; import { applyStatusSnapshot, applyStatusUpdate, @@ -77,7 +76,6 @@ import { applyRemoteControlSessionSelection, clearRemoteControlSessionBindings, completeRemoteControlSurgeRequest, - expandedSessionListsAfterWorkspaceCollapseAll, expandedSessionListsAfterWorkspaceDisclosure, nextRemoteControlSurgeRequest, reorderSidebarWorkspaceSession, @@ -497,7 +495,7 @@ function SidebarDisclosure({ expanded, className = '' }) { name={icon.name} size={icon.size} className={clsx('block shrink-0 transition-transform', className)} - style={{ transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)' }} + style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }} /> ); } @@ -514,9 +512,9 @@ function SidebarNavItem({ item, onClick }) { data-tour-target={item.id === 'new-task' ? 'sidebar-new-task' : undefined} onPointerDown={item.id === 'new-task' ? preserveHomeComposerFocus : undefined} onClick={onClick} - className="ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition text-left" + className="ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 h-8 rounded-md text-[14px] text-fg hover:bg-surface-hi transition text-left" > - + {item.label} @@ -543,14 +541,14 @@ function CustomSidebarItem({ item, count, onClick }) { type="button" onClick={onClick} data-sidebar-custom-item={item.id} - className="ace-sidebar-primary-text w-full flex items-center gap-[5px] px-3 py-[3px] text-[14px] text-fg hover:bg-surface-hi transition text-left" + className="ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 h-8 text-[14px] text-fg hover:bg-surface-hi transition text-left" > - + {item.label} {Number.isFinite(count) && ( - {count} + {count} )} ); @@ -599,21 +597,23 @@ function CustomSidebarSection({ workspaceHash = '', onOpenSettingsSection, onOpe type="button" onClick={() => setExpanded((value) => !value)} data-sidebar-custom-section="true" - className="ace-sidebar-extensions-trigger ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition" + className="ace-sidebar-extensions-trigger ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-3 h-8 rounded-md text-[14px] text-fg hover:bg-surface-hi transition" aria-expanded={expanded} aria-controls={listId} > - + - 扩展 - {totalCount != null && ( - {totalCount} - )} + + {totalCount != null && ( + {totalCount} + )} + + {expanded && ( -
+
{SIDEBAR_CUSTOM_ITEMS.map((item) => ( - {actions && ( @@ -676,11 +675,19 @@ function SessionAttentionIndicator({ attention, meta }) { role="status" aria-label={meta.label} > -