Skip to content
Merged
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
33 changes: 19 additions & 14 deletions apps/desktop/e2e/mcp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ 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 extensionSelector = page.locator('.maka-module-hub-selector-trigger');
await expect(extensionSelector).toHaveAccessibleName('扩展内容:技能');
await extensionSelector.click();
await page.getByRole('menuitemradio', { name: 'MCP' }).click();
const mcp = page.getByRole('main', { name: '扩展' });
await expect(mcp.getByRole('heading', { name: '扩展' })).toBeVisible();
await expect(extensionSelector).toHaveAccessibleName('扩展内容:MCP');

// 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(extensionSelector).toHaveAccessibleName('扩展内容:MCP');
if (screenshotPath) {
await page.screenshot({ path: variantPath(screenshotPath, 'market') });
await page.getByRole('button', { name: '设置', exact: true }).click();
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/e2e/scroll-geometry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/e2e/session-workbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/e2e/sidebar-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures.js';

async function expandedSidebar(page: Page) {
const expand = page.getByRole('button', { name: '展开侧边栏' });
if (await expand.count()) await expand.click();
return page.getByRole('complementary', { name: '对话列表' });
}

test('session grouping menu applies and retains the selected mode', async ({ window: page }) => {
const sidebar = await expandedSidebar(page);
const grouping = sidebar.getByRole('button', { name: '会话分组方式' });

await grouping.click();
const byStatus = page.getByRole('menuitemradio', { name: '按状态' });
const byProject = page.getByRole('menuitemradio', { name: '按项目' });
await expect(byStatus).toHaveAttribute('aria-checked', 'true');
await byProject.click();

await grouping.click();
await expect(page.getByRole('menuitemradio', { name: '按项目' })).toHaveAttribute('aria-checked', 'true');
await expect(page.getByRole('menuitemradio', { name: '按状态' })).toHaveAttribute('aria-checked', 'false');
});

test('scheduled-task hub restores the last selected child module', async ({ window: page }) => {
const sidebar = await expandedSidebar(page);
const scheduledTasks = sidebar.getByRole('button', { name: '定时任务', exact: true });
await scheduledTasks.click();

const selector = page.locator('.maka-module-hub-selector-trigger');
await expect(selector).toHaveAccessibleName('定时任务内容:计划提醒');
await selector.click();
await page.getByRole('menuitemradio', { name: '每日回顾' }).click();
await expect(selector).toHaveAccessibleName('定时任务内容:每日回顾');

await sidebar.getByRole('button', { name: '扩展', exact: true }).click();
await scheduledTasks.click();
await expect(selector).toHaveAccessibleName('定时任务内容:每日回顾');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement>\('\[data-maka-plan-title-input="true"\]'\)/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 4 additions & 6 deletions apps/desktop/src/main/__tests__/icon-governance-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
46 changes: 46 additions & 0 deletions apps/desktop/src/main/__tests__/navigation-state.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<Suspense fallback=\{<ModulePageFallback label=\{copy\.skills\} message=\{copy\.loadingSkills\} \/>\}>/);
assert.match(modulePages, /<Suspense fallback=\{<ModulePageFallback label=\{copy\.automations\} message=\{copy\.loadingAutomations\} \/>\}>/);
assert.match(modulePages, /<Suspense fallback=\{<ModulePageFallback label=\{props\.hubHeader\?\.title \?\? copy\.skills\} message=\{copy\.loadingSkills\} \/>\}>/);
assert.match(modulePages, /<Suspense fallback=\{<ModulePageFallback label=\{props\.hubHeader\?\.title \?\? copy\.automations\} message=\{copy\.loadingAutomations\} \/>\}>/);
assert.match(modulePages, /<Suspense fallback=\{<ModulePanelFallback message=\{copy\.loadingDailyReview\} \/>\}>/);
assert.doesNotMatch(modulePages, /<Suspense fallback=\{null\}>/);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('session health notice layout contract (#1032)', () => {
);
assert.match(
shell,
/navSelection\.section === 'daily-review' \?[\s\S]*<ChatMessageSurface/,
/navSelection\.section === 'automations' && navSelection\.module === 'daily-review' \?[\s\S]*<ChatMessageSurface/,
'the message surface must stay on the conversation surface, not Skills/Automations/Daily Review',
);
assert.match(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,16 @@ describe('sidebar project view mode', () => {
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 <button> carries aria-pressed and puts the label inline (no <span>).
const statusMarkup = renderSessionListPanel({ viewMode: 'status' });
assert.match(statusMarkup, /<button[^>]*aria-pressed="true"[^>]*>按状态/);
assert.match(statusMarkup, /<button[^>]*aria-pressed="false"[^>]*>按项目/);

const projectMarkup = renderSessionListPanel({ viewMode: 'project' });
assert.match(projectMarkup, /<button[^>]*aria-pressed="false"[^>]*>按状态/);
assert.match(projectMarkup, /<button[^>]*aria-pressed="true"[^>]*>按项目/);
it('moves the status/project controls into the session-list heading menu', async () => {
const markup = renderSessionListPanel({ viewMode: 'status' });
const panel = await readRepo('packages/ui/src/session-list-panel.tsx');

assert.match(markup, /class="maka-session-list-heading"[^>]*>会话/);
assert.match(markup, /aria-label="会话分组方式"/);
assert.doesNotMatch(markup, />按状态|>按项目/);
assert.match(panel, /<MenuRadioGroup value=\{viewMode\}/);
assert.match(panel, /<MenuRadioItem value="status">\{copy\.groupByStatus\}/);
assert.match(panel, /<MenuRadioItem value="project">\{copy\.groupByProject\}/);
});

it('renders project groups as folder headers with an initial four-session preview', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/__tests__/shell-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('shell copy catalog', () => {
const mcpCommand = commands.find((command) => command.id === 'nav:mcp');
assert.equal(mcpCommand?.label, 'Open · MCP');
mcpCommand?.run();
assert.deepEqual(selectedModule, { section: 'mcp' });
assert.deepEqual(selectedModule, { section: 'extensions', module: 'mcp' });

const sessionCommands = buildSessionCommands({
locale: 'en',
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { strict as assert } from 'node:assert';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, it } from 'node:test';

const repoRoot = resolve(process.cwd(), '..', '..');

async function readSource(path: string): Promise<string> {
return readFile(resolve(repoRoot, path), 'utf8');
}

describe('sidebar module hubs contract', () => {
it('renders Extensions as one destination that restores its remembered module', async () => {
const source = await readSource('packages/ui/src/session-sidebar-nav.tsx');

assert.match(
source,
/props\.onSelect\(\{ section: 'extensions', module: moduleMemory\.extensions \}\)/,
);
assert.doesNotMatch(source, /aria-expanded=|maka-sidebar-nav-tree|moduleNavLabel\.skills|moduleNavLabel\.mcp/);
});

it('projects both hubs through one localized title switch without parallel legacy pages', async () => {
const shell = await readSource('apps/desktop/src/renderer/app-shell.tsx');

assert.match(shell, /<ModuleHubSelector[\s\S]*hub="extensions"[\s\S]*hub="automations"/);
assert.match(shell, /<SkillsPage[\s\S]*hubHeader=\{extensionsHubHeader\}/);
assert.match(shell, /<McpPage hubHeader=\{extensionsHubHeader\}/);
assert.match(shell, /<AutomationsPage[\s\S]*hubHeader=\{automationsHubHeader\}/);
assert.match(shell, /<DailyReviewPage[\s\S]*hubHeader=\{automationsHubHeader\}/);
assert.doesNotMatch(shell, /navSelection\.section === '(?:skills|mcp|daily-review)'/);
});

it('hides only session grouping on module pages and preserves history', async () => {
const source = await readSource('packages/ui/src/session-list-panel.tsx');

assert.match(source, /showSessionNavigation\s*=\s*props\.selection\.section\s*===\s*'sessions'/);
assert.match(source, /showSessionNavigation\s*&&\s*onViewModeChange/);
assert.match(source, /<SessionHistoryList/);
assert.doesNotMatch(source, /showSessionNavigation\s*\?\s*\(\s*<SessionHistoryList/s);
assert.match(source, /data-content=\{showSessionNavigation\s*\?\s*'sessions'\s*:\s*'module'\}/);
});
});
Loading
Loading