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 eef3fea626..de18c10854 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -422,7 +422,6 @@ export interface ConversationCopy { actionsAriaLabel: (name: string) => string; pin: string; unpin: string; - rename: string; archive: string; unarchive: string; delete: string; @@ -606,7 +605,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} 项`, }, }, 'zh-TW': { @@ -758,7 +757,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: { @@ -936,7 +935,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 9a22a1f797..6af9728d3d 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -72,7 +72,7 @@ import { getConversationCopy } from './conversation-copy.js'; import { getSessionHoverCardCopy } from './session-hover-card-copy.js'; import { deriveTitlebarProjectName } from './titlebar-session-identity.js'; -type SessionRowActionId = 'flag' | 'archive' | 'rename'; +type SessionRowActionId = 'flag' | 'archive'; type ProjectRowActionId = 'new' | 'relink' | 'rename' | 'archive' | 'restore'; type SessionHistoryGroupVariant = 'conversation' | 'project'; @@ -794,6 +794,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 @@ -847,7 +859,6 @@ const SessionNavRow = memo(function SessionNavRow(props: { bulkCount={props.bulkCount} bulkAllPinned={props.bulkAllPinned} selectionCommands={props.selectionCommands} - onStartRename={props.onStartRename} /> )} @@ -1216,9 +1227,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 = [ @@ -1233,7 +1242,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( @@ -1263,7 +1271,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 ? [ @@ -1306,26 +1307,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 1b8f542bea..459d74af59 100644 --- a/packages/ui/stories/session-list-panel.stories.tsx +++ b/packages/ui/stories/session-list-panel.stories.tsx @@ -443,8 +443,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.getByRole('menuitem', { name: '归档' })).toBeVisible(); + expect(page.queryByRole('menuitem', { name: '重命名' })).not.toBeInTheDocument(); expect(page.queryByRole('menuitem', { name: /删除/ })).toBeNull(); }, }; @@ -707,17 +708,21 @@ export const ProjectGroups: Story = { const taskActionButton = within(taskRow).getByRole('button', { name: /任务操作$/ }); taskActionButton.focus(); await userEvent.keyboard('{Enter}'); - const renameTask = page.getByRole('menuitem', { name: '重命名' }); - await expect(renameTask).toBeVisible(); + const pinTask = page.getByRole('menuitem', { name: '置顶' }); + await expect(pinTask).toBeVisible(); + await expect(page.queryByRole('menuitem', { name: '重命名' })).toBeNull(); const taskAction = taskRow.querySelector('.maka-session-row-action'); if (!taskAction) throw new Error('task action is missing'); await expect(taskAction).toHaveAttribute( 'data-menu-open', 'true', ); - await userEvent.hover(renameTask); + await userEvent.hover(pinTask); await expect(timestamp).toHaveStyle({ visibility: 'hidden' }); - await userEvent.click(renameTask); + await userEvent.keyboard('{Escape}'); + await expect(taskActionButton).toHaveFocus(); + taskControl.focus(); + await userEvent.keyboard('{F2}'); await expect(await page.findByRole('dialog', { name: '重命名任务' }, { timeout: 5_000, })).toBeVisible();