Skip to content
Open
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
66 changes: 39 additions & 27 deletions apps/desktop/e2e/sidebar-project-row.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,39 +112,51 @@ test('project navigation and actions follow their visual keyboard order', async
await expect(action).toBeFocused();
});

test('task row action menu accepts pointer selection', async ({
test('task rows keep double-click rename out of the action menu', async ({
projectSidebarWindow: page,
}) => {
await page.keyboard.press('Escape');
await expect(page.locator('[data-maka-contract="search-modal"]')).not.toBeVisible();

const sidebar = page.getByRole('navigation', { name: '任务列表' });
const taskSessionId = `${LONG_SIDEBAR_SESSION_PREFIX}00`;
const taskRow = sessionRow(sidebar, taskSessionId);
const actionMenu = taskRow.locator(':scope > .maka-session-row-action');
const timestamp = taskRow.locator('.maka-session-row-time');
await expect(timestamp).toHaveCSS('visibility', 'visible');
await taskRow.hover();
await taskRow.getByRole('button', { name: /任务操作$/ }).click();

const rename = page.getByRole('menuitem', { name: '重命名', exact: true });
await expect(rename).toBeVisible();
await expect(actionMenu).toHaveAttribute('data-menu-open', 'true');
await rename.hover();
await expect.poll(() => taskRow.evaluate((row) => row.matches(':hover'))).toBe(false);
await expect(timestamp).toHaveCSS('visibility', 'hidden');

await page.mouse.click(4, 4);
await expect(rename).not.toBeVisible();
await expect(actionMenu).not.toHaveAttribute('data-menu-open', 'true');

await taskRow.hover();
await taskRow.getByRole('button', { name: /任务操作$/ }).focus();
await page.keyboard.press('Enter');
await expect(rename).toBeVisible();
await rename.click();

await expect(page.getByRole('dialog', { name: '重命名任务' })).toBeVisible();
await sidebar.getByRole('radio', { name: '按项目', exact: true }).click();
const projectGroup = sidebar.locator(
`[data-project-id="project:${LONG_SIDEBAR_PROJECT_ID}"]`,
);
const noProjectGroup = sidebar.locator('[data-project-id="__ungrouped__"]');

async function expectDoubleClickRenameOnly(group: Locator, sessionId: string): Promise<void> {
const taskRow = sessionRow(group, sessionId);
const taskButton = taskRow.locator(':scope > div > button.astryx-side-nav-item');
const actionMenu = taskRow.locator(':scope > .maka-session-row-action');
const timestamp = taskRow.locator('.maka-session-row-time');
await expect(taskButton).toBeVisible();
await expect(timestamp).toHaveCSS('visibility', 'visible');

await taskRow.hover();
await taskRow.getByRole('button', { name: /任务操作$/ }).click();

const pin = page.getByRole('menuitem', { name: '置顶', exact: true });
await expect(pin).toBeVisible();
await expect(page.getByRole('menuitem', { name: '重命名', exact: true })).toHaveCount(0);
await expect(actionMenu).toHaveAttribute('data-menu-open', 'true');
await pin.hover();
await expect.poll(() => taskRow.evaluate((row) => row.matches(':hover'))).toBe(false);
await expect(timestamp).toHaveCSS('visibility', 'hidden');

await page.mouse.click(4, 4);
await expect(pin).not.toBeVisible();
await expect(actionMenu).not.toHaveAttribute('data-menu-open', 'true');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: the test this replaced also opened the menu from the keyboard, with .focus() on the trigger, Enter, and an assertion that the item was reachable that way. Nothing in this file picks that up, and the neighbouring project navigation and actions follow their visual keyboard order only covers Tab order, not activation. Three lines here restore it: focus the 任务操作 trigger, press Enter, assert pin is visible. That coverage matters more now than before, since the menu is the only row-level action surface left.


await taskButton.dblclick();
const dialog = page.getByRole('dialog', { name: '重命名任务' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: '关闭', exact: true }).click();
await expect(taskButton).toBeFocused();
}

await expectDoubleClickRenameOnly(projectGroup, `${LONG_SIDEBAR_SESSION_PREFIX}00`);
await expectDoubleClickRenameOnly(noProjectGroup, `${LONG_SIDEBAR_SESSION_PREFIX}03`);
});

test('project and task rows show contextual hover details', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ test('the menu is about the one row when only that row is picked', async () => {
const harness = await mount({ selectedIds: ['b'] });
try {
await harness.openRowMenu('b');
assert.deepEqual(harness.menuLabels(), ['Pin', 'Rename', 'Archive']);
assert.deepEqual(harness.menuLabels(), ['Pin', 'Archive']);
} finally {
await harness.dispose();
}
Expand Down Expand Up @@ -700,7 +700,7 @@ test('a menu sweeps only the picked rows still on screen', async () => {
// so it leaves the set here, and stays out of it.
assert.deepEqual(harness.retains.at(-1), ['a1', 'a2']);
// And the menu says so: one row, with the single-row wording.
assert.deepEqual(harness.menuLabels(), ['Pin', 'Rename', 'Archive']);
assert.deepEqual(harness.menuLabels(), ['Pin', 'Archive']);
} finally {
await harness.dispose();
}
Expand Down
5 changes: 2 additions & 3 deletions packages/ui/src/conversation-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,6 @@ export interface ConversationCopy {
actionsAriaLabel: (name: string) => string;
pin: string;
unpin: string;
rename: string;
archive: string;
unarchive: string;
delete: string;
Expand Down Expand Up @@ -573,7 +572,7 @@ const CONVERSATION_COPY = {
sessions: {
status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' },
blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' },
listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', projects: '项目', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, pickedAriaLabel: '已选中', pinCount: (count) => `置顶 ${count} 项`, unpinCount: (count) => `取消置顶 ${count} 项`, archiveCount: (count) => `归档 ${count} 项`,
listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', projects: '项目', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, pickedAriaLabel: '已选中', pinCount: (count) => `置顶 ${count} 项`, unpinCount: (count) => `取消置顶 ${count} 项`, archiveCount: (count) => `归档 ${count} 项`,
},
},
en: {
Expand Down Expand Up @@ -731,7 +730,7 @@ const CONVERSATION_COPY = {
sessions: {
status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' },
blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' },
listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', projects: 'Projects', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, pickedAriaLabel: 'Selected', pinCount: (count) => `Pin ${count} tasks`, unpinCount: (count) => `Unpin ${count} tasks`, archiveCount: (count) => `Archive ${count} tasks`,
listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', projects: 'Projects', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, pickedAriaLabel: 'Selected', pinCount: (count) => `Pin ${count} tasks`, unpinCount: (count) => `Unpin ${count} tasks`, archiveCount: (count) => `Archive ${count} tasks`,
},
},
} satisfies UiCatalog<ConversationCopy>;
Expand Down
35 changes: 2 additions & 33 deletions packages/ui/src/session-history-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
import { getSessionHoverCardCopy } from './session-hover-card-copy.js';

type SessionRowActionId = 'flag' | 'archive' | 'rename';
type SessionRowActionId = 'flag' | 'archive';
type ProjectRowActionId = 'new' | 'relink' | 'rename' | 'archive' | 'restore';
type SessionHistoryGroupVariant = 'conversation' | 'project';

Expand Down Expand Up @@ -839,7 +839,6 @@ const SessionNavRow = memo(function SessionNavRow(props: {
bulkCount={props.bulkCount}
bulkAllPinned={props.bulkAllPinned}
selectionCommands={props.selectionCommands}
onStartRename={props.onStartRename}
/>
)}
</div>
Expand Down Expand Up @@ -1204,9 +1203,7 @@ function SessionItemActions(props: {
bulkCount: number;
bulkAllPinned: boolean;
selectionCommands?: SessionRailSelectionCommands;
onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void;
}) {
const trailingRef = useRef<HTMLSpanElement>(null);
const locale = useUiLocale();
const copy = getConversationCopy(locale).sessions;
const actionContext = [
Expand All @@ -1221,7 +1218,6 @@ function SessionItemActions(props: {
const [pendingAction, setPendingAction] = useState<SessionRowActionId | null>(null);
const mountedRef = useMountedRef();
const pendingActionRef = useRef<SessionRowActionId | null>(null);
const pendingMenuIntentRef = useRef<(() => void) | null>(null);
const actions = props.actions;

useEffect(
Expand Down Expand Up @@ -1251,21 +1247,14 @@ function SessionItemActions(props: {
<span
className="maka-session-row-action"
data-menu-open={menuOpen ? 'true' : undefined}
ref={trailingRef}
onKeyDown={(event) => event.stopPropagation()}
>
<MoreMenu
size="sm"
label={copy.actionsAriaLabel(actionContext)}
isDisabled={pendingAction !== null}
isMenuOpen={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) return;
const intent = pendingMenuIntentRef.current;
pendingMenuIntentRef.current = null;
if (intent) window.requestAnimationFrame(intent);
}}
onOpenChange={setMenuOpen}
items={
props.bulkCount > 1 && props.selectionCommands
? [
Expand Down Expand Up @@ -1294,26 +1283,6 @@ function SessionItemActions(props: {
actions.onToggleFlag(props.session.id, !props.session.isFlagged),
),
},
{
label: copy.rename,
icon: Pencil,
onClick: () => {
// Read now, while the trigger is still the thing the user
// is on: by the time the intent runs the menu has closed
// and focus is mid-handover.
const opener =
trailingRef.current?.querySelector<HTMLElement>('button') ?? null;
pendingMenuIntentRef.current = () =>
props.onStartRename(
{
kind: 'session',
id: props.session.id,
name: props.session.name,
},
opener,
);
},
},
// Archive is where the rail stops. Deleting is the one row
// action that cannot be undone, and the rail is where a
// mis-click is likeliest: rows are dense, the menu is one hover
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/stories/accessibility-dialogs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function RenameConversationStory() {
);
}

// Real path: conversation sidebar → row actions → rename.
// Real path: conversation sidebar task rename opened by double-click.
export const RenameConversation: Story = {
render: () => <RenameConversationStory />,
play: async ({ canvasElement }) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/ui/stories/session-list-panel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,9 @@ export const ActiveTaskActionsOpen: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await waitFor(() => expect(page.getByRole('menu')).toBeVisible());
expect(page.getByRole('menuitem', { name: '重命名' })).toBeVisible();
expect(page.getByRole('menuitem', { name: '置顶' })).toBeVisible();
expect(page.getByRole('menuitem', { name: '归档' })).toBeVisible();
expect(page.queryByRole('menuitem', { name: '重命名' })).not.toBeInTheDocument();
},
};

Expand Down