diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9f1b84d873e..f33765602cd 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1296,6 +1296,14 @@ html.native-shell .app-layout { background: color-mix(in srgb, var(--sidebar-accent) 40%, transparent); } +/* Why: one multi-selected look for every list on the card surface (workspaces and + agents); after the hover rules so it wins, and never on the active/open row. */ +[data-worktree-card-surface][data-worktree-card-selected]:not([data-worktree-card-active]) { + border-color: color-mix(in oklab, var(--worktree-sidebar-ring) 35%, transparent); + background-color: color-mix(in oklab, var(--worktree-sidebar-accent) 70%, transparent); + box-shadow: 0 0 0 1px color-mix(in oklab, var(--worktree-sidebar-ring) 30%, transparent); +} + /* Why: the brighter border carries the selected state; a translucent wash keeps card text legible instead of lifting the surface toward the foreground color. */ [data-worktree-card-surface][data-worktree-card-active='primary'] { diff --git a/src/renderer/src/assets/worktree-card-active-style.test.ts b/src/renderer/src/assets/worktree-card-active-style.test.ts index 5057da135b9..9fc69fe25b0 100644 --- a/src/renderer/src/assets/worktree-card-active-style.test.ts +++ b/src/renderer/src/assets/worktree-card-active-style.test.ts @@ -43,6 +43,20 @@ describe('worktree card active styling', () => { expect(darkSecondary).toContain('var(--sidebar-ring) 18%') }) + it('styles multi-selected cards through one rule that skips the active card', () => { + const selected = getCssRuleBody( + '[data-worktree-card-surface][data-worktree-card-selected]:not([data-worktree-card-active])' + ) + + expect(selected).toContain('var(--worktree-sidebar-ring) 35%') + expect(selected).toContain('var(--worktree-sidebar-accent) 70%') + expect(selected).toContain('var(--worktree-sidebar-ring) 30%') + // Why after hover: equal-specificity dark hover must not repaint a selected card. + expect(mainCss.indexOf('[data-worktree-card-selected]')).toBeGreaterThan( + mainCss.indexOf('.dark .worktree-sidebar-card-hover:hover') + ) + }) + it('dims sleeping cards through theme tokens so the cue survives any surface', () => { const sleeping = getCssRuleBody('[data-worktree-sleeping-dim]') diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 32b0af8662c..90716732e62 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -243,6 +243,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { const { markThreadRead, markThreadUnread, + markThreadsRead, + markThreadsUnread, selectThread, jumpToWorkspace, markAllThreadsRead, @@ -337,6 +339,8 @@ export default function ActivityPrototypePage(): React.JSX.Element { onJumpToWorkspace={jumpToWorkspace} onMarkThreadRead={markThreadRead} onMarkThreadUnread={markThreadUnread} + onMarkThreadsRead={markThreadsRead} + onMarkThreadsUnread={markThreadsUnread} canJumpToWorkspace={canJumpToWorkspace} isThreadListResizing={isThreadListResizing} onResizeStart={onResizeStart} diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 0e1cdd6a20e..fd20d2ca18d 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -282,4 +282,20 @@ describe('activity thread host routing', () => { actions.markAllThreadsRead() expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey]) }) + + it('marks a batch of threads read or unread in one store update', () => { + const other = { ...makeRemoteThread(), paneKey: 'tab-2:other' } + const unacknowledgeAgents = vi.fn() + const actions = createActivityThreadActions({ + getMarkAllReadThreads: () => [], + acknowledgeAgents, + unacknowledgeAgents, + setSelectedPaneKey + }) + + actions.markThreadsRead([thread, other]) + actions.markThreadsUnread([thread, other]) + expect(acknowledgeAgents).toHaveBeenCalledExactlyOnceWith([thread.paneKey, other.paneKey]) + expect(unacknowledgeAgents).toHaveBeenCalledExactlyOnceWith([thread.paneKey, other.paneKey]) + }) }) diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 84d9d55ea21..1193467fea0 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -64,18 +64,24 @@ export function createActivityThreadActions({ }): { markThreadRead: (thread: AgentPaneThread) => void markThreadUnread: (thread: AgentPaneThread) => void + markThreadsRead: (threads: readonly AgentPaneThread[]) => void + markThreadsUnread: (threads: readonly AgentPaneThread[]) => void selectThread: (thread: AgentPaneThread) => void jumpToWorkspace: (thread: AgentPaneThread) => void markAllThreadsRead: () => void } { - const markThreadRead = (thread: AgentPaneThread): void => { - acknowledgeAgents([thread.paneKey]) + const markThreadsRead = (threads: readonly AgentPaneThread[]): void => { + acknowledgeAgents(threads.map((thread) => thread.paneKey)) } - const markThreadUnread = (thread: AgentPaneThread): void => { - unacknowledgeAgents([thread.paneKey]) + const markThreadsUnread = (threads: readonly AgentPaneThread[]): void => { + unacknowledgeAgents(threads.map((thread) => thread.paneKey)) } + const markThreadRead = (thread: AgentPaneThread): void => markThreadsRead([thread]) + + const markThreadUnread = (thread: AgentPaneThread): void => markThreadsUnread([thread]) + const activateThreadTarget = (thread: AgentPaneThread): void => { const isFloatingTerminal = thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID const executionHostId = getActivityThreadExecutionHostId( @@ -151,6 +157,8 @@ export function createActivityThreadActions({ return { markThreadRead, markThreadUnread, + markThreadsRead, + markThreadsUnread, selectThread, jumpToWorkspace, markAllThreadsRead diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx index 46758ccf057..db2dcd6731a 100644 --- a/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-context-menu.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { ActivityThreadContextMenu, getActivityThreadCopyTargets @@ -10,11 +11,12 @@ import type * as ActivityClearCompleted from './activity-clear-completed' import type { AgentPaneThread } from './activity-thread-types' import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' -const mocks = vi.hoisted(() => ({ clearActivityThread: vi.fn() })) +const mocks = vi.hoisted(() => ({ clearActivityThread: vi.fn(), clearCompletedActivity: vi.fn() })) vi.mock('./activity-clear-completed', async (importOriginal) => ({ ...(await importOriginal()), - clearActivityThread: mocks.clearActivityThread + clearActivityThread: mocks.clearActivityThread, + clearCompletedActivity: mocks.clearCompletedActivity })) function makeThread(overrides: Partial = {}): AgentPaneThread { @@ -36,28 +38,73 @@ function makeThread(overrides: Partial = {}): AgentPaneThread { } } +function makeDoneThread(paneKey: string, overrides: Partial = {}) { + return makeThread({ + paneKey, + currentAgentState: null, + paneEntry: { + state: 'done', + prompt: '', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'claude', + paneKey, + stateHistory: [] + }, + ...overrides + }) +} + const handlers = { onOpen: vi.fn(), onJump: vi.fn(), onMarkRead: vi.fn(), - onMarkUnread: vi.fn() + onMarkUnread: vi.fn(), + onMarkManyRead: vi.fn(), + onMarkManyUnread: vi.fn() } -function openMenu(thread: AgentPaneThread, canJump = true, disableMarkUnread = false): void { +function renderMenu( + thread: AgentPaneThread, + { + canJump = true, + canMarkUnread = () => true, + getTargets, + testId = 'row' + }: { + canJump?: boolean + canMarkUnread?: (thread: AgentPaneThread) => boolean + getTargets?: (thread: AgentPaneThread) => readonly AgentPaneThread[] + testId?: string + } = {} +): void { render( {(menuOpen) => ( -
+
row
)} ) +} + +function openMenu(thread: AgentPaneThread, canJump = true): void { + renderMenu(thread, { canJump }) + fireEvent.contextMenu(screen.getByTestId('row')) +} + +function openBulkMenu( + targets: readonly AgentPaneThread[], + canMarkUnread: (thread: AgentPaneThread) => boolean = () => true +): void { + renderMenu(targets[0], { canMarkUnread, getTargets: () => targets }) fireEvent.contextMenu(screen.getByTestId('row')) } @@ -98,7 +145,8 @@ describe('ActivityThreadContextMenu', () => { }) it('disables Mark Unread for the open thread', () => { - openMenu(makeThread({ unread: false }), true, true) + renderMenu(makeThread({ unread: false }), { canMarkUnread: () => false }) + fireEvent.contextMenu(screen.getByTestId('row')) expect(menuItem('Mark Unread').hasAttribute('data-disabled')).toBe(true) }) @@ -148,6 +196,76 @@ describe('ActivityThreadContextMenu', () => { expect(screen.queryByRole('menuitem', { name: 'Clear from List' })).toBeNull() }) + it('acts on every target with counted labels and hides single-agent actions', () => { + const unreadA = makeThread({ paneKey: 'a', unread: true }) + const readB = makeDoneThread('b', { unread: false }) + const readC = makeDoneThread('c', { unread: false }) + openBulkMenu([unreadA, readB, readC]) + + expect(screen.queryByRole('menuitem', { name: 'Open' })).toBeNull() + expect(screen.queryByRole('menuitem', { name: 'Go to Workspace' })).toBeNull() + expect(screen.queryByRole('menuitem', { name: 'Copy Title' })).toBeNull() + expect(screen.getByText('Agent')).toBeTruthy() + + expect(screen.queryByRole('menuitem', { name: /Unread/ })).toBeNull() + fireEvent.click(menuItem('Mark 1 Agent Read')) + expect(handlers.onMarkManyRead).toHaveBeenCalledWith([unreadA]) + }) + + it('marks unread only the read targets that may be marked unread', () => { + const openRow = makeThread({ paneKey: 'open', unread: false }) + const readB = makeThread({ paneKey: 'b', unread: false }) + openBulkMenu([openRow, readB], (thread) => thread.paneKey !== 'open') + + fireEvent.click(menuItem('Mark 1 Agent Unread')) + expect(handlers.onMarkManyUnread).toHaveBeenCalledWith([readB]) + }) + + it('clears only the clearable targets through the undoable bulk clear', () => { + const doneA = makeDoneThread('a') + const working = makeThread({ paneKey: 'w' }) + const doneB = makeDoneThread('b') + openBulkMenu([doneA, working, doneB]) + + fireEvent.click(menuItem('Clear 2 Agents from List')) + expect(mocks.clearCompletedActivity).toHaveBeenCalledWith([doneA, doneB]) + expect(mocks.clearActivityThread).not.toHaveBeenCalled() + }) + + it('disables bulk actions with nothing to act on and drops their count', () => { + openBulkMenu( + [makeThread({ paneKey: 'a', unread: false }), makeThread({ paneKey: 'b', unread: false })], + () => false + ) + + expect(menuItem('Mark Unread').hasAttribute('data-disabled')).toBe(true) + expect(menuItem('Clear from List').hasAttribute('data-disabled')).toBe(true) + }) + + it('shows the single-agent menu when the targets are just the clicked row', () => { + const thread = makeThread() + renderMenu(thread, { getTargets: () => [thread] }) + fireEvent.contextMenu(screen.getByTestId('row')) + + expect(menuItem('Open')).toBeTruthy() + expect(menuItem('Mark Read')).toBeTruthy() + }) + + it('announces itself to other menus on open and closes when another menu opens', () => { + const onCloseAll = vi.fn() + window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, onCloseAll) + openMenu(makeThread()) + expect(onCloseAll).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(true) + + act(() => { + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + }) + expect(screen.getByTestId('row').hasAttribute('data-menu-open')).toBe(false) + expect(screen.queryByRole('menu')).toBeNull() + window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, onCloseAll) + }) + it('copies the path, then the title, of a real workspace like the workspace menu', () => { expect(getActivityThreadCopyTargets(makeThread(), true)).toEqual([ { key: 'path', label: 'Copy Path', value: '/repo/wt-1' }, diff --git a/src/renderer/src/components/activity/activity-thread-context-menu.tsx b/src/renderer/src/components/activity/activity-thread-context-menu.tsx index 2a63c5d627c..b1b0baa59d4 100644 --- a/src/renderer/src/components/activity/activity-thread-context-menu.tsx +++ b/src/renderer/src/components/activity/activity-thread-context-menu.tsx @@ -9,7 +9,12 @@ import { ContextMenuTrigger } from '@/components/ui/context-menu' import { translate } from '@/i18n/i18n' -import { clearActivityThread, isClearableActivityThread } from './activity-clear-completed' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' +import { + clearActivityThread, + clearCompletedActivity, + isClearableActivityThread +} from './activity-clear-completed' import { activityThreadRowCopy } from './activity-thread-presentation' import type { AgentPaneThread } from './activity-thread-types' @@ -41,81 +46,212 @@ export function getActivityThreadCopyTargets( export function ActivityThreadContextMenu({ thread, canJump, - disableMarkUnread, + canMarkUnread, + getTargets, onOpen, onJump, onMarkRead, onMarkUnread, + onMarkManyRead, + onMarkManyUnread, children }: { thread: AgentPaneThread canJump: boolean - disableMarkUnread: boolean + canMarkUnread: (thread: AgentPaneThread) => boolean + /** Called as the menu opens; returns every agent the menu should act on. */ + getTargets?: (thread: AgentPaneThread) => readonly AgentPaneThread[] onOpen: (thread: AgentPaneThread) => void onJump: (thread: AgentPaneThread) => void onMarkRead: (thread: AgentPaneThread) => void onMarkUnread: (thread: AgentPaneThread) => void + onMarkManyRead: (threads: readonly AgentPaneThread[]) => void + onMarkManyUnread: (threads: readonly AgentPaneThread[]) => void /** Receives whether the menu is open, so the row can keep its preview out of the way. */ children: (menuOpen: boolean) => React.ReactElement }): React.JSX.Element { const [menuOpen, setMenuOpen] = React.useState(false) + // Why a snapshot: the pointerdown on a portaled item clears the list selection before onSelect. + const [targets, setTargets] = React.useState([thread]) + + React.useEffect(() => { + if (!menuOpen) { + return + } + const closeMenu = (): void => setMenuOpen(false) + window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) + }, [menuOpen]) + + const handleOpenChange = (open: boolean): void => { + if (open) { + window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) + setTargets(getTargets?.(thread) ?? [thread]) + } + setMenuOpen(open) + } + return ( - + {children(menuOpen)} {/* Why no focus restore: refocusing the row would reopen its hover preview and pin it open. */} event.preventDefault()}> {translate('auto.components.activity.ActivityThreadContextMenu.agentSection', 'Agent')} - onOpen(thread)}> - - {translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')} + {targets.length > 1 ? ( + + ) : ( + + )} + + + ) +} + +function ActivityThreadSingleMenuItems({ + thread, + canJump, + canMarkUnread, + onOpen, + onJump, + onMarkRead, + onMarkUnread +}: { + thread: AgentPaneThread + canJump: boolean + canMarkUnread: boolean + onOpen: (thread: AgentPaneThread) => void + onJump: (thread: AgentPaneThread) => void + onMarkRead: (thread: AgentPaneThread) => void + onMarkUnread: (thread: AgentPaneThread) => void +}): React.JSX.Element { + return ( + <> + onOpen(thread)}> + + {translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')} + + {canJump ? ( + onJump(thread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.goToWorkspace', + 'Go to Workspace' + )} - {canJump ? ( - onJump(thread)}> - + ) : null} + + {getActivityThreadCopyTargets(thread, canJump).map((target) => ( + void window.api.ui.writeClipboardText(target.value)} + > + + {target.label} + + ))} + + (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))} + > + {thread.unread ? : } + {thread.unread + ? translate('auto.components.activity.ActivityThreadContextMenu.markRead', 'Mark Read') + : translate( + 'auto.components.activity.ActivityThreadContextMenu.markUnread', + 'Mark Unread' + )} + + {isClearableActivityThread(thread) ? ( + <> + + clearActivityThread(thread)}> + {translate( - 'auto.components.activity.ActivityThreadContextMenu.goToWorkspace', - 'Go to Workspace' + 'auto.components.activity.ActivityThreadContextMenu.clear', + 'Clear from List' )} - ) : null} - - {getActivityThreadCopyTargets(thread, canJump).map((target) => ( - void window.api.ui.writeClipboardText(target.value)} - > - - {target.label} - - ))} - - (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))} - > - {thread.unread ? : } - {thread.unread - ? translate('auto.components.activity.ActivityThreadContextMenu.markRead', 'Mark Read') + + ) : null} + + ) +} + +// Single-agent actions (Open, Go to Workspace, Copy) are hidden, as in the workspace menu. +function ActivityThreadBulkMenuItems({ + targets, + canMarkUnread, + onMarkManyRead, + onMarkManyUnread +}: { + targets: readonly AgentPaneThread[] + canMarkUnread: (thread: AgentPaneThread) => boolean + onMarkManyRead: (threads: readonly AgentPaneThread[]) => void + onMarkManyUnread: (threads: readonly AgentPaneThread[]) => void +}): React.JSX.Element { + const unread = targets.filter((target) => target.unread) + const read = targets.filter((target) => !target.unread && canMarkUnread(target)) + const clearable = targets.filter(isClearableActivityThread) + return ( + <> + {/* Why read wins when mixed: matches the single toggle, which offers Mark Read on any unread agent. */} + {unread.length > 0 ? ( + onMarkManyRead(unread)}> + + {translate( + 'auto.components.activity.ActivityThreadContextMenu.markManyRead', + 'Mark {{count}} Agents Read', + { count: unread.length } + )} + + ) : ( + onMarkManyUnread(read)}> + + {read.length > 0 + ? translate( + 'auto.components.activity.ActivityThreadContextMenu.markManyUnread', + 'Mark {{count}} Agents Unread', + { count: read.length } + ) : translate( 'auto.components.activity.ActivityThreadContextMenu.markUnread', 'Mark Unread' )} - {isClearableActivityThread(thread) ? ( - <> - - clearActivityThread(thread)}> - - {translate( - 'auto.components.activity.ActivityThreadContextMenu.clear', - 'Clear from List' - )} - - - ) : null} - - + )} + + clearCompletedActivity(clearable)} + > + + {clearable.length > 0 + ? translate( + 'auto.components.activity.ActivityThreadContextMenu.clearMany', + 'Clear {{count}} Agents from List', + { count: clearable.length } + ) + : translate( + 'auto.components.activity.ActivityThreadContextMenu.clear', + 'Clear from List' + )} + + ) } diff --git a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx index 6db25785dc6..c983fd721d9 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane-collapsible.test.tsx @@ -167,6 +167,8 @@ describe('ActivityThreadListPane collapsible sections', () => { onJumpToWorkspace={vi.fn()} onMarkThreadRead={vi.fn()} onMarkThreadUnread={vi.fn()} + onMarkThreadsRead={vi.fn()} + onMarkThreadsUnread={vi.fn()} canJumpToWorkspace={() => true} showFilterControls={false} showOptionsMenu={false} @@ -222,6 +224,8 @@ describe('ActivityThreadListPane collapsible sections', () => { onJumpToWorkspace={vi.fn()} onMarkThreadRead={vi.fn()} onMarkThreadUnread={vi.fn()} + onMarkThreadsRead={vi.fn()} + onMarkThreadsUnread={vi.fn()} canJumpToWorkspace={() => true} showFilterControls={false} showOptionsMenu={false} @@ -269,6 +273,8 @@ describe('ActivityThreadListPane collapsible sections', () => { onJumpToWorkspace={vi.fn()} onMarkThreadRead={vi.fn()} onMarkThreadUnread={onMarkThreadUnread} + onMarkThreadsRead={vi.fn()} + onMarkThreadsUnread={vi.fn()} canJumpToWorkspace={() => true} allowMarkUnreadWhenSelected showFilterControls={false} diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.multi-select.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.multi-select.test.tsx new file mode 100644 index 00000000000..c915b6a4fdc --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-list-pane.multi-select.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { ActivityThreadListPane } from './activity-thread-list-pane' +import type { ActivityThreadGroup, AgentPaneThread } from './activity-thread-types' +import { makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +function makeThread(name: string): AgentPaneThread { + return { + paneKey: `tab-${name}:leaf`, + tab: makeTab(), + worktree: makeWorktree(), + repo: null, + currentAgentState: null, + currentAgentEntry: null, + latestEvent: null, + latestTimestamp: 1000, + agentType: 'claude', + unread: false, + paneTitle: `Agent ${name}`, + responsePreview: '', + events: [] + } +} + +const a = makeThread('A') +const b = makeThread('B') +const c = makeThread('C') +const d = makeThread('D') +const groups: ActivityThreadGroup[] = [ + { key: 'working', label: 'Working', state: 'working', threads: [a, b] }, + { key: 'done', label: 'Done', state: 'done', threads: [c, d] } +] + +let container: HTMLDivElement +let root: Root +let onSelectThread: ReturnType void>> + +function renderPane(selectedPaneKey: string | null = null): void { + act(() => { + root.render( + + true} + showFilterControls={false} + showOptionsMenu={false} + /> + + ) + }) +} + +function row(thread: AgentPaneThread): HTMLElement { + const match = Array.from( + container.querySelectorAll('[data-worktree-card-surface]') + ).find((element) => element.getAttribute('aria-label') === thread.paneTitle) + if (!match) { + throw new Error(`row ${thread.paneTitle} is not rendered`) + } + return match +} + +function click( + thread: AgentPaneThread, + modifiers: Partial> = {} +): void { + act(() => { + row(thread).dispatchEvent(new MouseEvent('click', { bubbles: true, ...modifiers })) + }) +} + +function selectedTitles(): string[] { + return Array.from(container.querySelectorAll('[data-worktree-card-selected]')).map( + (element) => element.getAttribute('aria-label') ?? '' + ) +} + +function setPlatform(isMac: boolean): void { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + isMac ? 'Mozilla/5.0 (Macintosh)' : 'Mozilla/5.0 (Windows NT 10.0)' + ) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onSelectThread = vi.fn<(thread: AgentPaneThread) => void>() +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('ActivityThreadListPane multi-select', () => { + it('toggles rows with Ctrl on Windows/Linux without opening them', () => { + setPlatform(false) + renderPane() + click(a, { ctrlKey: true }) + click(c, { ctrlKey: true }) + + expect(selectedTitles()).toEqual(['Agent A', 'Agent C']) + expect(onSelectThread).not.toHaveBeenCalled() + click(a, { ctrlKey: true }) + expect(selectedTitles()).toEqual(['Agent C']) + }) + + it('toggles with Cmd on macOS and ignores Ctrl-click, the native right-click gesture', () => { + setPlatform(true) + renderPane() + click(a, { metaKey: true }) + click(b, { ctrlKey: true }) + + expect(selectedTitles()).toEqual(['Agent A']) + expect(onSelectThread).not.toHaveBeenCalled() + }) + + it('opens the agent and replaces the selection on a plain click', () => { + setPlatform(false) + renderPane() + click(a, { ctrlKey: true }) + click(c, { ctrlKey: true }) + click(b) + + expect(onSelectThread).toHaveBeenCalledWith(b) + expect(selectedTitles()).toEqual(['Agent B']) + }) + + it('selects a Shift range across group headers', () => { + setPlatform(false) + renderPane() + click(b) + click(d, { shiftKey: true }) + + expect(selectedTitles()).toEqual(['Agent B', 'Agent C', 'Agent D']) + expect(onSelectThread).toHaveBeenCalledTimes(1) + }) + + it('never marks the open row as selected, so it keeps its active look', () => { + setPlatform(false) + renderPane(b.paneKey) + click(a, { ctrlKey: true }) + click(b, { ctrlKey: true }) + + expect(selectedTitles()).toEqual(['Agent A']) + expect(row(b).getAttribute('data-worktree-card-active')).toBe('primary') + }) + + it('drops rows hidden by a collapsed group from the selection', () => { + setPlatform(false) + renderPane() + click(a, { ctrlKey: true }) + click(c, { ctrlKey: true }) + + const doneHeader = Array.from(container.querySelectorAll('[role="button"]')).find( + (element) => element.textContent?.includes('Done') + ) + act(() => doneHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + act(() => doneHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + + expect(selectedTitles()).toEqual(['Agent A']) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.tsx index a5503e58562..ab3d73bbfb9 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.tsx @@ -24,6 +24,7 @@ import { getActivityVirtualItemKey } from './activity-thread-virtual-items' import { ActivityThreadCollapseContext } from './activity-thread-collapse-context' +import { useActivityThreadSelection } from './use-activity-thread-selection' import type { ActivityGroupBy, ActivityThreadGroup, @@ -66,6 +67,8 @@ export function ActivityThreadListPane({ onJumpToWorkspace, onMarkThreadRead, onMarkThreadUnread, + onMarkThreadsRead, + onMarkThreadsUnread, canJumpToWorkspace, allowMarkUnreadWhenSelected = false, showJumpAction = true, @@ -102,6 +105,8 @@ export function ActivityThreadListPane({ onJumpToWorkspace: (thread: AgentPaneThread) => void onMarkThreadRead: (thread: AgentPaneThread) => void onMarkThreadUnread: (thread: AgentPaneThread) => void + onMarkThreadsRead: (threads: readonly AgentPaneThread[]) => void + onMarkThreadsUnread: (threads: readonly AgentPaneThread[]) => void canJumpToWorkspace: (thread: AgentPaneThread) => boolean allowMarkUnreadWhenSelected?: boolean showJumpAction?: boolean @@ -172,6 +177,15 @@ export function ActivityThreadListPane({ () => getActivityHeaderItemIndexes(virtualItems), [virtualItems] ) + const { selectedKeys, handleSelectThread, getContextMenuTargets } = useActivityThreadSelection({ + virtualItems, + scrollContainerRef, + onSelectThread + }) + const canMarkThreadUnread = useCallback( + (thread: AgentPaneThread) => allowMarkUnreadWhenSelected || thread.paneKey !== selectedPaneKey, + [allowMarkUnreadWhenSelected, selectedPaneKey] + ) const selectedItemIndex = useMemo( () => findActivityThreadItemIndex(virtualItems, selectedPaneKey), [virtualItems, selectedPaneKey] @@ -360,13 +374,18 @@ export function ActivityThreadListPane({ } onToggleGroup={handleToggleGroup} selectedPaneKey={selectedPaneKey} - onSelectThread={onSelectThread} + multiSelectedKeys={selectedKeys} + onSelectThread={handleSelectThread} + onOpenThread={onSelectThread} + getContextMenuTargets={getContextMenuTargets} onJumpToWorkspace={onJumpToWorkspace} onMarkThreadRead={onMarkThreadRead} onMarkThreadUnread={onMarkThreadUnread} + onMarkThreadsRead={onMarkThreadsRead} + onMarkThreadsUnread={onMarkThreadsUnread} canJumpToWorkspace={canJumpToWorkspace} + canMarkThreadUnread={canMarkThreadUnread} compactMode={compactMode} - allowMarkUnreadWhenSelected={allowMarkUnreadWhenSelected} showJumpAction={showJumpAction} />
diff --git a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx index 5b531570444..548da3262eb 100644 --- a/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-list-pane.virtualization.test.tsx @@ -74,6 +74,8 @@ function renderPane( onJumpToWorkspace={vi.fn()} onMarkThreadRead={vi.fn()} onMarkThreadUnread={vi.fn()} + onMarkThreadsRead={vi.fn()} + onMarkThreadsUnread={vi.fn()} canJumpToWorkspace={() => true} showFilterControls={false} showOptionsMenu={false} diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx index a418624bfff..2040c3465d6 100644 --- a/src/renderer/src/components/activity/activity-thread-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-row.tsx @@ -12,6 +12,7 @@ import { EventTime, ThreadAgentStateIndicator } from './activity-thread-controls import { ActivityThreadHoverCard } from './activity-thread-hover-card' import { activityThreadRowCopy } from './activity-thread-presentation' import type { AgentPaneThread } from './activity-thread-types' +import type { ActivityThreadClickEvent } from './use-activity-thread-selection' function ActivityThreadRowAction({ label, @@ -57,6 +58,7 @@ function ActivityThreadRowAction({ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ thread, selected, + multiSelected = false, onSelect, onJump, onMarkRead, @@ -70,7 +72,9 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ }: { thread: AgentPaneThread selected: boolean - onSelect: (thread: AgentPaneThread) => void + /** In the multi-selection and not the open row. */ + multiSelected?: boolean + onSelect: (thread: AgentPaneThread, event: ActivityThreadClickEvent) => void onJump: (thread: AgentPaneThread) => void onMarkRead: (thread: AgentPaneThread) => void onMarkUnread: (thread: AgentPaneThread) => void @@ -97,14 +101,12 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ data-current={selected ? 'true' : undefined} data-worktree-card-surface="true" data-worktree-card-active={selected ? 'primary' : undefined} - onClick={() => onSelect(thread)} + data-worktree-card-selected={multiSelected || undefined} + onClick={(event) => onSelect(thread, event)} role="listitem" aria-label={taskTitle} aria-current={selected ? 'true' : undefined} - className={cn( - 'group relative flex w-full cursor-pointer flex-col gap-1 rounded-lg border border-transparent px-1.5 py-1.5 text-left transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none worktree-sidebar-card-hover focus-visible:ring-1 focus-visible:ring-ring', - selected && 'border-transparent' - )} + className="group relative flex w-full cursor-pointer flex-col gap-1 rounded-lg border border-transparent px-1.5 py-1.5 text-left transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none worktree-sidebar-card-hover focus-visible:ring-1 focus-visible:ring-ring" >
@@ -118,7 +120,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ aria-keyshortcuts="Enter Space" onClick={(event) => { event.stopPropagation() - onSelect(thread) + onSelect(thread, event) }} className={cn( 'block min-w-0 w-full cursor-pointer text-left text-[13px] leading-5 outline-none focus-visible:ring-1 focus-visible:ring-ring', diff --git a/src/renderer/src/components/activity/activity-thread-virtual-row.tsx b/src/renderer/src/components/activity/activity-thread-virtual-row.tsx index 72eb342772c..2811fc4c50d 100644 --- a/src/renderer/src/components/activity/activity-thread-virtual-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-virtual-row.tsx @@ -4,35 +4,46 @@ import { clearActivityThread, isClearableActivityThread } from './activity-clear import { ActivityStatusGroupHeader } from './activity-thread-controls' import { ActivityThreadContextMenu } from './activity-thread-context-menu' import { ActivityThreadRow } from './activity-thread-row' +import type { AgentPaneThread } from './activity-thread-types' import type { ActivityVirtualItemDescriptor } from './activity-thread-virtual-items' +type ContextMenuProps = Parameters[0] + export function ActivityThreadVirtualRow({ item, collapsed, onToggleGroup, selectedPaneKey, + multiSelectedKeys, onSelectThread, + onOpenThread, + getContextMenuTargets, onJumpToWorkspace, onMarkThreadRead, onMarkThreadUnread, + onMarkThreadsRead, + onMarkThreadsUnread, canJumpToWorkspace, + canMarkThreadUnread, compactMode, - allowMarkUnreadWhenSelected, showJumpAction }: { item: ActivityVirtualItemDescriptor collapsed: boolean onToggleGroup: (groupKey: string) => void selectedPaneKey: string | null + multiSelectedKeys: ReadonlySet onSelectThread: Parameters[0]['onSelect'] + onOpenThread: ContextMenuProps['onOpen'] + getContextMenuTargets: ContextMenuProps['getTargets'] onJumpToWorkspace: Parameters[0]['onJump'] onMarkThreadRead: Parameters[0]['onMarkRead'] onMarkThreadUnread: Parameters[0]['onMarkUnread'] - canJumpToWorkspace: ( - thread: Extract['thread'] - ) => boolean + onMarkThreadsRead: ContextMenuProps['onMarkManyRead'] + onMarkThreadsUnread: ContextMenuProps['onMarkManyUnread'] + canJumpToWorkspace: (thread: AgentPaneThread) => boolean + canMarkThreadUnread: (thread: AgentPaneThread) => boolean compactMode: boolean - allowMarkUnreadWhenSelected: boolean showJumpAction: boolean }): React.JSX.Element { if (item.type === 'header') { @@ -55,16 +66,19 @@ export function ActivityThreadVirtualRow({ ) } const canJump = canJumpToWorkspace(item.thread) - const disableMarkUnread = item.thread.paneKey === selectedPaneKey && !allowMarkUnreadWhenSelected + const isOpen = item.thread.paneKey === selectedPaneKey return ( {(menuOpen) => ( // Why the menu wraps this wrapper, not the row: the row is already the hover-card @@ -72,7 +86,8 @@ export function ActivityThreadVirtualRow({
diff --git a/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts b/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts index 0004db75737..4dfa17f7e0d 100644 --- a/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts +++ b/src/renderer/src/components/activity/use-activity-thread-action-bindings.ts @@ -6,6 +6,8 @@ import type { AgentPaneThread } from './activity-thread-types' type ActivityThreadActionBindings = { markThreadRead: (thread: AgentPaneThread) => void markThreadUnread: (thread: AgentPaneThread) => void + markThreadsRead: (threads: readonly AgentPaneThread[]) => void + markThreadsUnread: (threads: readonly AgentPaneThread[]) => void selectThread: (thread: AgentPaneThread) => void jumpToWorkspace: (thread: AgentPaneThread) => void markAllThreadsRead: () => void diff --git a/src/renderer/src/components/activity/use-activity-thread-selection.ts b/src/renderer/src/components/activity/use-activity-thread-selection.ts new file mode 100644 index 00000000000..7c663f6b112 --- /dev/null +++ b/src/renderer/src/components/activity/use-activity-thread-selection.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import type React from 'react' +import { useListMultiSelection } from '@/hooks/use-list-multi-selection' +import type { ActivityVirtualItemDescriptor } from './activity-thread-virtual-items' +import type { AgentPaneThread } from './activity-thread-types' + +const getThreadPaneKey = (thread: AgentPaneThread): string => thread.paneKey + +export type ActivityThreadClickEvent = Pick + +/** Click/Cmd/Shift multi-select over the agent rows the list renders, in render order. */ +export function useActivityThreadSelection({ + virtualItems, + scrollContainerRef, + onSelectThread +}: { + virtualItems: readonly ActivityVirtualItemDescriptor[] + scrollContainerRef: React.RefObject + onSelectThread: (thread: AgentPaneThread) => void +}): { + selectedKeys: ReadonlySet + handleSelectThread: (thread: AgentPaneThread, event: ActivityThreadClickEvent) => void + getContextMenuTargets: (thread: AgentPaneThread) => readonly AgentPaneThread[] +} { + // Collapsed groups contribute no thread items, so their rows drop out of the selection. + const renderedThreads = useMemo( + () => virtualItems.flatMap((item) => (item.type === 'thread' ? [item.thread] : [])), + [virtualItems] + ) + const getScope = useCallback(() => scrollContainerRef.current, [scrollContainerRef]) + const selection = useListMultiSelection({ + items: renderedThreads, + getKey: getThreadPaneKey, + getScope + }) + + // Why a ref: rows are React.memo'd on these handlers, and the selection callbacks change + // identity with every selection change. + const selectionRef = useRef(selection) + const onSelectThreadRef = useRef(onSelectThread) + useEffect(() => { + selectionRef.current = selection + onSelectThreadRef.current = onSelectThread + }, [selection, onSelectThread]) + + const handleSelectThread = useCallback( + (thread: AgentPaneThread, event: ActivityThreadClickEvent): void => { + // Why: macOS Ctrl-click is the native secondary click; the row menu handles it. + if (navigator.userAgent.includes('Mac') && event.ctrlKey && !event.metaKey) { + return + } + if (selectionRef.current.updateSelectionForGesture(event, thread)) { + return + } + onSelectThreadRef.current(thread) + }, + [] + ) + const getContextMenuTargets = useCallback( + (thread: AgentPaneThread) => selectionRef.current.selectForContextMenu(thread), + [] + ) + + return { selectedKeys: selection.selectedKeys, handleSelectThread, getContextMenuTargets } +} diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx index 4b063019a78..a7acb8ee7a2 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -13,7 +13,7 @@ import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import type { OpenFile } from '@/store/slices/editor' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '../tab-bar/SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { useEditorHeaderFileRename } from './editor-header-file-rename' import { getEditorHeaderCopyState } from './editor-header' import { splitPathForDisplay } from './editor-path-display' diff --git a/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx b/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx index 1b3faef1f3b..8ddc9397802 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerBackgroundMenu.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react' import { FilePlus, FolderPlus } from 'lucide-react' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { DropdownMenu, DropdownMenuContent, diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index d8ca5fba7ae..f2eb2ba1141 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -16,7 +16,7 @@ import { RENAME_HOTSPOT_ATTR } from './file-explorer-dir-toggle-timing' import type { TreeNode } from './file-explorer-types' import { useFileExplorerRowDrag } from './useFileExplorerRowDrag' import { translate } from '@/i18n/i18n' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { createMultiSelectDragGhost } from './file-explorer-multi-drag-image' import { FileExplorerRowContextMenu } from './file-explorer-row-context-menu' diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-background-menu.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-background-menu.ts index b05103959f8..f5014eae999 100644 --- a/src/renderer/src/components/right-sidebar/use-file-explorer-background-menu.ts +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-background-menu.ts @@ -1,7 +1,7 @@ import type React from 'react' import type { Dispatch, SetStateAction } from 'react' import { useCallback, useState } from 'react' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/components/tab-bar/SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import type { InlineInput } from './file-explorer-inline-input-row' type UseFileExplorerBackgroundMenuResult = { diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index cd782457d64..eab6379936c 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -80,6 +80,8 @@ export default function SidebarAgentsList({ const { markThreadRead, markThreadUnread, + markThreadsRead, + markThreadsUnread, selectThread, jumpToWorkspace, markAllThreadsRead, @@ -158,6 +160,8 @@ export default function SidebarAgentsList({ onJumpToWorkspace={jumpToWorkspace} onMarkThreadRead={markThreadRead} onMarkThreadUnread={markThreadUnread} + onMarkThreadsRead={markThreadsRead} + onMarkThreadsUnread={markThreadsUnread} canJumpToWorkspace={canJumpToWorkspace} allowMarkUnreadWhenSelected showJumpAction={false} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index e451ce5681f..1cdaa5a039b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -94,7 +94,6 @@ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => (
{children}
), - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 069eb2c0701..6b9b8515210 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -120,7 +120,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 2b5411f7899..4720e015bf8 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -133,7 +133,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx index 4628e568602..11ef17ebe96 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.delete-failed.test.tsx @@ -80,7 +80,6 @@ vi.mock('./use-worktree-sleep-state', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx index e8fac60fbf5..1407ddeb35f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.host-badge-toggle.test.tsx @@ -69,7 +69,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 269f9ed9ac7..75f151163ee 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -67,7 +67,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index 9147a881c47..d7ccd755c68 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -59,7 +59,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index 481b6ee25a4..e44647b3160 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -72,7 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index ba317dbfed2..8ac13a01093 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -68,7 +68,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index b605c5258ee..18f01fe92b6 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -76,7 +76,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index c411b58bd9a..8b5fe68de7b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -76,7 +76,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) @@ -197,6 +196,18 @@ describe('WorktreeCard quick actions', () => { expect(markup).not.toContain('border-black/[0.015]') }) + it('marks multi-selected workspaces for the shared selected style, except the active one', () => { + const selected = renderToStaticMarkup( + + ) + const activeSelected = renderToStaticMarkup( + + ) + + expect(selected).toContain('data-worktree-card-selected="true"') + expect(activeSelected).not.toContain('data-worktree-card-selected') + }) + it('renders folder directory name in the detailed metadata row without a Folder badge', () => { const markup = renderToStaticMarkup( ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 9f0d3544257..1abb2b7ca99 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -14,7 +14,6 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu( export default WorktreeContextMenu export { - CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR, WORKTREE_NATIVE_CONTEXT_MENU_ATTR, getWorktreeParentPickerAnchor, diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx index 4e4605f70f6..43658d8c1af 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx @@ -37,7 +37,6 @@ import type { WorktreeContextMenuModel } from './use-worktree-context-menu-model import { WorktreeStatusMenuItems } from './WorktreeStatusMenuItems' import { WorktreeContextMenuOverlays } from './WorktreeContextMenuOverlays' import { - CLOSE_ALL_CONTEXT_MENUS_EVENT, WORKTREE_CONTEXT_MENU_SCOPE_ATTR, getWorktreeParentPickerLabel, isWorktreeParentPickerDisabled, @@ -45,6 +44,7 @@ import { shouldRevealWorktreeDeveloperMenu, shouldUseNativeContextMenu } from './worktree-context-menu-policy' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' export default function WorktreeContextMenuView({ model }: { model: WorktreeContextMenuModel }) { const { diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 03c1760e5a2..79f53bf4d8c 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -148,7 +148,6 @@ vi.mock('./CacheTimer', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index a3f7e14b818..fde815ab094 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -146,7 +146,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 35919acae1d..4a8059db50e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -147,7 +147,6 @@ vi.mock('./WorktreeCardAgents', () => ({ vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' })) diff --git a/src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx b/src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx index e0a3e5a7ffa..79887ae88a6 100644 --- a/src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx +++ b/src/renderer/src/components/sidebar/kanban-empty-selection-prune-budget.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { makeWorktree } from '../worktree-jump-palette-test-fixtures' -import * as policy from './worktree-multi-selection' +import * as policy from '@/lib/list-multi-selection' import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection' Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) @@ -113,8 +113,8 @@ beforeEach(() => { budget = { calls: 0, ids: 0 } gestureReturn = undefined contextReturn = undefined - const original = policy.pruneWorktreeSelection - vi.spyOn(policy, 'pruneWorktreeSelection').mockImplementation((selected, anchor, ids) => { + const original = policy.pruneSelection + vi.spyOn(policy, 'pruneSelection').mockImplementation((selected, anchor, ids) => { budget.calls += 1 const iterator = ids[Symbol.iterator]() const next = iterator.next.bind(iterator) @@ -274,8 +274,8 @@ describe('Kanban empty selection pruning budget', () => { }) render({ ...props, rendered: [] }) state(board, [], null) - const a = policy.pruneWorktreeSelection(new Set(), null, ids) - const b = policy.pruneWorktreeSelection(new Set(), null, ids) + const a = policy.pruneSelection(new Set(), null, ids) + const b = policy.pruneSelection(new Set(), null, ids) expect(a).toEqual(b) expect(a).not.toBe(b) expect(a.selectedIds).not.toBe(b.selectedIds) diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts index 432f0fda395..268dfd1bdfe 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-selection.ts @@ -2,12 +2,12 @@ import React, { useCallback, useMemo, useState } from 'react' import type { Worktree } from '../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' import { - areWorktreeSelectionsEqual, - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeAreaSelection, - updateWorktreeSelection -} from './worktree-multi-selection' + areSelectionsEqual, + getSelectionIntent, + pruneSelection, + updateAreaSelection, + updateSelection +} from '@/lib/list-multi-selection' /** Returns the first still-rendered selected id, or `null` if the anchor is fine. */ function resolveRenderedAnchorId( @@ -56,10 +56,10 @@ export function useWorkspaceKanbanSelection( setSelectionAnchorId(null) } } else if (selectedWorktreeIds.size > 0 || selectionAnchorId !== null) { - const pruned = pruneWorktreeSelection(selectedWorktreeIds, selectionAnchorId, boardWorktreeIds) + const pruned = pruneSelection(selectedWorktreeIds, selectionAnchorId, boardWorktreeIds) // Why: the drawer can keep rendering while rows are filtered/reordered. // Prune stale local selection before children see ids that no longer exist. - if (!areWorktreeSelectionsEqual(selectedWorktreeIds, pruned.selectedIds)) { + if (!areSelectionsEqual(selectedWorktreeIds, pruned.selectedIds)) { setSelectedWorktreeIds(pruned.selectedIds) } if (selectionAnchorId !== pruned.anchorId) { @@ -69,9 +69,9 @@ export function useWorkspaceKanbanSelection( const updateSelectionForGesture = useCallback( (event: React.MouseEvent, worktreeId: string): boolean => { - const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) + const intent = getSelectionIntent(event, navigator.userAgent.includes('Mac')) // Why: a search can hide the anchor while leaving the rest of the - // selection on screen. updateWorktreeSelection reads an anchor missing + // selection on screen. updateSelection reads an anchor missing // from visibleIds as "no anchor" and collapses the range to the click, // so re-anchor onto the first still-rendered selected card instead. const anchorId = @@ -79,7 +79,7 @@ export function useWorkspaceKanbanSelection( ? (resolveRenderedAnchorId(renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId) ?? selectionAnchorId) : selectionAnchorId - const result = updateWorktreeSelection({ + const result = updateSelection({ visibleIds: renderedWorktreeIds, previousSelectedIds: selectedWorktreeIds, previousAnchorId: anchorId, @@ -116,7 +116,7 @@ export function useWorkspaceKanbanSelection( baseSelectedIds: ReadonlySet = selectedWorktreeIds, baseAnchorId: string | null = selectionAnchorId ): void => { - const result = updateWorktreeAreaSelection({ + const result = updateAreaSelection({ visibleIds: renderedWorktreeIds, previousSelectedIds: baseSelectedIds, previousAnchorId: baseAnchorId, @@ -124,7 +124,7 @@ export function useWorkspaceKanbanSelection( additive }) setSelectedWorktreeIds((previous) => - areWorktreeSelectionsEqual(previous, result.selectedIds) ? previous : result.selectedIds + areSelectionsEqual(previous, result.selectedIds) ? previous : result.selectedIds ) setSelectionAnchorId((previous) => previous === result.anchorId ? previous : result.anchorId diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index 772675d035f..1b185a733ab 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -15,7 +15,6 @@ import { import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match' import { - CLOSE_ALL_CONTEXT_MENUS_EVENT, EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, @@ -31,6 +30,7 @@ import { import { useWorktreeContextMenuCommands } from './use-worktree-context-menu-commands' import { useWorktreeParentPickerTransition } from './use-worktree-parent-picker-transition' import { useWorktreeContextMenuSecondaryActions } from './use-worktree-context-menu-secondary-actions' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' export type WorktreeContextMenuProps = { worktree: Worktree diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index df0cd67b412..37bafb3db25 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -58,9 +58,7 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): ? 'border border-worktree-sidebar-foreground/40 bg-worktree-sidebar-accent text-worktree-sidebar-accent-foreground ring-1 ring-inset ring-worktree-sidebar-ring/60' : isActiveSurface ? 'border border-transparent' - : isMultiSelected - ? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30' - : 'border border-transparent worktree-sidebar-card-hover', + : 'border border-transparent worktree-sidebar-card-hover', isActiveSurface && isMultiSelected && 'ring-1 ring-worktree-sidebar-ring/35', revealHighlight && [ 'scroll-to-current-workspace-reveal-highlight', @@ -80,6 +78,9 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): data-worktree-card-active={ isActiveSurface && !isLineageDropTarget ? activeSurfaceVariant : undefined } + data-worktree-card-selected={ + (isMultiSelected && !isActiveSurface && !isLineageDropTarget) || undefined + } data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index bbe73b69435..488459af972 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -10,7 +10,6 @@ import { getWorkspaceStatus } from './workspace-status' import { translate } from '@/i18n/i18n' import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' export const WORKTREE_CONTEXT_MENU_SCOPE_ATTR = 'data-worktree-context-menu-scope' export const WORKTREE_NATIVE_CONTEXT_MENU_ATTR = 'data-worktree-native-context-menu' const CONTEXT_MENU_CLICK_SUPPRESSION_MS = 500 diff --git a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts index ef47c1fac63..6b122a41ada 100644 --- a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts +++ b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts @@ -214,7 +214,6 @@ export function createWorktreeContextMenuModuleMock(): Record { return { default: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' } } diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts index 0d59c888785..c9dc08736b6 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-selection.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react' +import { useCallback, useLayoutEffect, useMemo } from 'react' import type React from 'react' import type { Worktree } from '../../../../../../shared/worktree/types' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' @@ -6,14 +6,12 @@ import type { HostSectionRow } from '../../host-section-rows' import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types' import { getRenderedWorktreesInSidebarOrder } from '../../worktree-sidebar-row-preference' import { setVisibleWorktreeIds, setVisibleWorktreeShortcutTargets } from '../../visible-worktrees' -import { - areWorktreeSelectionsEqual, - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeSelection -} from '../../worktree-multi-selection' +import { useListMultiSelection } from '@/hooks/use-list-multi-selection' import { useReusedArrayIdentity } from '../listing/use-reused-array-identity' +const getSidebarContainer = (): Element | null => + document.querySelector('[data-worktree-sidebar-container]') + // Multi-select over the rows the sidebar actually rendered, so gestures, context menus, and // the Cmd+1–9 shortcut cache all agree on one order. export function useSidebarWorktreeSelection(args: { @@ -26,107 +24,23 @@ export function useSidebarWorktreeSelection(args: { () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), [pinnedDisplayPolicy, sectionRows] ) - // Why: order-preserving sectionRows rebuilds must not give this array a new - // identity — updateSelectionForGesture depends on it, and a fresh identity - // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. - const renderedWorktreeIdentities = useReusedArrayIdentity( - useMemo( - () => Array.from(new Set(renderedWorktrees.map(getWorktreeHostIdentity))), - [renderedWorktrees] - ) - ) const renderedWorktreeIds = useReusedArrayIdentity( useMemo( () => Array.from(new Set(renderedWorktrees.map((worktree) => worktree.id))), [renderedWorktrees] ) ) - const [selectedWorktreeIds, setSelectedWorktreeIds] = useState>(new Set()) - const [selectionAnchorId, setSelectionAnchorId] = useState(null) - - const prunedSelection = pruneWorktreeSelection( - selectedWorktreeIds, - selectionAnchorId, - renderedWorktreeIdentities - ) - // Why: filters/grouping can hide selected cards; prune during render so nothing sees stale ids for unrendered worktrees. - if (!areWorktreeSelectionsEqual(selectedWorktreeIds, prunedSelection.selectedIds)) { - setSelectedWorktreeIds(prunedSelection.selectedIds) - } - if (selectionAnchorId !== prunedSelection.anchorId) { - setSelectionAnchorId(prunedSelection.anchorId) - } - - // Why identity reuse: the empty/unchanged-selection case must keep one array - // identity — selectForContextMenu and both drag-start handlers depend on - // this array, and card memo bail-out depends on those staying stable. - const selectedWorktrees = useReusedArrayIdentity( - useMemo(() => { - if (selectedWorktreeIds.size === 0) { - return [] - } - const selected = new Map() - for (const worktree of renderedWorktrees) { - const identity = getWorktreeHostIdentity(worktree) - if (selectedWorktreeIds.has(identity) && !selected.has(identity)) { - selected.set(identity, worktree) - } - } - return Array.from(selected.values()) - }, [renderedWorktrees, selectedWorktreeIds]) - ) - - useEffect(() => { - if (selectedWorktreeIds.size === 0) { - return - } - - const clearSelectionOutsideSidebar = (event: PointerEvent): void => { - const target = event.target - const sidebarContainer = document.querySelector('[data-worktree-sidebar-container]') - if (target instanceof Node && sidebarContainer?.contains(target)) { - return - } - setSelectedWorktreeIds(new Set()) - setSelectionAnchorId(null) - } - - document.addEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - return () => { - document.removeEventListener('pointerdown', clearSelectionOutsideSidebar, { capture: true }) - } - }, [selectedWorktreeIds.size]) - - const updateSelectionForGesture = useCallback( - (event: React.MouseEvent, worktree: Worktree): boolean => { - const worktreeIdentity = getWorktreeHostIdentity(worktree) - const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac')) - const result = updateWorktreeSelection({ - visibleIds: renderedWorktreeIdentities, - previousSelectedIds: selectedWorktreeIds, - previousAnchorId: selectionAnchorId, - targetId: worktreeIdentity, - intent - }) - setSelectedWorktreeIds(result.selectedIds) - setSelectionAnchorId(result.anchorId) - // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. - return intent !== 'replace' - }, - [renderedWorktreeIdentities, selectedWorktreeIds, selectionAnchorId] - ) - + const selection = useListMultiSelection({ + items: renderedWorktrees, + getKey: getWorktreeHostIdentity, + getScope: getSidebarContainer + }) + const { selectForContextMenu: selectItemForContextMenu } = selection + // Why keep the event arg: kanban and list cards share the onContextMenuSelect prop shape. const selectForContextMenu = useCallback( - (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => { - const worktreeIdentity = getWorktreeHostIdentity(worktree) - if (selectedWorktreeIds.has(worktreeIdentity) && selectedWorktreeIds.size > 1) { - return selectedWorktrees - } - setSelectedWorktreeIds(new Set([worktreeIdentity])) - setSelectionAnchorId(worktreeIdentity) - return [worktree] - }, - [selectedWorktreeIds, selectedWorktrees] + (_event: React.MouseEvent, worktree: Worktree): readonly Worktree[] => + selectItemForContextMenu(worktree), + [selectItemForContextMenu] ) // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. @@ -157,10 +71,10 @@ export function useSidebarWorktreeSelection(args: { return { renderedWorktreeIds, - renderedWorktreeIdentities, - selectedWorktreeIds, - selectedWorktrees, - updateSelectionForGesture, + renderedWorktreeIdentities: selection.visibleKeys, + selectedWorktreeIds: selection.selectedKeys, + selectedWorktrees: selection.selectedItems, + updateSelectionForGesture: selection.updateSelectionForGesture, selectForContextMenu } } diff --git a/src/renderer/src/components/status-bar/ProviderDetailsMenu.tsx b/src/renderer/src/components/status-bar/ProviderDetailsMenu.tsx index 286ff2086fc..139930d22db 100644 --- a/src/renderer/src/components/status-bar/ProviderDetailsMenu.tsx +++ b/src/renderer/src/components/status-bar/ProviderDetailsMenu.tsx @@ -123,8 +123,6 @@ export function ProviderDetailsMenu({ ) } -export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' - export function useStatusBarMenuFocusHandoff(): { reset: () => void onPointerDownOutside: () => void diff --git a/src/renderer/src/components/status-bar/StatusBarSurface.tsx b/src/renderer/src/components/status-bar/StatusBarSurface.tsx index 844eeabe504..deb2c7e0641 100644 --- a/src/renderer/src/components/status-bar/StatusBarSurface.tsx +++ b/src/renderer/src/components/status-bar/StatusBarSurface.tsx @@ -25,7 +25,8 @@ import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu' import { ClaudeSwitcherMenu } from './ClaudeSwitcherMenu' import { CodexSwitcherMenu } from './CodexSwitcherMenu' -import { ProviderDetailsMenu, CLOSE_ALL_CONTEXT_MENUS_EVENT } from './ProviderDetailsMenu' +import { ProviderDetailsMenu } from './ProviderDetailsMenu' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { ProviderSegment, UsageOverflowChip, getUsageTone } from './StatusBarProviderSegment' import { useStatusBarController } from './use-status-bar-controller' import { StatusBarVisibilityMenu } from './StatusBarVisibilityMenu' diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 1c98e3e2f47..d537eba860f 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -8,7 +8,8 @@ import { normalizeStatusBarUsageMode } from '../../../../shared/status-bar-usage import { isStatusBarItemAvailable } from './status-bar-agent-gating' import { getVisibleUsageProvider, isUsageEmptyState } from './status-bar-provider-visibility' import { getUsageProviderAccountsSectionId } from './usage-provider-settings-target' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT, useStatusBarMenuFocusHandoff } from './ProviderDetailsMenu' +import { useStatusBarMenuFocusHandoff } from './ProviderDetailsMenu' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { useStatusBarDensity } from './status-bar-density' export function useStatusBarController(floatingTerminalOpen: boolean) { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index e97e8223d3c..31a8401d5bc 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -21,7 +21,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { redactKagiSessionToken } from '../../../../shared/browser-url' import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { getLiveBrowserUrl } from '../browser-pane/describe-page/live-browser-url-registry' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 592c9579568..68da6cc1613 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -204,10 +204,6 @@ vi.mock('../right-sidebar/status-display', () => ({ STATUS_LABELS: {} })) -vi.mock('./SortableTab', () => ({ - CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca-close-all-context-menus' -})) - vi.mock('./drop-indicator', () => ({ ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator', getDropIndicatorClasses: () => '', diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 9a651ab75f2..5d35a4a915f 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -16,7 +16,7 @@ import type { GitFileStatus } from '../../../../shared/git-status-types' import type { OpenFile } from '../../store/slices/editor' import { getUntitledFileRoot } from '@/components/editor/untitled-file-rename-path' import { preventMiddleButtonDefault } from './middle-button-default-guard' -import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from './SortableTab' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { ACTIVE_TAB_INDICATOR_CLASSES, diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 1707f3efee3..3f128f811fa 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -30,6 +30,7 @@ import { resolveTerminalTabActivityStatus, terminalTabHasUnreadActivity } from './terminal-tab-activity-status' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' type SortableTabProps = { tab: TerminalTab @@ -62,8 +63,6 @@ type SortableTabProps = { canSplitTerminal?: boolean } -export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' - export default function SortableTab({ tab, unifiedTabId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts index 97e3bd7efd1..5dc11f15cd9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts @@ -2,8 +2,7 @@ import { useEffect, useRef, useState } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { TerminalPasteSource } from './terminal-paste-coordinator' import { copyTerminalSelection } from './terminal-selection-copy' - -const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' +import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' type UseTerminalContextMenuTriggerDeps = { managerRef: React.RefObject diff --git a/src/renderer/src/hooks/use-list-multi-selection.test.tsx b/src/renderer/src/hooks/use-list-multi-selection.test.tsx new file mode 100644 index 00000000000..c812f3b9ca9 --- /dev/null +++ b/src/renderer/src/hooks/use-list-multi-selection.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useListMultiSelection, type ListMultiSelection } from './use-list-multi-selection' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +type Item = { key: string } +const getKey = (item: Item): string => item.key +const a = { key: 'a' } +const b = { key: 'b' } +const c = { key: 'c' } +const d = { key: 'd' } + +let container: HTMLDivElement +let scope: HTMLDivElement +let outside: HTMLDivElement +let root: Root +let selection: ListMultiSelection +const getScope = (): Element | null => scope + +function Probe({ items }: { items: readonly Item[] }): null { + selection = useListMultiSelection({ items, getKey, getScope }) + return null +} + +function render(items: readonly Item[]): void { + act(() => root.render()) +} + +function gesture(modifiers: Partial>) { + return { metaKey: false, ctrlKey: false, shiftKey: false, ...modifiers } +} + +function setPlatform(isMac: boolean): void { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue( + isMac ? 'Mozilla/5.0 (Macintosh)' : 'Mozilla/5.0 (Windows NT 10.0)' + ) +} + +beforeEach(() => { + container = document.createElement('div') + scope = document.createElement('div') + outside = document.createElement('div') + document.body.append(container, scope, outside) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + scope.remove() + outside.remove() + vi.restoreAllMocks() +}) + +describe('useListMultiSelection', () => { + it.each([ + ['macOS', true, { metaKey: true }], + ['Windows/Linux', false, { ctrlKey: true }] + ])('toggles with the %s modifier without navigating', (_label, isMac, modifier) => { + setPlatform(isMac) + render([a, b, c]) + + let navigate = true + act(() => { + navigate = !selection.updateSelectionForGesture(gesture(modifier), a) + }) + act(() => selection.updateSelectionForGesture(gesture(modifier), c)) + + expect(navigate).toBe(false) + expect(selection.selectedItems).toEqual([a, c]) + act(() => selection.updateSelectionForGesture(gesture(modifier), a)) + expect(selection.selectedItems).toEqual([c]) + }) + + it('treats the other platform modifier as a plain click', () => { + setPlatform(true) + render([a, b]) + act(() => selection.updateSelectionForGesture(gesture({ metaKey: true }), a)) + let navigate = false + act(() => { + navigate = !selection.updateSelectionForGesture(gesture({ ctrlKey: true }), b) + }) + + expect(navigate).toBe(true) + expect(selection.selectedItems).toEqual([b]) + }) + + it('replaces on plain click and selects ranges in render order from the anchor', () => { + setPlatform(false) + render([a, b, c, d]) + act(() => selection.updateSelectionForGesture(gesture({}), b)) + act(() => selection.updateSelectionForGesture(gesture({ shiftKey: true }), d)) + + expect(selection.selectedItems).toEqual([b, c, d]) + act(() => selection.updateSelectionForGesture(gesture({ shiftKey: true }), a)) + expect(selection.selectedItems).toEqual([a, b]) + }) + + it('prunes rows that stop rendering and falls back to a surviving anchor', () => { + setPlatform(false) + render([a, b, c, d]) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), b)) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), c)) + + render([a, c, d]) + expect(selection.selectedKeys).toEqual(new Set(['c'])) + act(() => selection.updateSelectionForGesture(gesture({ shiftKey: true }), d)) + expect(selection.selectedItems).toEqual([c, d]) + }) + + it('keeps a 2+ selection for a right-click inside it and resets for one outside it', () => { + setPlatform(false) + render([a, b, c]) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), a)) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), b)) + + let targets: readonly Item[] = [] + act(() => { + targets = selection.selectForContextMenu(b) + }) + expect(targets).toEqual([a, b]) + expect(selection.selectedItems).toEqual([a, b]) + + act(() => { + targets = selection.selectForContextMenu(c) + }) + expect(targets).toEqual([c]) + expect(selection.selectedItems).toEqual([c]) + }) + + it('clears on a pointerdown outside the scope only', () => { + setPlatform(false) + render([a, b]) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), a)) + + act(() => { + scope.dispatchEvent(new Event('pointerdown', { bubbles: true })) + }) + expect(selection.selectedItems).toEqual([a]) + act(() => { + outside.dispatchEvent(new Event('pointerdown', { bubbles: true })) + }) + expect(selection.selectedItems).toEqual([]) + }) + + it('keeps array identities across rebuilds with the same order', () => { + setPlatform(false) + render([a, b]) + act(() => selection.updateSelectionForGesture(gesture({ ctrlKey: true }), a)) + const { visibleKeys, selectedItems, updateSelectionForGesture } = selection + + render([a, b]) + expect(selection.visibleKeys).toBe(visibleKeys) + expect(selection.selectedItems).toBe(selectedItems) + expect(selection.updateSelectionForGesture).toBe(updateSelectionForGesture) + }) +}) diff --git a/src/renderer/src/hooks/use-list-multi-selection.ts b/src/renderer/src/hooks/use-list-multi-selection.ts new file mode 100644 index 00000000000..706289435cc --- /dev/null +++ b/src/renderer/src/hooks/use-list-multi-selection.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type React from 'react' +import { + areSelectionsEqual, + getSelectionIntent, + pruneSelection, + updateSelection +} from '@/lib/list-multi-selection' +import { useReusedArrayIdentity } from '@/components/sidebar/worktree-list/listing/use-reused-array-identity' + +export type ListMultiSelection = { + visibleKeys: string[] + selectedKeys: Set + selectedItems: T[] + /** Returns true for selection-only gestures (Cmd/Ctrl/Shift), which must not navigate. */ + updateSelectionForGesture: ( + event: Pick, + item: T + ) => boolean + /** Right-click inside a 2+ selection acts on all of it; elsewhere it resets to the item. */ + selectForContextMenu: (item: T) => readonly T[] +} + +// Click/Cmd/Shift multi-select keyed by id over `items` in render order. `getKey` and +// `getScope` must keep one identity across renders. +export function useListMultiSelection({ + items, + getKey, + getScope +}: { + items: readonly T[] + getKey: (item: T) => string + /** Pointerdowns outside this element clear the selection. */ + getScope: () => Element | null +}): ListMultiSelection { + // Why: order-preserving item rebuilds must not give this array a new identity — + // updateSelectionForGesture depends on it, and a fresh identity there defeats + // React.memo bail-out for every row on epoch bumps. + const visibleKeys = useReusedArrayIdentity( + useMemo(() => Array.from(new Set(items.map((item) => getKey(item)))), [items, getKey]) + ) + const [selectedKeys, setSelectedKeys] = useState>(new Set()) + const [selectionAnchorKey, setSelectionAnchorKey] = useState(null) + + const prunedSelection = pruneSelection(selectedKeys, selectionAnchorKey, visibleKeys) + // Why: filters/grouping can hide selected rows; prune during render so nothing sees stale ids for unrendered items. + if (!areSelectionsEqual(selectedKeys, prunedSelection.selectedIds)) { + setSelectedKeys(prunedSelection.selectedIds) + } + if (selectionAnchorKey !== prunedSelection.anchorId) { + setSelectionAnchorKey(prunedSelection.anchorId) + } + + // Why identity reuse: the empty/unchanged-selection case must keep one array + // identity — selectForContextMenu and drag-start handlers depend on this + // array, and row memo bail-out depends on those staying stable. + const selectedItems = useReusedArrayIdentity( + useMemo(() => { + if (selectedKeys.size === 0) { + return [] + } + const selected = new Map() + for (const item of items) { + const key = getKey(item) + if (selectedKeys.has(key) && !selected.has(key)) { + selected.set(key, item) + } + } + return Array.from(selected.values()) + }, [items, getKey, selectedKeys]) + ) + + useEffect(() => { + if (selectedKeys.size === 0) { + return + } + + const clearSelectionOutsideScope = (event: PointerEvent): void => { + const target = event.target + if (target instanceof Node && getScope()?.contains(target)) { + return + } + setSelectedKeys(new Set()) + setSelectionAnchorKey(null) + } + + document.addEventListener('pointerdown', clearSelectionOutsideScope, { capture: true }) + return () => { + document.removeEventListener('pointerdown', clearSelectionOutsideScope, { capture: true }) + } + }, [selectedKeys.size, getScope]) + + const updateSelectionForGesture = useCallback( + (event: Pick, item: T): boolean => { + const intent = getSelectionIntent(event, navigator.userAgent.includes('Mac')) + const result = updateSelection({ + visibleIds: visibleKeys, + previousSelectedIds: selectedKeys, + previousAnchorId: selectionAnchorKey, + targetId: getKey(item), + intent + }) + setSelectedKeys(result.selectedIds) + setSelectionAnchorKey(result.anchorId) + // Plain click navigates; modifier gestures are selection-only so a batch can build without switching away. + return intent !== 'replace' + }, + [getKey, visibleKeys, selectedKeys, selectionAnchorKey] + ) + + const selectForContextMenu = useCallback( + (item: T): readonly T[] => { + const key = getKey(item) + if (selectedKeys.has(key) && selectedKeys.size > 1) { + return selectedItems + } + setSelectedKeys(new Set([key])) + setSelectionAnchorKey(key) + return [item] + }, + [getKey, selectedKeys, selectedItems] + ) + + return { + visibleKeys, + selectedKeys, + selectedItems, + updateSelectionForGesture, + selectForContextMenu + } +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index ff122b73905..d72e86a111d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -203,6 +203,14 @@ }, "ActivityScopeFilterControls": { "hiddenCount": "{{value0}} hidden" + }, + "ActivityThreadContextMenu": { + "clearMany_one": "Clear {{count}} Agent from List", + "clearMany_other": "Clear {{count}} Agents from List", + "markManyRead_one": "Mark {{count}} Agent Read", + "markManyRead_other": "Mark {{count}} Agents Read", + "markManyUnread_one": "Mark {{count}} Agent Unread", + "markManyUnread_other": "Mark {{count}} Agents Unread" } }, "artifacts": { @@ -2796,8 +2804,8 @@ "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", "runCommandAgain": "Run /{{command}} again.", - "sendToTryAgain": "Send your message to try again.", "sendAgainToTryOnceMore": "Send your message again to try once more.", + "sendToTryAgain": "Send your message to try again.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 173a1453e07..4e69e42b639 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16966,7 +16966,16 @@ "markUnread": "Mark Unread", "goToWorkspace": "Go to Workspace", "clear": "Clear from List", - "agentSection": "Agent" + "agentSection": "Agent", + "markManyRead_one": "Mark {{count}} Agent Read", + "markManyRead_other": "Mark {{count}} Agents Read", + "markManyUnread_one": "Mark {{count}} Agent Unread", + "markManyUnread_other": "Mark {{count}} Agents Unread", + "clearMany_one": "Clear {{count}} Agent from List", + "clearMany_other": "Clear {{count}} Agents from List", + "markManyRead": "Mark {{count}} Agents Read", + "markManyUnread": "Mark {{count}} Agents Unread", + "clearMany": "Clear {{count}} Agents from List" } }, "confirmation": { diff --git a/src/renderer/src/lib/close-all-context-menus.ts b/src/renderer/src/lib/close-all-context-menus.ts new file mode 100644 index 00000000000..b8e094f09e1 --- /dev/null +++ b/src/renderer/src/lib/close-all-context-menus.ts @@ -0,0 +1,2 @@ +/** Window event a context menu dispatches as it opens; every other open menu closes on it. */ +export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' diff --git a/src/renderer/src/components/sidebar/worktree-multi-selection.test.ts b/src/renderer/src/lib/list-multi-selection.test.ts similarity index 74% rename from src/renderer/src/components/sidebar/worktree-multi-selection.test.ts rename to src/renderer/src/lib/list-multi-selection.test.ts index 77348eb4020..74545df2325 100644 --- a/src/renderer/src/components/sidebar/worktree-multi-selection.test.ts +++ b/src/renderer/src/lib/list-multi-selection.test.ts @@ -1,28 +1,28 @@ import { describe, expect, it } from 'vitest' import { - getWorktreeSelectionIntent, - pruneWorktreeSelection, - updateWorktreeAreaSelection, - updateWorktreeSelection -} from './worktree-multi-selection' + getSelectionIntent, + pruneSelection, + updateAreaSelection, + updateSelection +} from './list-multi-selection' const visibleIds = ['wt-1', 'wt-2', 'wt-3', 'wt-4'] -describe('worktree multi selection', () => { +describe('list multi selection', () => { it('uses Cmd on Mac and Ctrl elsewhere for toggle selection', () => { - expect( - getWorktreeSelectionIntent({ metaKey: true, ctrlKey: false, shiftKey: false }, true) - ).toBe('toggle') - expect( - getWorktreeSelectionIntent({ metaKey: false, ctrlKey: true, shiftKey: false }, false) - ).toBe('toggle') - expect( - getWorktreeSelectionIntent({ metaKey: false, ctrlKey: false, shiftKey: true }, false) - ).toBe('range') + expect(getSelectionIntent({ metaKey: true, ctrlKey: false, shiftKey: false }, true)).toBe( + 'toggle' + ) + expect(getSelectionIntent({ metaKey: false, ctrlKey: true, shiftKey: false }, false)).toBe( + 'toggle' + ) + expect(getSelectionIntent({ metaKey: false, ctrlKey: false, shiftKey: true }, false)).toBe( + 'range' + ) }) it('replaces selection on plain click', () => { - const result = updateWorktreeSelection({ + const result = updateSelection({ visibleIds, previousSelectedIds: new Set(['wt-1', 'wt-2']), previousAnchorId: 'wt-1', @@ -35,7 +35,7 @@ describe('worktree multi selection', () => { }) it('toggles one worktree without dropping the rest', () => { - const result = updateWorktreeSelection({ + const result = updateSelection({ visibleIds, previousSelectedIds: new Set(['wt-1', 'wt-2']), previousAnchorId: 'wt-2', @@ -48,7 +48,7 @@ describe('worktree multi selection', () => { }) it('allows toggling the last selected worktree off', () => { - const result = updateWorktreeSelection({ + const result = updateSelection({ visibleIds, previousSelectedIds: new Set(['wt-2']), previousAnchorId: 'wt-2', @@ -61,7 +61,7 @@ describe('worktree multi selection', () => { }) it('selects the visible range from the anchor to the target', () => { - const result = updateWorktreeSelection({ + const result = updateSelection({ visibleIds, previousSelectedIds: new Set(['wt-1']), previousAnchorId: 'wt-1', @@ -74,14 +74,14 @@ describe('worktree multi selection', () => { }) it('prunes selection when filtering hides selected worktrees', () => { - const result = pruneWorktreeSelection(new Set(['wt-1', 'wt-3']), 'wt-1', ['wt-2', 'wt-3']) + const result = pruneSelection(new Set(['wt-1', 'wt-3']), 'wt-1', ['wt-2', 'wt-3']) expect([...result.selectedIds]).toEqual(['wt-3']) expect(result.anchorId).toBe('wt-3') }) it('replaces selection from an area in visible order', () => { - const result = updateWorktreeAreaSelection({ + const result = updateAreaSelection({ visibleIds, previousSelectedIds: new Set(['wt-4']), previousAnchorId: 'wt-4', @@ -94,7 +94,7 @@ describe('worktree multi selection', () => { }) it('adds area selection to the existing batch with modifier keys', () => { - const result = updateWorktreeAreaSelection({ + const result = updateAreaSelection({ visibleIds, previousSelectedIds: new Set(['wt-4']), previousAnchorId: 'wt-4', @@ -107,7 +107,7 @@ describe('worktree multi selection', () => { }) it('does not clear the existing batch for an empty additive area', () => { - const result = updateWorktreeAreaSelection({ + const result = updateAreaSelection({ visibleIds, previousSelectedIds: new Set(['wt-2']), previousAnchorId: 'wt-2', @@ -120,7 +120,7 @@ describe('worktree multi selection', () => { }) it('clears the existing batch for an empty non-additive area', () => { - const result = updateWorktreeAreaSelection({ + const result = updateAreaSelection({ visibleIds, previousSelectedIds: new Set(['wt-2', 'wt-3']), previousAnchorId: 'wt-2', diff --git a/src/renderer/src/components/sidebar/worktree-multi-selection.ts b/src/renderer/src/lib/list-multi-selection.ts similarity index 82% rename from src/renderer/src/components/sidebar/worktree-multi-selection.ts rename to src/renderer/src/lib/list-multi-selection.ts index b2e2e580d82..84cc17bd85c 100644 --- a/src/renderer/src/components/sidebar/worktree-multi-selection.ts +++ b/src/renderer/src/lib/list-multi-selection.ts @@ -1,19 +1,19 @@ -export type WorktreeSelectionIntent = 'replace' | 'toggle' | 'range' +export type SelectionIntent = 'replace' | 'toggle' | 'range' -export type WorktreeSelectionResult = { +export type SelectionResult = { selectedIds: Set anchorId: string } -export type WorktreeAreaSelectionResult = { +export type AreaSelectionResult = { selectedIds: Set anchorId: string | null } -export function getWorktreeSelectionIntent( +export function getSelectionIntent( event: Pick, isMac: boolean -): WorktreeSelectionIntent { +): SelectionIntent { if (event.shiftKey) { return 'range' } @@ -21,13 +21,13 @@ export function getWorktreeSelectionIntent( return toggle ? 'toggle' : 'replace' } -export function updateWorktreeSelection(params: { +export function updateSelection(params: { visibleIds: readonly string[] previousSelectedIds: ReadonlySet previousAnchorId: string | null targetId: string - intent: WorktreeSelectionIntent -}): WorktreeSelectionResult { + intent: SelectionIntent +}): SelectionResult { const { visibleIds, previousSelectedIds, previousAnchorId, targetId, intent } = params if (intent === 'replace') { @@ -62,7 +62,7 @@ export function updateWorktreeSelection(params: { } } -export function pruneWorktreeSelection( +export function pruneSelection( selectedIds: ReadonlySet, anchorId: string | null, visibleIds: readonly string[] @@ -80,13 +80,13 @@ export function pruneWorktreeSelection( } } -export function updateWorktreeAreaSelection(params: { +export function updateAreaSelection(params: { visibleIds: readonly string[] previousSelectedIds: ReadonlySet previousAnchorId: string | null areaIds: readonly string[] additive: boolean -}): WorktreeAreaSelectionResult { +}): AreaSelectionResult { const { visibleIds, previousSelectedIds, previousAnchorId, areaIds, additive } = params const areaIdSet = new Set(areaIds) const orderedAreaIds = visibleIds.filter((id) => areaIdSet.has(id)) @@ -108,10 +108,7 @@ export function updateWorktreeAreaSelection(params: { } } -export function areWorktreeSelectionsEqual( - a: ReadonlySet, - b: ReadonlySet -): boolean { +export function areSelectionsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) { return false }