diff --git a/apps/desktop/src/app/chat/sidebar/session-row.test.tsx b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx new file mode 100644 index 0000000000000..1872062be3fde --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row.test.tsx @@ -0,0 +1,131 @@ +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { SidebarSessionRow, type SidebarSessionRowProps } from './session-row' + +function session(over: Partial = {}): SessionInfo { + return { + archived: false, + cwd: null, + ended_at: null, + _lineage_root_id: null, + input_tokens: 0, + is_active: false, + last_active: 1000, + message_count: 3, + model: null, + output_tokens: 0, + preview: null, + profile: 'default', + source: null, + started_at: 1000, + title: 'Test session', + id: 's1', + tool_call_count: 0, + ...over + } as SessionInfo +} + +function renderRow(over: Partial = {}) { + const handlers = { + onArchive: vi.fn(), + onDelete: vi.fn(), + onPin: vi.fn(), + onResume: vi.fn(), + onToggleSelect: vi.fn() + } + + const utils = render( + + ) + + const rowButton = utils.container.querySelector('[data-session-id] button') as HTMLButtonElement + + return { ...utils, handlers, rowButton } +} + +afterEach(cleanup) + +describe('SidebarSessionRow gestures', () => { + it('plain click resumes when no selection is active', () => { + const { handlers, rowButton } = renderRow() + + fireEvent.click(rowButton) + + expect(handlers.onResume).toHaveBeenCalledTimes(1) + expect(handlers.onToggleSelect).not.toHaveBeenCalled() + }) + + it('shift-click on a selectable row STARTS a selection — it must not pin', () => { + const { handlers, rowButton } = renderRow() + + fireEvent.click(rowButton, { shiftKey: true }) + + expect(handlers.onToggleSelect).toHaveBeenCalledWith('single') + expect(handlers.onPin).not.toHaveBeenCalled() + expect(handlers.onResume).not.toHaveBeenCalled() + }) + + it('alt-click also starts a selection', () => { + const { handlers, rowButton } = renderRow() + + fireEvent.click(rowButton, { altKey: true }) + + expect(handlers.onToggleSelect).toHaveBeenCalledWith('single') + expect(handlers.onResume).not.toHaveBeenCalled() + }) + + it('with a selection active, plain click toggles and shift-click range-extends', () => { + const { handlers, rowButton } = renderRow({ selectionActive: true }) + + fireEvent.click(rowButton) + expect(handlers.onToggleSelect).toHaveBeenLastCalledWith('single') + + fireEvent.click(rowButton, { shiftKey: true }) + expect(handlers.onToggleSelect).toHaveBeenLastCalledWith('range') + + expect(handlers.onResume).not.toHaveBeenCalled() + expect(handlers.onPin).not.toHaveBeenCalled() + }) + + it('keeps the legacy shift-click pin only on rows with no selection wiring', () => { + const { handlers, rowButton } = renderRow({ onToggleSelect: undefined, selectable: false }) + + fireEvent.click(rowButton, { shiftKey: true }) + + expect(handlers.onPin).toHaveBeenCalledTimes(1) + expect(handlers.onResume).not.toHaveBeenCalled() + }) + + it('never pins an archived row, even without selection wiring', () => { + const { handlers, rowButton } = renderRow({ archived: true, onToggleSelect: undefined, selectable: false }) + + fireEvent.click(rowButton, { shiftKey: true }) + + expect(handlers.onPin).not.toHaveBeenCalled() + expect(handlers.onResume).not.toHaveBeenCalled() + }) + + it('renders a checked checkbox while its section is selecting', () => { + const { container } = renderRow({ checked: true, selectionActive: true }) + + const checkbox = container.querySelector('[role="checkbox"]') + + expect(checkbox).toBeTruthy() + expect(checkbox?.getAttribute('aria-checked')).toBe('true') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index 0dc08d3f006d3..4233222220851 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -178,18 +178,23 @@ export function SidebarSessionRow({ event.preventDefault() event.stopPropagation() - // Pins can't resolve an archived row, so in the Archived section - // shift-click starts a selection instead of pinning. - if (archived) { - if (selectable && onToggleSelect) { - toggleSelect('single') - } + // Shift-click is the multi-select gesture everywhere selection + // exists: the first one starts the selection with this row, the + // next one range-extends (handled by the selectionActive branch + // above). Pin used to own this binding — it shadowed selection + // and made multi-select undiscoverable, so pin now lives in + // drag-to-Pinned, the row menus, and the bulk bar instead. + if (selectable && onToggleSelect) { + toggleSelect('single') return } - triggerHaptic('selection') - onPin() + // Rows outside any selectable section keep the legacy binding. + if (!archived) { + triggerHaptic('selection') + onPin() + } return } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d68d19cfeaeb6..8387095ea2aa1 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1111,7 +1111,7 @@ export const en: Translations = { archiveAllChecked: count => `${count} visible session${count === 1 ? '' : 's'} will be checked.`, archiveAllNone: 'No sessions to archive.', allPinned: 'Everything here is pinned. Unpin a chat to show it in recents.', - shiftClickHint: 'Drag a chat here, or shift-click to pin', + shiftClickHint: 'Drag a chat here to pin', noWorkspace: 'No workspace', newSessionIn: label => `New session in ${label}`, reorderWorkspace: label => `Reorder workspace ${label}`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index feeaf3787b5fa..f113a95d82ef6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1186,7 +1186,7 @@ export const ja = defineLocale({ archiveAllChecked: count => `表示中の ${count} 件のセッションが対象になります。`, archiveAllNone: 'アーカイブするセッションはありません。', allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。', - shiftClickHint: 'チャットをここにドラッグ、または Shift クリックでピン留め', + shiftClickHint: 'チャットをここにドラッグしてピン留め', noWorkspace: 'ワークスペースなし', newSessionIn: label => `${label} で新しいセッション`, reorderWorkspace: label => `ワークスペース ${label} を並べ替え`, diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 33238097e15ed..f91b52c1ce0d3 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1152,7 +1152,7 @@ export const zhHant = defineLocale({ archiveAllChecked: count => `將封存 ${count} 個顯示中的工作階段。`, archiveAllNone: '沒有可封存的工作階段。', allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。', - shiftClickHint: '將聊天拖曳到此處,或 Shift + 點擊以釘選', + shiftClickHint: '將聊天拖曳到此處以釘選', noWorkspace: '無工作區', newSessionIn: label => `在 ${label} 中新建工作階段`, reorderWorkspace: label => `重新排序工作區 ${label}`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 2073e7c727424..6db593861dba0 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1256,7 +1256,7 @@ export const zh: Translations = { archiveAllChecked: count => `将归档 ${count} 个可见会话。`, archiveAllNone: '没有可归档的会话。', allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。', - shiftClickHint: '将对话拖到此处,或 Shift+ 单击以置顶', + shiftClickHint: '将对话拖到此处以置顶', noWorkspace: '无工作区', newSessionIn: label => `在 ${label} 中新建会话`, reorderWorkspace: label => `重新排序工作区 ${label}`,