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
3 changes: 2 additions & 1 deletion apps/desktop/e2e/project-management.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ test('project picker can create an unassigned task and surfaces it under project
const sidebar = page.locator('.maka-session-panel');
await sidebar.getByRole('button', { name: '会话分组方式' }).click();
await page.getByRole('menuitemradio', { name: '按项目' }).click();
await expect(sidebar.locator('.maka-list-project-heading').filter({ hasText: '未归属项目' })).toBeVisible();
// Project groups are collapsible SideNav items, not the retired heading class.
await expect(sidebar.locator('[data-project-id]').filter({ hasText: '未归属项目' })).toBeVisible();
});
67 changes: 39 additions & 28 deletions apps/desktop/e2e/sidebar-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ test('session grouping menu switches between flat conversations and project disc
const grouping = sidebar.getByRole('button', { name: '会话分组方式' });
const popup = page.getByRole('menu', { name: '会话分组方式' });

await expect(sidebar.locator('.maka-list-group-label')).toHaveCount(0);
await expect(sidebar.locator('.astryx-list-item').first()).toBeVisible();
await expect(sidebar.locator('[data-maka-contract="session-row"]').first()).toBeVisible();
await grouping.click();
const byTime = page.getByRole('menuitemradio', { name: '按时间' });
const byProject = page.getByRole('menuitemradio', { name: '按项目' });
await expect(byTime).toHaveAttribute('aria-checked', 'true');
await byProject.click();
await expect(sidebar.locator('.maka-list-project-heading').first()).toBeVisible();
await expect(sidebar.locator('[data-project-id]').first()).toBeVisible();
await expect(sidebar.locator('.astryx-badge').first()).toBeVisible();

// A radio item does not dismiss the menu, so the single trigger click this
// used to do was closing the menu, not reopening it — and the radios below
Expand All @@ -53,46 +53,48 @@ test('session grouping menu switches between flat conversations and project disc
await expect(page.getByRole('menuitemradio', { name: '按时间' })).toHaveAttribute('aria-checked', 'false');
});

test('project history keeps Astryx TreeList as the only arrow-key focus authority', async ({
test('project mode nests sessions under collapsible project SideNav items', async ({
sidebarLongSessionsWindow: page,
}) => {
const sidebar = await expandedSidebar(page);
await sidebar.getByRole('button', { name: '会话分组方式' }).click();
await page.getByRole('menuitemradio', { name: '按项目' }).click();

const treeItems = sidebar.getByRole('treeitem');
const current = sidebar.getByRole('treeitem', { name: /^会话 00\b/ });
await expect(current).toHaveCount(1);
const currentIndex = await current.evaluate((element) =>
Array.from(element.closest('[role="tree"]')?.querySelectorAll('[role="treeitem"]') ?? []).indexOf(element),
);
expect(currentIndex).toBeGreaterThanOrEqual(0);
const next = treeItems.nth(currentIndex + 1);
await expect(current.getByRole('button', { name: /^会话 00\b/ })).toHaveAttribute('tabindex', '-1');

await current.focus();
await expect(current).toBeFocused();
await current.press('ArrowDown');
await expect(next).toBeFocused();
const project = sidebar.locator('[data-project-id]').first();
await expect(project).toBeVisible();
// Project row is a collapsible SideNavItem (button with aria-expanded).
const projectToggle = project.locator('button[aria-expanded]').first();
await expect(projectToggle).toHaveAttribute('aria-expanded', 'true');
const session = project.locator('[data-maka-contract="session-row"]').first();
await expect(session).toBeVisible();
// Nested project sessions are ordinary nav rows, not subagent rows.
await expect(session).not.toHaveAttribute('data-subagent', 'true');
// Product zero-nest: session left edge matches the project row (no 24px tree indent).
const projectBox = await projectToggle.boundingBox();
const sessionBox = await session.boundingBox();
expect(projectBox && sessionBox).toBeTruthy();
if (projectBox && sessionBox) {
expect(Math.abs(sessionBox.x - projectBox.x)).toBeLessThanOrEqual(2);
}
});

test('project rename owns Enter without toggling the Astryx TreeList disclosure', async ({
test('project rename owns Enter without toggling the project disclosure', async ({
sidebarLongSessionsWindow: page,
}) => {
const sidebar = await expandedSidebar(page);
await sidebar.getByRole('button', { name: '会话分组方式' }).click();
await page.getByRole('menuitemradio', { name: '按项目' }).click();

const project = sidebar.getByRole('treeitem').first();
await expect(project).toHaveAttribute('aria-expanded', 'true');
const project = sidebar.locator('[data-project-id]').first();
const projectToggle = project.locator('button[aria-expanded]').first();
await expect(projectToggle).toHaveAttribute('aria-expanded', 'true');
await project.getByRole('button', { name: /项目操作$/ }).click();
await page.getByRole('menuitem', { name: '重命名', exact: true }).click();

const rename = project.getByRole('textbox', { name: '重命名' });
await expect(rename).toBeFocused();
const disclosureState = await project.getAttribute('aria-expanded');
const disclosureState = await projectToggle.getAttribute('aria-expanded');
const originalName = await rename.inputValue();
const tree = sidebar.getByRole('tree');
await rename.press('A');
await expect(rename).toBeFocused();
await rename.press('Space');
Expand All @@ -108,15 +110,14 @@ test('project rename owns Enter without toggling the Astryx TreeList disclosure'
await expect(rename).toBeFocused();
await rename.fill(originalName);
await rename.press('Enter');
await expect(tree).toBeVisible();
await expect(project).toHaveAttribute('aria-expanded', disclosureState ?? 'false');
await expect(projectToggle).toHaveAttribute('aria-expanded', disclosureState ?? 'false');
});

test('double-clicking the flat ListItem menu does not enter rename', async ({
sidebarLongSessionsWindow: page,
}) => {
const sidebar = await expandedSidebar(page);
const row = sidebar.locator('.astryx-list-item').filter({ hasText: '会话 00' }).first();
const row = sidebar.locator('[data-maka-contract="session-row"]').filter({ hasText: '会话 00' }).first();

await row.getByRole('button', { name: '对话操作' }).dispatchEvent('dblclick');

Expand All @@ -127,8 +128,9 @@ test('session delete intent opens only after its menu closes and restores the tr
sidebarLongSessionsWindow: page,
}) => {
const sidebar = await expandedSidebar(page);
const row = sidebar.locator('.astryx-list-item').filter({ hasText: '会话 00' }).first();
const rowButton = row.locator(':scope > button');
const row = sidebar.locator('[data-maka-contract="session-row"]').filter({ hasText: '会话 00' }).first();
// SideNavItem wraps the primary control: row > root div > button (not row > button).
const rowButton = row.getByRole('button', { name: '会话 00' });
await expect(rowButton).toHaveCount(1);
await rowButton.focus();

Expand All @@ -147,6 +149,15 @@ test('session delete intent opens only after its menu closes and restores the tr
await expect(trigger).toBeFocused();
});

test('session heading stays singular and the default list has no redundant heading', async ({
sidebarLongSessionsWindow: page,
}) => {
const sidebar = await expandedSidebar(page);

await expect(sidebar.getByText('会话', { exact: true })).toHaveCount(1);
await expect(sidebar.locator('[data-maka-contract="session-row"]').first()).toBeVisible();
});

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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function renderSessionListPanel(options: {
childSessionsByParentId?: Parameters<
typeof SessionListPanel
>[0]['childSessionsByParentId'];
staleSessionIds?: Parameters<typeof SessionListPanel>[0]['staleSessionIds'];
viewMode?: Parameters<typeof SessionListPanel>[0]['viewMode'];
} = {}): string {
const rowActions = options.rowActions ?? {
Expand All @@ -50,6 +51,7 @@ export function renderSessionListPanel(options: {
projectActions: options.projectActions,
worktreeSessionIds: options.worktreeSessionIds,
childSessionsByParentId: options.childSessionsByParentId,
staleSessionIds: options.staleSessionIds,
viewMode: options.viewMode,
onViewModeChange: options.viewMode ? () => {} : undefined,
onSelectSession() {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,23 @@ describe('sidebar project view mode', () => {
});

assert.match(markup, />Active project</);
assert.match(markup, /maka-list-project-count[^>]*>1</);
assert.match(markup, /astryx-badge[^>]*>1</);
assert.match(markup, />Missing project</);
assert.match(markup, /aria-label="Active project 项目操作"/);
assert.match(markup, /aria-label="Missing project 项目操作"/);
// Empty projects are leaves (no fabricated collapsible chrome).
const missingChunk = markup.slice(
markup.indexOf('data-project-id="project:project-missing"'),
markup.indexOf('data-project-id="project:project-missing"') + 1200,
);
assert.doesNotMatch(missingChunk, /aria-expanded=/);
// Active project with sessions is a real disclosure.
assert.match(markup, /aria-expanded="true"/);
// Archived disclosure stays collapsible: children always mount so Astryx
// can keep the chevron; default collapsed hides them with inert/aria-hidden.
assert.match(markup, />已归档项目</);
assert.doesNotMatch(markup, />Archived project</);
assert.match(markup, /inert[\s\S]*Archived project|aria-hidden="true"[\s\S]*Archived project/);
assert.match(markup, />Archived project</);
assert.equal((markup.match(/lucide-folder-git-2/g) ?? []).length, 1);
});

Expand Down Expand Up @@ -173,7 +184,7 @@ describe('sidebar project view mode', () => {
it('moves the conversation/project controls into the session-list heading menu', () => {
const markup = renderSessionListPanel({ viewMode: 'conversation' });

assert.match(markup, /class="maka-session-list-heading"[^>]*>会话/);
assert.match(markup, /maka-session-heading-section/);
assert.match(markup, /aria-label="会话分组方式"/);
const trigger = markup.match(/<button(?=[^>]*aria-label="会话分组方式")[\s\S]*?<\/button>/)?.[0] ?? '';
assert.ok(trigger, 'the grouping trigger must render');
Expand Down Expand Up @@ -205,12 +216,14 @@ describe('sidebar project view mode', () => {
});

assert.equal((markup.match(/>会话</g) ?? []).length, 1);
assert.equal((markup.match(/maka-list-group-label/g) ?? []).length, 0);
assert.equal((markup.match(/maka-list-row-status-icon/g) ?? []).length, 2);
assert.match(markup, /data-status="running"/);
assert.match(markup, /data-status="blocked"/);
assert.doesNotMatch(markup, /data-status="active"/);
assert.doesNotMatch(markup, />进行中|>需要处理|>可继续|>已完成/);
assert.equal((markup.match(/data-session-status="running"/g) ?? []).length, 1);
assert.equal((markup.match(/data-session-status="blocked"/g) ?? []).length, 1);
assert.doesNotMatch(markup, /data-session-status="active"/);
// Active rows keep an empty end slot; only non-active statuses mount a StatusDot.
const activeChunk =
markup.match(/data-session-id="active-session"[\s\S]*?(?=data-session-id="|$)/)?.[0] ?? '';
assert.ok(activeChunk.includes('Active session'));
assert.doesNotMatch(activeChunk, /data-session-status=/);
});

it('renders linked child sessions directly beneath their parent as normal selectable rows', () => {
Expand All @@ -224,7 +237,14 @@ describe('sidebar project view mode', () => {
assert.ok(markup.indexOf('Parent task') < markup.indexOf('Child agent'));
assert.match(markup, /data-subagent="true"/);
assert.match(markup, /data-session-id="child"/);
assert.match(markup, /lucide-bot/);
assert.match(markup, /data-maka-contract="session-row"/);
// Nested subagent rows use native SideNavItem Bot icon (lucide-bot).
const childChunk =
markup.match(/data-session-id="child"[\s\S]*?(?=data-session-id="|$)/)?.[0] ?? '';
assert.match(childChunk, /lucide-bot/);
const parentChunk =
markup.match(/data-session-id="parent"[\s\S]*?(?=data-session-id="child"|$)/)?.[0] ?? '';
assert.doesNotMatch(parentChunk, /lucide-bot/);
});

it('applies Chats, Flagged, and Archived filters independently to parents and children', () => {
Expand Down Expand Up @@ -365,17 +385,22 @@ describe('sidebar project view mode', () => {
assert.ok(groups.some((g) => g.label === 'repo-a'), 'expected a repo-a label');
assert.ok(groups.some((g) => g.label === 'x'), 'expected an x label');

// Astryx TreeList uses the stable group id as its tree identity.
// Project SideNav rows carry the session ids under each project; group
// identity is the stable project:* id from deriveProjectGroups.
const markup = renderSessionListPanel({
sessions,
groups,
viewMode: 'project',
});
const treeIds = [...markup.matchAll(/data-tree-id="([^"]*)"/g)].map((m) => m[1]);
assert.ok(treeIds.length >= 3, `expected at least 3 tree ids, got ${treeIds.length}`);
for (const id of treeIds) {
assert.match(id, /^[A-Za-z0-9:_-]+$/, `rendered tree id must be DOM-safe, got: ${id}`);
for (const id of ids) {
assert.match(
markup,
new RegExp(`data-project-id="${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`),
);
}
assert.match(markup, /data-session-id="a"/);
assert.match(markup, /data-session-id="b"/);
assert.match(markup, /data-session-id="c"/);
});
});

Expand Down
59 changes: 53 additions & 6 deletions apps/desktop/src/main/__tests__/stale-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,28 @@ describe('stale session CSS contract (@kenji review gate)', () => {

it('inactive stale row dims, active stale row restores opacity', async () => {
const css = await readRendererContractCss();
// Inactive stale dimming rule must exist.
// SideNav session rows: stale dims only this row's item (child combinators);
// current page on that item restores opacity.
assert.match(
css,
/\.maka-session-item-label\[data-stale="true"\]\s*\{[\s\S]*?opacity:\s*var\(--opacity-muted\)/,
'expected stale product content to use the muted opacity token',
/\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\s*\{[\s\S]*?opacity:\s*var\(--opacity-muted\)/,
'expected stale SideNav session rows to dim only the direct SideNavItem',
);
// Active stale restoration rule must exist.
assert.match(
css,
/\[role="treeitem"\]\[aria-selected="true"\][\s\S]*?\.maka-session-item-label\[data-stale="true"\][\s\S]*?opacity:\s*1/,
'expected Astryx-selected stale content to restore full opacity',
/\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item\[aria-current="page"\]/,
'expected the active stale leaf SideNav row to restore full opacity',
);
assert.match(
css,
/\.maka-session-row\[data-stale="true"\]\s*>\s*div\s*>\s*\.astryx-side-nav-item:has\(\s*>\s*\[aria-current="page"\]\s*\)/,
'expected the active stale collapsible SideNav row to restore full opacity',
);
// A bare descendant selector would mute healthy subagents under a stale parent.
assert.doesNotMatch(
css,
/\.maka-session-row\[data-stale="true"\](?![^\n{]*\s*>\s*div\s*>)\s+\.astryx-side-nav-item/,
'stale opacity must not use an unbounded descendant selector',
);
});

Expand All @@ -145,4 +156,40 @@ describe('stale session CSS contract (@kenji review gate)', () => {
'no CSS rule may hide `.maka-list-row-stale-pill` (active stale row must still show pill)',
);
});

it('staleSessionIds wires data-stale and pill only on marked sessions', async () => {
const { makeSessionSummary, renderSessionListPanel } = await import(
'./session-list-render-helpers.js'
);
const parent = makeSessionSummary({ id: 'parent', name: 'Parent' });
const child = makeSessionSummary({ id: 'child', name: 'Child' });
const html = renderSessionListPanel({
sessions: [parent],
childSessionsByParentId: new Map([['parent', [child]]]),
staleSessionIds: new Set(['parent']),
});

const parentChunk = html.match(
/data-session-id="parent"[\s\S]*?(?=data-session-id="child"|$)/,
)?.[0];
const childChunk = html.match(/data-session-id="child"[\s\S]*?(?=data-session-id=|$)/)?.[0];
assert.ok(parentChunk, 'expected parent session row markup');
assert.ok(childChunk, 'expected child session row markup');
assert.match(parentChunk, /data-stale="true"/, 'stale parent must set data-stale');
assert.match(
parentChunk,
/maka-list-row-stale-pill/,
'stale parent must render the stale pill',
);
assert.doesNotMatch(
childChunk,
/data-stale="true"/,
'healthy child must not inherit data-stale from parent',
);
assert.doesNotMatch(
childChunk,
/maka-list-row-stale-pill/,
'healthy child must not render a stale pill',
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ describe('UI render memo boundary contract', () => {
createSession('session-b', 'Beta'),
];
const groups = [{ id: 'all', label: '', sessions }];
// Omit rowActions: permanent MoreMenu mounts Astryx DropdownMenu, which
// needs a full focusable DOM (hasAttribute). This contract only measures
// SessionNavRow identity for formatSessionMeta under stable props.
const stableProps = {
SessionHistoryList,
LocaleProvider,
activeId: 'session-a',
groups,
onSelectSession: () => {},
rowActions: createRowActions(),
sessions,
staleSessionIds: new Set<string>(),
};
Expand Down Expand Up @@ -101,7 +103,6 @@ function RenderHost(props: {
groups: ReadonlyArray<{ id: string; label: string; sessions: SessionSummary[] }>;
label: string;
onSelectSession(sessionId: string): void;
rowActions: NonNullable<Parameters<SessionHistoryModule['SessionHistoryList']>[0]['rowActions']>;
sessions: SessionSummary[];
staleSessionIds: Set<string>;
streamingSessionIds: Set<string>;
Expand All @@ -119,7 +120,6 @@ function RenderHost(props: {
streamingSessionIds: props.streamingSessionIds,
staleSessionIds: props.staleSessionIds,
onSelectSession: props.onSelectSession,
rowActions: props.rowActions,
}),
),
});
Expand Down Expand Up @@ -163,16 +163,6 @@ function timestampReads(sessions: SessionSummary[]): number[] {
);
}

function createRowActions(): NonNullable<Parameters<SessionHistoryModule['SessionHistoryList']>[0]['rowActions']> {
return {
onToggleFlag() {},
onArchive() {},
onUnarchive() {},
onRename() {},
onDelete() {},
};
}

async function importSessionHistoryList(): Promise<SessionHistoryModule> {
const outfile = resolve(
REPO_ROOT,
Expand Down
Loading
Loading