From 2db12160dfa42e411607267c37f6ad5ab6bd1d2f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 24 Jul 2026 18:01:33 +0800 Subject: [PATCH 1/4] feat(ui): consolidate sidebar modules into hubs --- apps/desktop/e2e/mcp.spec.ts | 32 +++-- apps/desktop/e2e/scroll-geometry.spec.ts | 2 +- apps/desktop/e2e/session-workbar.spec.ts | 4 +- ...and-palette-plan-reminder-contract.test.ts | 2 +- ...aily-review-copy-feedback-contract.test.ts | 2 +- .../icon-governance-contract.test.ts | 10 +- .../main/__tests__/navigation-state.test.ts | 46 ++++++ .../renderer-lazy-fallback-contract.test.ts | 4 +- ...sion-health-notice-layout-contract.test.ts | 2 +- .../session-project-view-contract.test.ts | 20 +-- .../src/main/__tests__/shell-copy.test.ts | 2 +- .../sidebar-extensions-tree-contract.test.ts | 55 -------- .../sidebar-module-hubs-contract.test.ts | 43 ++++++ .../__tests__/sidebar-scroll-contract.test.ts | 12 +- .../desktop/src/main/__tests__/skills.test.ts | 2 +- .../src/renderer/app-shell-e2e-fixture.ts | 8 +- .../desktop/src/renderer/app-shell-effects.ts | 16 +-- apps/desktop/src/renderer/app-shell.tsx | 79 ++++++++--- .../src/renderer/command-palette-commands.ts | 8 +- apps/desktop/src/renderer/mcp-page.tsx | 11 +- apps/desktop/src/renderer/nav-selection.ts | 133 ++++++++++++++---- .../styles/module-pages/module-shell.css | 10 ++ apps/desktop/src/renderer/styles/sidebar.css | 98 +++---------- apps/desktop/stories/app-shell.stories.tsx | 5 +- apps/desktop/stories/module-hubs.stories.tsx | 72 ++++++++++ .../__tests__/sidebar-subtraction.test.tsx | 91 ++++++++++++ packages/ui/src/components.tsx | 10 +- packages/ui/src/daily-review-panel.tsx | 8 +- packages/ui/src/module-hub-switch.tsx | 53 +++++++ packages/ui/src/module-pages.tsx | 26 ++-- packages/ui/src/nav-selection.ts | 13 +- packages/ui/src/plan-reminder-panel.tsx | 8 +- packages/ui/src/session-list-panel.tsx | 38 +++-- packages/ui/src/session-sidebar-nav.tsx | 93 +++--------- packages/ui/src/shared-ui-copy.ts | 48 +++++++ packages/ui/src/shell-controls-copy.ts | 11 +- packages/ui/src/skills-panel.tsx | 10 +- .../ui/stories/interaction-states.stories.tsx | 2 +- 38 files changed, 715 insertions(+), 374 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/navigation-state.test.ts delete mode 100644 apps/desktop/src/main/__tests__/sidebar-extensions-tree-contract.test.ts create mode 100644 apps/desktop/src/main/__tests__/sidebar-module-hubs-contract.test.ts create mode 100644 apps/desktop/stories/module-hubs.stories.tsx create mode 100644 packages/ui/src/__tests__/sidebar-subtraction.test.tsx create mode 100644 packages/ui/src/module-hub-switch.tsx diff --git a/apps/desktop/e2e/mcp.spec.ts b/apps/desktop/e2e/mcp.spec.ts index 6fa8794f58..b79478d6ee 100644 --- a/apps/desktop/e2e/mcp.spec.ts +++ b/apps/desktop/e2e/mcp.spec.ts @@ -11,22 +11,26 @@ test('MCP module completes stdio add, discovery, disable, JSON import, and delet await page.getByRole('button', { name: '展开侧边栏' }).click(); const sidebar = page.getByRole('complementary', { name: '对话列表' }); const extensions = sidebar.getByRole('button', { name: '扩展', exact: true }); - await expect(extensions).toHaveAttribute('aria-expanded', 'true'); - await expect(sidebar.getByRole('button', { name: '技能', exact: true })).toBeVisible(); - await expect(sidebar.getByRole('button', { name: 'MCP', exact: true })).toBeVisible(); + await expect(sidebar.getByRole('button', { name: '技能', exact: true })).toHaveCount(0); + await expect(sidebar.getByRole('button', { name: 'MCP', exact: true })).toHaveCount(0); await extensions.click(); - await expect(extensions).toHaveAttribute('aria-expanded', 'false'); - await expect(sidebar.getByRole('button', { name: 'MCP', exact: true })).toBeHidden(); - if (screenshotPath) { - await extensions.hover(); - await page.screenshot({ path: variantPath(screenshotPath, 'extensions-hover') }); - } - await extensions.click(); - await sidebar.getByRole('button', { name: 'MCP', exact: true }).click(); - await expect(sidebar.getByRole('group', { name: '会话分组方式' })).toHaveCount(0); + await expect(extensions).toHaveAttribute('aria-current', 'page'); + await expect(sidebar.getByRole('button', { name: '会话分组方式' })).toHaveCount(0); await expect(sidebar.locator('.maka-session-list')).toBeVisible(); - const mcp = page.getByRole('main', { name: 'MCP' }); - await expect(mcp.getByRole('heading', { name: 'MCP' })).toBeVisible(); + + const extensionSwitch = page.getByRole('group', { name: '扩展内容' }); + await expect(extensionSwitch.getByRole('button', { name: '技能' })).toHaveAttribute('aria-pressed', 'true'); + await extensionSwitch.getByRole('button', { name: 'MCP' }).click(); + const mcp = page.getByRole('main', { name: '扩展' }); + await expect(mcp.getByRole('heading', { name: '扩展' })).toBeVisible(); + await expect(extensionSwitch.getByRole('button', { name: 'MCP' })).toHaveAttribute('aria-pressed', 'true'); + + // Each hub restores its last module when the user returns from another + // sidebar destination. + await sidebar.getByRole('button', { name: '定时任务', exact: true }).click(); + await extensions.click(); + await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); + await expect(extensionSwitch.getByRole('button', { name: 'MCP' })).toHaveAttribute('aria-pressed', 'true'); if (screenshotPath) { await page.screenshot({ path: variantPath(screenshotPath, 'market') }); await page.getByRole('button', { name: '设置', exact: true }).click(); diff --git a/apps/desktop/e2e/scroll-geometry.spec.ts b/apps/desktop/e2e/scroll-geometry.spec.ts index 3e1bc82735..fb5b74b5ce 100644 --- a/apps/desktop/e2e/scroll-geometry.spec.ts +++ b/apps/desktop/e2e/scroll-geometry.spec.ts @@ -150,7 +150,7 @@ test('returning to the session after visiting skills re-settles the new transcri // `.maka-turn` node with no remembered size, so the warm-up must walk the // NEW DOM. Fixture windows don't pass OS hit-testing — dispatch clicks. await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); - await page.locator('button[aria-label="技能"]').dispatchEvent('click'); + await page.locator('button[aria-label="扩展"]').dispatchEvent('click'); await expect(page.locator('.maka-turn')).toHaveCount(0); await page.getByText('超长会话滚动几何').first().dispatchEvent('click'); await expect(page.locator('.maka-turn')).toHaveCount(24); diff --git a/apps/desktop/e2e/session-workbar.spec.ts b/apps/desktop/e2e/session-workbar.spec.ts index 29658bf300..adf535d9f5 100644 --- a/apps/desktop/e2e/session-workbar.spec.ts +++ b/apps/desktop/e2e/session-workbar.spec.ts @@ -25,9 +25,9 @@ test('session tools share one user-controlled workbar', async ({ sessionWorkbarW expect(narrowLayout.height).toBeLessThanOrEqual(narrowLayout.viewportHeight * 0.42 + 1); await page.locator('button[aria-label="展开侧边栏"]').dispatchEvent('click'); - await page.locator('button[aria-label="技能"]').dispatchEvent('click'); + await page.locator('button[aria-label="扩展"]').dispatchEvent('click'); await expect(workbar).toBeHidden(); - await expect(page.getByRole('main', { name: '技能' })).toBeVisible(); + await expect(page.getByRole('main', { name: '扩展' })).toBeVisible(); }); test('workbar toggle stays unmounted without an active session', async ({ window: page }) => { diff --git a/apps/desktop/src/main/__tests__/command-palette-plan-reminder-contract.test.ts b/apps/desktop/src/main/__tests__/command-palette-plan-reminder-contract.test.ts index cb16b93191..b75e31e160 100644 --- a/apps/desktop/src/main/__tests__/command-palette-plan-reminder-contract.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-plan-reminder-contract.test.ts @@ -26,7 +26,7 @@ describe('command palette plan reminder contract', () => { const dialog = await readFile(resolve(REPO_ROOT, 'packages/ui/src/plan-reminder-form-dialog.tsx'), 'utf8'); assert.match(main, /function\s+openPlanReminderForm\(\)/); - assert.match(main, /setNavSelection\(\{\s*section:\s*'automations'\s*\}\)/); + assert.match(main, /setNavSelection\(\{\s*section:\s*'automations',\s*module:\s*'plan-reminders'\s*\}\)/); // #1045: run() reads openPlanReminderForm from the live options ref. assert.match(main, /onStartPlanReminder:\s*\(\)\s*=>\s*optionsRef\.current\.openPlanReminderForm\(\)/); assert.match(main, /querySelector\('\[data-maka-plan-title-input="true"\]'\)/); diff --git a/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts b/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts index 409079db08..34434d0610 100644 --- a/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts +++ b/apps/desktop/src/main/__tests__/daily-review-copy-feedback-contract.test.ts @@ -36,7 +36,7 @@ describe('Daily Review copy feedback contract', () => { assert.match(main, /async function copyDailyReviewMarkdown\([\s\S]*?await navigator\.clipboard\.writeText\(input\.markdown\)/); assert.match( main, - /function isDailyReviewSurfaceActive\(\): boolean \{[\s\S]*return navSelectionRef\.current\.section === 'daily-review';[\s\S]*\}/, + /function isDailyReviewSurfaceActive\(\): boolean \{[\s\S]*return navSelectionRef\.current\.section === 'automations' && navSelectionRef\.current\.module === 'daily-review';[\s\S]*\}/, 'Daily Review action feedback must be owned by the active Daily Review surface', ); assert.match( diff --git a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts index aa29b963ed..550608320a 100644 --- a/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts +++ b/apps/desktop/src/main/__tests__/icon-governance-contract.test.ts @@ -208,12 +208,10 @@ describe('icon + typography governance contract', () => { .map((name) => name.trim()) .filter(Boolean) .sort(); - // Decided semantic mapping (maintainer 2026-07-10: 新任务 matches the - // collapsed-topbar compose icon): 新任务 → SquarePen, 每日回顾 → CalendarCheck, - // 扩展 → Blocks/ChevronDown, 定时任务 → Timer, 设置 → Settings. The - // expanded tree uses text-only children so Skills and MCP read as one - // nested branch instead of a second row of primary navigation icons. - const expected = ['Blocks', 'CalendarCheck', 'ChevronDown', 'ChevronRight', 'Settings', 'SquarePen', 'Timer']; + // Hub-level mapping: 新任务 → SquarePen, 扩展 → Blocks, + // 定时任务 → Timer, 设置 → Settings. Skills/MCP and Plan reminders/ + // Daily review switch inside their main surfaces, not in sidebar rows. + const expected = ['Blocks', 'Settings', 'SquarePen', 'Timer']; assert.deepEqual( imported, expected, diff --git a/apps/desktop/src/main/__tests__/navigation-state.test.ts b/apps/desktop/src/main/__tests__/navigation-state.test.ts new file mode 100644 index 0000000000..9fa5451e9a --- /dev/null +++ b/apps/desktop/src/main/__tests__/navigation-state.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { parseNavigationState, selectNavigation } from '../../renderer/nav-selection.js'; + +describe('sidebar navigation state', () => { + it('migrates legacy module destinations into their hub tabs', () => { + assert.deepEqual(parseNavigationState(JSON.stringify({ section: 'skills' })), { + selection: { section: 'extensions', module: 'skills' }, + moduleMemory: { extensions: 'skills', automations: 'plan-reminders' }, + }); + assert.deepEqual(parseNavigationState(JSON.stringify({ section: 'mcp' })), { + selection: { section: 'extensions', module: 'mcp' }, + moduleMemory: { extensions: 'mcp', automations: 'plan-reminders' }, + }); + assert.deepEqual(parseNavigationState(JSON.stringify({ section: 'daily-review' })), { + selection: { section: 'automations', module: 'daily-review' }, + moduleMemory: { extensions: 'skills', automations: 'daily-review' }, + }); + assert.deepEqual(parseNavigationState(JSON.stringify({ section: 'automations' })), { + selection: { section: 'automations', module: 'plan-reminders' }, + moduleMemory: { extensions: 'skills', automations: 'plan-reminders' }, + }); + }); + + it('remembers each hub tab while navigating elsewhere', () => { + const initial = parseNavigationState(null); + const mcp = selectNavigation(initial, { section: 'extensions', module: 'mcp' }); + const dailyReview = selectNavigation(mcp, { section: 'automations', module: 'daily-review' }); + const sessions = selectNavigation(dailyReview, { section: 'sessions', filter: 'chats' }); + + assert.deepEqual(sessions, { + selection: { section: 'sessions', filter: 'chats' }, + moduleMemory: { extensions: 'mcp', automations: 'daily-review' }, + }); + }); + + it('restores persisted tab memory independently of the active destination', () => { + assert.deepEqual(parseNavigationState(JSON.stringify({ + selection: { section: 'sessions', filter: 'chats' }, + moduleMemory: { extensions: 'mcp', automations: 'daily-review' }, + })), { + selection: { section: 'sessions', filter: 'chats' }, + moduleMemory: { extensions: 'mcp', automations: 'daily-review' }, + }); + }); +}); diff --git a/apps/desktop/src/main/__tests__/renderer-lazy-fallback-contract.test.ts b/apps/desktop/src/main/__tests__/renderer-lazy-fallback-contract.test.ts index fdc0728c16..c799e730f7 100644 --- a/apps/desktop/src/main/__tests__/renderer-lazy-fallback-contract.test.ts +++ b/apps/desktop/src/main/__tests__/renderer-lazy-fallback-contract.test.ts @@ -38,8 +38,8 @@ describe('renderer lazy fallback contract', () => { assert.match(modulePages, /function ModulePageFallback/, 'whole-page modules must reserve a module loading shell'); assert.match(modulePages, /function ModulePanelFallback/, 'daily review content must reserve a panel loading shell'); - assert.match(modulePages, /\}>/); - assert.match(modulePages, /\}>/); + assert.match(modulePages, /\}>/); + assert.match(modulePages, /\}>/); assert.match(modulePages, /\}>/); assert.doesNotMatch(modulePages, //); }); diff --git a/apps/desktop/src/main/__tests__/session-health-notice-layout-contract.test.ts b/apps/desktop/src/main/__tests__/session-health-notice-layout-contract.test.ts index ce22352301..af3b133c80 100644 --- a/apps/desktop/src/main/__tests__/session-health-notice-layout-contract.test.ts +++ b/apps/desktop/src/main/__tests__/session-health-notice-layout-contract.test.ts @@ -40,7 +40,7 @@ describe('session health notice layout contract (#1032)', () => { ); assert.match( shell, - /navSelection\.section === 'daily-review' \?[\s\S]* { assert.match(fallbackMarkup, /待发送/); }); - it('renders the status/project view mode controls as a pressed segmented control', () => { - // #571 routes the toggle through the shared Segmented primitive, - // whose