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
131 changes: 131 additions & 0 deletions apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<SidebarSessionRowProps> = {}) {
const handlers = {
onArchive: vi.fn(),
onDelete: vi.fn(),
onPin: vi.fn(),
onResume: vi.fn(),
onToggleSelect: vi.fn()
}

const utils = render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={handlers.onArchive}
onDelete={handlers.onDelete}
onPin={handlers.onPin}
onResume={handlers.onResume}
onToggleSelect={handlers.onToggleSelect}
selectable
session={session()}
{...over}
/>
)

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')
})
})
21 changes: 13 additions & 8 deletions apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ export const ja = defineLocale({
archiveAllChecked: count => `表示中の ${count} 件のセッションが対象になります。`,
archiveAllNone: 'アーカイブするセッションはありません。',
allPinned: 'ここにあるものはすべてピン留めされています。チャットのピン留めを解除すると最近のものに表示されます。',
shiftClickHint: 'チャットをここにドラッグ、または Shift クリックでピン留め',
shiftClickHint: 'チャットをここにドラッグしてピン留め',
noWorkspace: 'ワークスペースなし',
newSessionIn: label => `${label} で新しいセッション`,
reorderWorkspace: label => `ワークスペース ${label} を並べ替え`,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/i18n/zh-hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1152,7 +1152,7 @@ export const zhHant = defineLocale({
archiveAllChecked: count => `將封存 ${count} 個顯示中的工作階段。`,
archiveAllNone: '沒有可封存的工作階段。',
allPinned: '這裡的全部已釘選。取消釘選某個聊天即可在最近中顯示。',
shiftClickHint: '將聊天拖曳到此處,或 Shift + 點擊以釘選',
shiftClickHint: '將聊天拖曳到此處以釘選',
noWorkspace: '無工作區',
newSessionIn: label => `在 ${label} 中新建工作階段`,
reorderWorkspace: label => `重新排序工作區 ${label}`,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1256,7 +1256,7 @@ export const zh: Translations = {
archiveAllChecked: count => `将归档 ${count} 个可见会话。`,
archiveAllNone: '没有可归档的会话。',
allPinned: '这里的全部已置顶。取消置顶某个对话即可在最近中显示。',
shiftClickHint: '将对话拖到此处,或 Shift+ 单击以置顶',
shiftClickHint: '将对话拖到此处以置顶',
noWorkspace: '无工作区',
newSessionIn: label => `在 ${label} 中新建会话`,
reorderWorkspace: label => `重新排序工作区 ${label}`,
Expand Down
Loading