diff --git a/web/scripts/check-session-loading-motion.py b/web/scripts/check-session-loading-motion.py
deleted file mode 100644
index 4ee134a74..000000000
--- a/web/scripts/check-session-loading-motion.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Check the built sidebar indicator with Playwright, including older Chromium.
-
-Run pnpm build first. Requires Python's playwright package and a Chromium install.
-Optional: --browser PATH adds a legacy Chromium executable to the default browser.
-"""
-
-import argparse
-import json
-import math
-import subprocess
-from html.parser import HTMLParser
-from pathlib import Path
-
-from playwright.sync_api import sync_playwright
-
-
-WEB = Path(__file__).resolve().parents[1]
-
-
-class Styles(HTMLParser):
- def __init__(self, source):
- super().__init__()
- self.active = False
- self.parts = []
- self.feed(source)
-
- def handle_starttag(self, tag, attrs):
- if tag == 'style':
- self.active = True
-
- def handle_endtag(self, tag):
- if tag == 'style':
- self.active = False
-
- def handle_data(self, data):
- if self.active:
- self.parts.append(data)
-
-
-def production_markup():
- # Render the actual component; the fixture does not duplicate its markup.
- script = r'''
-import fs from 'node:fs';
-import vm from 'node:vm';
-import React from 'react';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { transformWithEsbuild } from 'vite';
-import clsx from 'clsx';
-const source = fs.readFileSync('src/components/Sidebar.jsx', 'utf8');
-const start = source.indexOf('function SessionAttentionIndicator');
-const end = source.indexOf('function SessionHoverCard', start);
-if (start < 0 || end < start) throw new Error('Missing production indicator');
-const { code } = await transformWithEsbuild(source.slice(start, end), 'Indicator.jsx', {
- loader: 'jsx', jsxFactory: 'React.createElement', jsxFragment: 'React.Fragment',
-});
-const Component = vm.runInNewContext(`${code}; SessionAttentionIndicator`, { React, clsx });
-process.stdout.write(renderToStaticMarkup(React.createElement(Component, {
- attention: 'in_progress', meta: { label: 'Running' },
-})));
-'''
- return subprocess.run(
- ['node', '--input-type=module'], input=script, cwd=WEB,
- text=True, encoding='utf-8', capture_output=True, check=True,
- ).stdout
-
-
-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),
- label:host.getAttribute('aria-label') };
-}'''
-
-
-def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--browser', action='append', default=[])
- parser.add_argument('--build-html', type=Path, default=WEB / 'dist/index.html')
- parser.add_argument('--output', type=Path)
- parser.add_argument('--screenshot-dir', type=Path)
- args = parser.parse_args()
- css = '\n'.join(Styles(args.build_html.read_text(encoding='utf-8')).parts)
- assert css, 'Build HTML must contain the production CSS'
- markup = production_markup()
- results = []
- with sync_playwright() as playwright:
- for executable in [None, *args.browser]:
- options = {'headless': True}
- if executable:
- options['executable_path'] = executable
- browser = playwright.chromium.launch(**options)
- page = browser.new_page()
-
- def mount(theme='light', motion='no-preference', width=390):
- page.set_viewport_size({'width': width, 'height': 240})
- page.emulate_media(reduced_motion=motion)
- page.set_content(f'
'
- f'{markup}')
-
- for theme in ['light', 'dark']:
- for width in [390, 1280]:
- for motion in ['no-preference', 'reduce']:
- mount(theme, motion, width)
- frames = page.evaluate('''snapshot => {
- const read = new Function('return (' + snapshot + ')')();
- const animations = document.querySelector('.ace-session-loading').getAnimations({subtree:true});
- animations.forEach(a => a.pause());
- const frames = [];
- for (let time=0; time<=6480; time+=40) {
- animations.forEach(a => a.currentTime=time);
- frames.push(read());
- }
- return frames;
- }''', 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
- 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'
- 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'
- results.append({'browser': browser.version, 'theme': theme, 'width': width, 'motion': motion, 'passed': True})
-
- # Live preference changes must work without manually controlling animations.
- mount()
- for motion in ['no-preference', 'reduce', 'no-preference']:
- page.emulate_media(reduced_motion=motion)
- first = page.evaluate(SNAPSHOT)
- page.wait_for_timeout(350)
- last = page.evaluate(SNAPSHOT)
- if motion == 'reduce':
- assert first['transforms'] == last['transforms']
- assert abs(first['opacity'] - last['opacity']) > 0.01
- else:
- assert first['transforms'] != last['transforms']
-
- # Defined resting positions keep all four dots separate before animation starts.
- mount()
- page.add_style_tag(content='.ace-session-loading * { animation: none !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
- if args.screenshot_dir:
- args.screenshot_dir.mkdir(parents=True, exist_ok=True)
- page.locator('.ace-session-loading').screenshot(
- path=str(args.screenshot_dir / f'indicator-{browser.version}.png'))
- browser.close()
- output = json.dumps(results, indent=2)
- if args.output:
- args.output.write_text(output + '\n', encoding='utf-8')
- print(output)
-
-
-if __name__ == '__main__':
- main()
diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx
index aa4883f5e..6b60393ea 100644
--- a/web/src/components/Sidebar.jsx
+++ b/web/src/components/Sidebar.jsx
@@ -511,7 +511,7 @@ 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-[13px] py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition text-left"
>
@@ -527,26 +527,25 @@ function SidebarSectionHeader({ sectionId, count, expanded, onToggle, actions =
return (
-
{actions && (
@@ -561,19 +560,13 @@ function SessionAttentionIndicator({ attention, meta }) {
if (attention !== 'in_progress' && attention !== 'unread') return null;
return attention === 'in_progress' ? (
+ // 与对话流 ActivityLine 的运行中图标共用 ace-spinner,两处状态视觉保持一致。
-
-
-
-
-
-
-
+ />
) : (
);
@@ -1026,7 +1019,7 @@ function SessionRow({
data-sidebar-workspace-session-workspace={workspaceReorderable ? workspaceHash || undefined : undefined}
aria-describedby={hoverCardVisible ? hoverCardId : undefined}
className={clsx(
- 'ace-sidebar-session-row ace-sidebar-tree-row-grid ace-sidebar-primary-text group grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-x-[5px] mx-1.5 my-px px-2 rounded-md text-[14px] transition',
+ 'ace-sidebar-session-row ace-sidebar-tree-row-grid ace-sidebar-primary-text group grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-x-[7px] ml-1.5 mr-0 my-px pl-[13px] pr-[1px] rounded-md text-[14px] transition',
pinned && 'ace-sidebar-pinned-session-row',
workspaceReorderable && 'ace-sidebar-workspace-session-row',
dragging && 'is-dragging',
@@ -1136,7 +1129,7 @@ function SessionRow({
aria-label={remoteControlBound
? tr('remoteControl.connectedSessionAria', { title: marqueeTitle || title })
: (marqueeTitle || title)}
- className="ace-sidebar-session-title-button flex min-w-0 w-full items-center gap-1.5 py-[4.5px] bg-transparent text-left cursor-pointer"
+ className="ace-sidebar-session-title-button ace-sidebar-tree-content flex min-w-0 w-full items-center gap-1.5 px-0 py-[4.5px] bg-transparent text-left cursor-pointer"
>
{remoteControlBound && (
)}
-
+
{!editing && pendingPermission ? (
) : null}
{showSessionTime && !editing && !pendingPermission && !pendingQuestion && (
-
+
{relativeTime(s.updated_at || s.created_at)}
)}
@@ -1182,7 +1175,7 @@ function SessionRow({
onTogglePin?.(s, !pinned);
}}
className={clsx(
- 'ace-session-pin-btn ace-sidebar-row-hover-action w-5 h-7 rounded items-center justify-center shrink-0 transition',
+ 'ace-session-pin-btn ace-sidebar-row-hover-action w-[18px] h-7 rounded items-center justify-center shrink-0 transition',
pinned
? 'flex text-accent'
: 'hidden group-hover:flex group-focus-within:flex text-fg-mute hover:text-fg hover:bg-surface-hi',
@@ -1202,7 +1195,7 @@ function SessionRow({
onArchive?.(s);
}}
className={clsx(
- 'ace-sidebar-row-hover-action w-5 h-7 rounded items-center justify-center shrink-0 text-fg-mute hover:text-fg hover:bg-surface-hi transition',
+ 'ace-sidebar-row-hover-action w-[18px] h-7 rounded items-center justify-center shrink-0 text-fg-mute hover:text-fg hover:bg-surface-hi transition',
sessionMarker
? 'flex'
: 'hidden group-hover:flex group-focus-within:flex',
@@ -1549,7 +1542,7 @@ function WorkspaceGroup({
data-desktop-workspace-remove={onRemove ? 'true' : undefined}
data-desktop-workspace-opencode-import-count={opencodeImportCount > 0 ? String(opencodeImportCount) : undefined}
data-folder-reorderable={folderReorderable && !editing ? 'true' : undefined}
- className="ace-sidebar-workspace-row ace-sidebar-tree-row-grid ace-sidebar-primary-text group grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-x-[5px] mx-1.5 pl-[12px] pr-2 py-[3px] rounded-md text-[14px] cursor-pointer transition text-fg hover:bg-surface-hi"
+ className="ace-sidebar-workspace-row ace-sidebar-tree-row-grid ace-sidebar-primary-text group grid grid-cols-[24px_minmax(0,1fr)_auto] items-center gap-x-[7px] mx-1.5 pl-[13px] pr-[14px] py-[3px] rounded-md text-[14px] cursor-pointer transition text-fg hover:bg-surface-hi"
role="button"
tabIndex={0}
aria-expanded={expanded}
@@ -1584,7 +1577,7 @@ function WorkspaceGroup({
className="ace-sidebar-primary-text min-w-0 h-7 px-1 py-0 text-[14px] bg-surface border border-accent rounded outline-none"
/>
) : (
-
+
{ws.name || ws.hash}
)}
@@ -1612,7 +1605,7 @@ function WorkspaceGroup({
{expanded && (
{sessions.length === 0 ? (
-
+
{sessionsLoading ? '加载中...' : '暂无任务'}
@@ -1642,12 +1635,12 @@ function WorkspaceGroup({
);
})}
{projectedSessions.collapsible && (
-
+
@@ -1682,7 +1675,7 @@ function NoWorkspaceSessionGroup({
return (
{sessions.length === 0 ? (
-
+
{sessionsLoading ? '加载中...' : '暂无任务'}
@@ -1704,12 +1697,12 @@ function NoWorkspaceSessionGroup({
/>
))}
{projectedSessions.collapsible && (
-
+
@@ -3559,7 +3552,7 @@ export function Sidebar({
style={collapsed ? undefined : { width, minWidth: width }}
>
-
+
ACECode
{appVersionLabel && (
@@ -3760,7 +3753,7 @@ export function Sidebar({
)}
-
+
{!collapsed && (
@@ -319,7 +319,7 @@ export function SidebarExtensions({ workspaceHash = '', onOpenSettingsSection, o
aria-expanded={menuOpen}
aria-controls={menuOpen ? 'sidebar-extensions-menu' : undefined}
className={clsx(
- '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',
+ 'ace-sidebar-extensions-trigger ace-sidebar-primary-text w-full flex items-center gap-[7px] pl-[19px] pr-[13px] py-[3px] rounded-md text-[14px] text-fg hover:bg-surface-hi transition',
menuOpen && 'bg-surface-hi',
)}
>
diff --git a/web/src/components/SidebarQuickMenu.jsx b/web/src/components/SidebarQuickMenu.jsx
index 7ce455e9c..757a76153 100644
--- a/web/src/components/SidebarQuickMenu.jsx
+++ b/web/src/components/SidebarQuickMenu.jsx
@@ -48,7 +48,7 @@ export function SidebarQuickMenu({
if (!open) onBeforeOpen?.();
setOpen((value) => !value);
}}
- className="w-9 h-9 shrink-0 flex items-center justify-center rounded-md text-fg-mute hover:text-fg hover:bg-surface-hi transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
+ className="w-6 h-8 shrink-0 flex items-center justify-center rounded-md text-fg-mute hover:text-fg hover:bg-surface-hi transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
>
diff --git a/web/src/components/TopBar.jsx b/web/src/components/TopBar.jsx
index 4253e7a25..f4e49e990 100644
--- a/web/src/components/TopBar.jsx
+++ b/web/src/components/TopBar.jsx
@@ -163,7 +163,6 @@ export function TopBar({
onClick={onToggleSidebar}
pressed={!sidebarCollapsed}
panelToggle
- className="ml-[8px]"
>
diff --git a/web/src/lib/permissionSidebarArchitecture.test.js b/web/src/lib/permissionSidebarArchitecture.test.js
index ae00894fa..87bd949c0 100644
--- a/web/src/lib/permissionSidebarArchitecture.test.js
+++ b/web/src/lib/permissionSidebarArchitecture.test.js
@@ -45,19 +45,18 @@ run('SessionRow uses the AskUserQuestion slot with exact permission wording', ()
assert.match(row, /onSelect\?\.\(s\)/);
});
-run('running sessions use the four-dot breathing indicator', () => {
+run('running sessions reuse the conversation spinner indicator', () => {
const sidebar = source('components/Sidebar.jsx');
const styles = source('styles/globals.css');
const indicator = between(sidebar, 'function SessionAttentionIndicator', 'function SessionHoverCard');
assert.match(indicator, /if \(attention !== 'in_progress' && attention !== 'unread'\) return null/);
- assert.equal((indicator.match(/ace-session-loading-dot is-/g) || []).length, 4);
+ assert.match(indicator, /className="ace-spinner h-3 w-3 shrink-0"/);
assert.match(indicator, /role="status"/);
- assert.match(styles, /\.ace-session-loading-orbit\s*\{[\s\S]*animation: ace-session-loading-turn 6\.47s linear infinite/);
- assert.match(styles, /\.ace-session-loading-dot\.is-top/);
- assert.match(styles, /\.ace-session-loading-dot\.is-right/);
- assert.match(styles, /\.ace-session-loading-dot\.is-bottom/);
- assert.match(styles, /\.ace-session-loading-dot\.is-left/);
- assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.ace-session-loading-orbit/);
+ assert.doesNotMatch(indicator, /ace-session-loading/);
+ // 侧栏运行中状态必须复用对话流 ActivityLine 的同一个 spinner。
+ assert.match(source('components/ActivityLine.jsx'), /ace-spinner h-3 w-3/);
+ assert.match(styles, /\.ace-spinner \{[\s\S]*?animation: ace-spin/);
+ assert.doesNotMatch(styles, /ace-session-loading/);
});
run('permission state reaches pinned, no-workspace, and workspace session rows', () => {
diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js
index b4d74a1d2..c897494c7 100644
--- a/web/src/lib/runTests.js
+++ b/web/src/lib/runTests.js
@@ -246,6 +246,7 @@ import './sidebarWorkspaceDragArchitecture.test.js';
import './sessionCreationActionGuardsArchitecture.test.js';
import './sidebarSessionRevealArchitecture.test.js';
import './sidebarCustomSectionArchitecture.test.js';
+import './sidebarAlignmentArchitecture.test.js';
import './sidebarSessions.test.js';
import './sidebarWorkspaceSessions.test.js';
import './sidebarWorkspaceRefresh.test.js';
diff --git a/web/src/lib/sidebarAlignmentArchitecture.test.js b/web/src/lib/sidebarAlignmentArchitecture.test.js
new file mode 100644
index 000000000..dc5f07060
--- /dev/null
+++ b/web/src/lib/sidebarAlignmentArchitecture.test.js
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const srcRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const sidebar = fs.readFileSync(path.join(srcRoot, 'components/Sidebar.jsx'), 'utf8');
+const extensions = fs.readFileSync(path.join(srcRoot, 'components/SidebarExtensions.jsx'), 'utf8');
+const sidebarQuickMenu = fs.readFileSync(path.join(srcRoot, 'components/SidebarQuickMenu.jsx'), 'utf8');
+const topbar = fs.readFileSync(path.join(srcRoot, 'components/TopBar.jsx'), 'utf8');
+
+assert.match(sidebar, /SidebarNavItem[\s\S]*?pl-\[19px\] pr-\[13px\] py-\[3px\]/);
+assert.match(extensions, /PinnedExtensionItem[\s\S]*?pl-\[19px\] pr-\[13px\] py-\[3px\]/);
+assert.match(sidebar, /SidebarNavItem[\s\S]*?items-center justify-center shrink-0/);
+assert.match(extensions, /PinnedExtensionItem[\s\S]*?items-center justify-center shrink-0/);
+assert.match(sidebar, /ace-sidebar-meta-text whitespace-nowrap text-right text-\[13px\] text-fg-mute shrink-0 tabular-nums/);
+assert.doesNotMatch(sidebar, /ace-sidebar-meta-text w-\[76px\] text-right text-\[13px\]/);
+assert.match(sidebar, /grid-cols-\[24px_minmax\(0,1fr\)_auto\][\s\S]*?gap-x-\[7px\] ml-1\.5 mr-0 my-px pl-\[13px\] pr-\[1px\]/);
+assert.match(sidebar, /ace-sidebar-row-idle-slot ace-sidebar-meta-text[^\n]*text-right/);
+assert.doesNotMatch(sidebar, /ace-sidebar-row-idle-slot ace-sidebar-meta-text w-\[58px\]/);
+assert.doesNotMatch(sidebar, /!pinned && !sessionMarker[\s\S]*?ace-sidebar-row-idle-slot w-\[18px\] shrink-0/);
+assert.match(sidebar, /flex w-full min-w-0 items-center justify-end gap-0/);
+assert.match(sidebar, /ace-session-pin-btn ace-sidebar-row-hover-action w-\[18px\]/);
+assert.match(sidebar, /ace-sidebar-row-hover-action w-\[18px\] h-7/);
+assert.match(sidebar, /ace-sidebar-session-title-button ace-sidebar-tree-content[^\n]*px-0/);
+assert.match(sidebar, /ace-sidebar-tree-content min-w-0 truncate/);
+assert.match(sidebar, /ace-sidebar-tree-content ace-sidebar-meta-text px-0 py-\[5px\]/);
+assert.doesNotMatch(topbar, /className="ml-\[8px\]"/);
+assert.match(sidebar, /SidebarSectionHeader[\s\S]*?px-0 pt-\[6px\] pb-1/);
+// 分节标题的 hover 色块必须与工作区行同宽同高:同样的 mx-1.5 内缩、py-[3px] 行高、rounded-md 圆角。
+assert.match(sidebar, /SidebarSectionHeader[\s\S]*?flex min-w-0 flex-1 items-center mx-1\.5 pl-\[17px\] pr-\[6px\] py-\[3px\] rounded-md/);
+assert.match(sidebar, /SidebarSectionHeader[\s\S]*?inline-flex min-w-0 items-center gap-0\.5/);
+assert.match(sidebar, /data-sidebar-section-disclosure=\{sectionId\}[\s\S]*?inline-flex w-5 h-6/);
+const sectionHeader = sidebar.match(/function SidebarSectionHeader[\s\S]*?\r?\n}\r?\n/)[0];
+assert.equal((sectionHeader.match(/