diff --git a/apps/desktop/e2e/sidebar-project-row.spec.ts b/apps/desktop/e2e/sidebar-project-row.spec.ts index 10951a555e..3d3261764b 100644 --- a/apps/desktop/e2e/sidebar-project-row.spec.ts +++ b/apps/desktop/e2e/sidebar-project-row.spec.ts @@ -112,39 +112,64 @@ 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 { + 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 actionTrigger = taskRow.getByRole('button', { name: /任务操作$/ }); + const timestamp = taskRow.locator('.maka-session-row-time'); + await expect(taskButton).toBeVisible(); + await expect(timestamp).toHaveCSS('visibility', 'visible'); + + await taskRow.hover(); + await actionTrigger.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'); + + await actionTrigger.focus(); + await page.keyboard.press('Enter'); + await expect(pin).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(actionTrigger).toBeFocused(); + + await taskButton.focus(); + await page.keyboard.press('F2'); + const dialog = page.getByRole('dialog', { name: '重命名任务' }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: '关闭', exact: true }).click(); + await expect(taskButton).toBeFocused(); + + await taskButton.dblclick(); + 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 ({ diff --git a/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx index 4f5ed3c679..5211bf6f1e 100644 --- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -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(); } @@ -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(); } diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 3042b31f3c..de3d1284f1 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -409,7 +409,6 @@ export interface ConversationCopy { actionsAriaLabel: (name: string) => string; pin: string; unpin: string; - rename: string; archive: string; unarchive: string; delete: string; @@ -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: { @@ -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; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 447c1018c5..61949ec014 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -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'; @@ -788,6 +788,18 @@ const SessionNavRow = memo(function SessionNavRow(props: { } props.onSelectSession(props.session.id); }} + onKeyDown={(event) => { + if (event.key !== 'F2' || !props.actions) return; + event.preventDefault(); + props.onStartRename( + { + kind: 'session', + id: props.session.id, + name: props.session.name, + }, + event.currentTarget, + ); + }} endContent={ // Slot 2. The timestamp is what the row shows at rest; the ⋯ menu // below is absolutely positioned over this box and sidebar.css swaps @@ -839,7 +851,6 @@ const SessionNavRow = memo(function SessionNavRow(props: { bulkCount={props.bulkCount} bulkAllPinned={props.bulkAllPinned} selectionCommands={props.selectionCommands} - onStartRename={props.onStartRename} /> )} @@ -1204,9 +1215,7 @@ function SessionItemActions(props: { bulkCount: number; bulkAllPinned: boolean; selectionCommands?: SessionRailSelectionCommands; - onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; }) { - const trailingRef = useRef(null); const locale = useUiLocale(); const copy = getConversationCopy(locale).sessions; const actionContext = [ @@ -1221,7 +1230,6 @@ function SessionItemActions(props: { const [pendingAction, setPendingAction] = useState(null); const mountedRef = useMountedRef(); const pendingActionRef = useRef(null); - const pendingMenuIntentRef = useRef<(() => void) | null>(null); const actions = props.actions; useEffect( @@ -1251,7 +1259,6 @@ function SessionItemActions(props: { event.stopPropagation()} > { - 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 ? [ @@ -1294,26 +1295,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('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 diff --git a/packages/ui/stories/accessibility-dialogs.stories.tsx b/packages/ui/stories/accessibility-dialogs.stories.tsx index d4b1ce615c..23c44a0623 100644 --- a/packages/ui/stories/accessibility-dialogs.stories.tsx +++ b/packages/ui/stories/accessibility-dialogs.stories.tsx @@ -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: () => , play: async ({ canvasElement }) => { diff --git a/packages/ui/stories/session-list-panel.stories.tsx b/packages/ui/stories/session-list-panel.stories.tsx index 9a5700d44d..2384dd8f2a 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -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(); }, };