feat(activity): multi-select agent rows with a bulk right-click menu (#25486)

Agent rows in the activity view now select like workspace cards (Cmd/Ctrl-click to toggle, Shift-click for a range), with a bulk right-click menu to mark agents read or unread and clear finished agents. Selection logic and the selected-row style are shared with the workspace list, and right-click menus close each other.
This commit is contained in:
Jinwoo Hong
2026-10-05 17:55:00 -04:00
committed by GitHub
parent d791421568
commit e2a1ecd479
59 changed files with 1101 additions and 281 deletions
+8
View File
@@ -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'] {
@@ -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]')
@@ -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}
@@ -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])
})
})
@@ -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
@@ -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<typeof ActivityClearCompleted>()),
clearActivityThread: mocks.clearActivityThread
clearActivityThread: mocks.clearActivityThread,
clearCompletedActivity: mocks.clearCompletedActivity
}))
function makeThread(overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
@@ -36,28 +38,73 @@ function makeThread(overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
}
}
function makeDoneThread(paneKey: string, overrides: Partial<AgentPaneThread> = {}) {
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(
<ActivityThreadContextMenu
thread={thread}
canJump={canJump}
disableMarkUnread={disableMarkUnread}
canMarkUnread={canMarkUnread}
getTargets={getTargets}
{...handlers}
>
{(menuOpen) => (
<div data-testid="row" data-menu-open={menuOpen ? '' : undefined}>
<div data-testid={testId} data-menu-open={menuOpen ? '' : undefined}>
row
</div>
)}
</ActivityThreadContextMenu>
)
}
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' },
@@ -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<readonly AgentPaneThread[]>([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 (
<ContextMenu onOpenChange={setMenuOpen}>
<ContextMenu open={menuOpen} onOpenChange={handleOpenChange}>
<ContextMenuTrigger asChild>{children(menuOpen)}</ContextMenuTrigger>
{/* Why no focus restore: refocusing the row would reopen its hover preview and pin it open. */}
<ContextMenuContent className="w-52" onCloseAutoFocus={(event) => event.preventDefault()}>
<ContextMenuLabel>
{translate('auto.components.activity.ActivityThreadContextMenu.agentSection', 'Agent')}
</ContextMenuLabel>
<ContextMenuItem onSelect={() => onOpen(thread)}>
<PanelRight className="size-3.5" />
{translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')}
{targets.length > 1 ? (
<ActivityThreadBulkMenuItems
targets={targets}
canMarkUnread={canMarkUnread}
onMarkManyRead={onMarkManyRead}
onMarkManyUnread={onMarkManyUnread}
/>
) : (
<ActivityThreadSingleMenuItems
thread={thread}
canJump={canJump}
canMarkUnread={canMarkUnread(thread)}
onOpen={onOpen}
onJump={onJump}
onMarkRead={onMarkRead}
onMarkUnread={onMarkUnread}
/>
)}
</ContextMenuContent>
</ContextMenu>
)
}
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 (
<>
<ContextMenuItem onSelect={() => onOpen(thread)}>
<PanelRight className="size-3.5" />
{translate('auto.components.activity.ActivityThreadContextMenu.open', 'Open')}
</ContextMenuItem>
{canJump ? (
<ContextMenuItem onSelect={() => onJump(thread)}>
<ExternalLink className="size-3.5" />
{translate(
'auto.components.activity.ActivityThreadContextMenu.goToWorkspace',
'Go to Workspace'
)}
</ContextMenuItem>
{canJump ? (
<ContextMenuItem onSelect={() => onJump(thread)}>
<ExternalLink className="size-3.5" />
) : null}
<ContextMenuSeparator />
{getActivityThreadCopyTargets(thread, canJump).map((target) => (
<ContextMenuItem
key={target.key}
onSelect={() => void window.api.ui.writeClipboardText(target.value)}
>
<Copy className="size-3.5" />
{target.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem
disabled={!thread.unread && !canMarkUnread}
onSelect={() => (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))}
>
{thread.unread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{thread.unread
? translate('auto.components.activity.ActivityThreadContextMenu.markRead', 'Mark Read')
: translate(
'auto.components.activity.ActivityThreadContextMenu.markUnread',
'Mark Unread'
)}
</ContextMenuItem>
{isClearableActivityThread(thread) ? (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => clearActivityThread(thread)}>
<X className="size-3.5" />
{translate(
'auto.components.activity.ActivityThreadContextMenu.goToWorkspace',
'Go to Workspace'
'auto.components.activity.ActivityThreadContextMenu.clear',
'Clear from List'
)}
</ContextMenuItem>
) : null}
<ContextMenuSeparator />
{getActivityThreadCopyTargets(thread, canJump).map((target) => (
<ContextMenuItem
key={target.key}
onSelect={() => void window.api.ui.writeClipboardText(target.value)}
>
<Copy className="size-3.5" />
{target.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem
disabled={!thread.unread && disableMarkUnread}
onSelect={() => (thread.unread ? onMarkRead(thread) : onMarkUnread(thread))}
>
{thread.unread ? <BellOff className="size-3.5" /> : <Bell className="size-3.5" />}
{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 ? (
<ContextMenuItem onSelect={() => onMarkManyRead(unread)}>
<BellOff className="size-3.5" />
{translate(
'auto.components.activity.ActivityThreadContextMenu.markManyRead',
'Mark {{count}} Agents Read',
{ count: unread.length }
)}
</ContextMenuItem>
) : (
<ContextMenuItem disabled={read.length === 0} onSelect={() => onMarkManyUnread(read)}>
<Bell className="size-3.5" />
{read.length > 0
? translate(
'auto.components.activity.ActivityThreadContextMenu.markManyUnread',
'Mark {{count}} Agents Unread',
{ count: read.length }
)
: translate(
'auto.components.activity.ActivityThreadContextMenu.markUnread',
'Mark Unread'
)}
</ContextMenuItem>
{isClearableActivityThread(thread) ? (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => clearActivityThread(thread)}>
<X className="size-3.5" />
{translate(
'auto.components.activity.ActivityThreadContextMenu.clear',
'Clear from List'
)}
</ContextMenuItem>
</>
) : null}
</ContextMenuContent>
</ContextMenu>
)}
<ContextMenuSeparator />
<ContextMenuItem
disabled={clearable.length === 0}
onSelect={() => clearCompletedActivity(clearable)}
>
<X className="size-3.5" />
{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'
)}
</ContextMenuItem>
</>
)
}
@@ -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}
@@ -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<typeof vi.fn<(thread: AgentPaneThread) => void>>
function renderPane(selectedPaneKey: string | null = null): void {
act(() => {
root.render(
<TooltipProvider>
<ActivityThreadListPane
activityFilterInputRef={{ current: null }}
query=""
onQueryChange={vi.fn()}
groupBy="status"
onGroupByChange={vi.fn()}
readFilter="all"
onReadFilterChange={vi.fn()}
compactMode={true}
hasUnreadThreads={false}
onCompactModeChange={vi.fn()}
visibleThreadGroups={groups}
visibleThreadCount={4}
selectedPaneKey={selectedPaneKey}
onSelectThread={onSelectThread}
onJumpToWorkspace={vi.fn()}
onMarkThreadRead={vi.fn()}
onMarkThreadUnread={vi.fn()}
onMarkThreadsRead={vi.fn()}
onMarkThreadsUnread={vi.fn()}
canJumpToWorkspace={() => true}
showFilterControls={false}
showOptionsMenu={false}
/>
</TooltipProvider>
)
})
}
function row(thread: AgentPaneThread): HTMLElement {
const match = Array.from(
container.querySelectorAll<HTMLElement>('[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<Record<'metaKey' | 'ctrlKey' | 'shiftKey', boolean>> = {}
): 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<HTMLElement>('[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'])
})
})
@@ -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}
/>
</div>
@@ -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}
@@ -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"
>
<div className="flex min-w-0 items-start gap-1.5">
<span className="mt-0.5 inline-flex shrink-0">
@@ -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',
@@ -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<typeof ActivityThreadContextMenu>[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<string>
onSelectThread: Parameters<typeof ActivityThreadRow>[0]['onSelect']
onOpenThread: ContextMenuProps['onOpen']
getContextMenuTargets: ContextMenuProps['getTargets']
onJumpToWorkspace: Parameters<typeof ActivityThreadRow>[0]['onJump']
onMarkThreadRead: Parameters<typeof ActivityThreadRow>[0]['onMarkRead']
onMarkThreadUnread: Parameters<typeof ActivityThreadRow>[0]['onMarkUnread']
canJumpToWorkspace: (
thread: Extract<ActivityVirtualItemDescriptor, { type: 'thread' }>['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 (
<ActivityThreadContextMenu
thread={item.thread}
canJump={canJump}
disableMarkUnread={disableMarkUnread}
onOpen={onSelectThread}
canMarkUnread={canMarkThreadUnread}
getTargets={getContextMenuTargets}
onOpen={onOpenThread}
onJump={onJumpToWorkspace}
onMarkRead={onMarkThreadRead}
onMarkUnread={onMarkThreadUnread}
onMarkManyRead={onMarkThreadsRead}
onMarkManyUnread={onMarkThreadsUnread}
>
{(menuOpen) => (
// Why the menu wraps this wrapper, not the row: the row is already the hover-card
@@ -72,7 +86,8 @@ export function ActivityThreadVirtualRow({
<div className="pb-0.5">
<ActivityThreadRow
thread={item.thread}
selected={item.thread.paneKey === selectedPaneKey}
selected={isOpen}
multiSelected={!isOpen && multiSelectedKeys.has(item.thread.paneKey)}
onSelect={onSelectThread}
onJump={onJumpToWorkspace}
onMarkRead={onMarkThreadRead}
@@ -80,7 +95,7 @@ export function ActivityThreadVirtualRow({
onClear={isClearableActivityThread(item.thread) ? clearActivityThread : undefined}
canJump={canJump}
compactMode={compactMode}
disableMarkUnread={disableMarkUnread}
disableMarkUnread={!canMarkThreadUnread(item.thread)}
showJumpAction={showJumpAction}
previewSuppressed={menuOpen}
/>
@@ -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
@@ -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<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>
/** 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<HTMLDivElement | null>
onSelectThread: (thread: AgentPaneThread) => void
}): {
selectedKeys: ReadonlySet<string>
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 }
}
@@ -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'
@@ -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,
@@ -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'
@@ -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 = {
@@ -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}
@@ -94,7 +94,6 @@ vi.mock('./WorktreeContextMenu', () => ({
default: ({ children }: { children: ReactNode }) => (
<div data-testid="context-menu-wrapper">{children}</div>
),
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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive={false} isMultiSelected />
)
const activeSelected = renderToStaticMarkup(
<WorktreeCard worktree={makeWorktree()} repo={makeRepo()} isActive isMultiSelected />
)
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(
<WorktreeCard
@@ -79,7 +79,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'
}))
@@ -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,
@@ -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 {
@@ -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'
}))
@@ -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'
}))
@@ -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'
}))
@@ -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)
@@ -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<HTMLElement>, 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<string> = 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
@@ -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
@@ -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}
@@ -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
@@ -214,7 +214,6 @@ export function createWorktreeContextMenuModuleMock(): Record<string, unknown> {
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'
}
}
@@ -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<Set<string>>(new Set())
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(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<string, Worktree>()
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<HTMLElement>, 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<HTMLElement>, 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<HTMLElement>, 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
}
}
@@ -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
@@ -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'
@@ -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) {
@@ -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 {
@@ -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: () => '',
@@ -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,
@@ -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,
@@ -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<PaneManager | null>
@@ -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<Item>
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(<Probe items={items} />))
}
function gesture(modifiers: Partial<Record<'metaKey' | 'ctrlKey' | 'shiftKey', boolean>>) {
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)
})
})
@@ -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<T> = {
visibleKeys: string[]
selectedKeys: Set<string>
selectedItems: T[]
/** Returns true for selection-only gestures (Cmd/Ctrl/Shift), which must not navigate. */
updateSelectionForGesture: (
event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
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<T>({
items,
getKey,
getScope
}: {
items: readonly T[]
getKey: (item: T) => string
/** Pointerdowns outside this element clear the selection. */
getScope: () => Element | null
}): ListMultiSelection<T> {
// 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<Set<string>>(new Set())
const [selectionAnchorKey, setSelectionAnchorKey] = useState<string | null>(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<string, T>()
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<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>, 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
}
}
+9 -1
View File
@@ -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.",
+10 -1
View File
@@ -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": {
@@ -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'
@@ -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',
@@ -1,19 +1,19 @@
export type WorktreeSelectionIntent = 'replace' | 'toggle' | 'range'
export type SelectionIntent = 'replace' | 'toggle' | 'range'
export type WorktreeSelectionResult = {
export type SelectionResult = {
selectedIds: Set<string>
anchorId: string
}
export type WorktreeAreaSelectionResult = {
export type AreaSelectionResult = {
selectedIds: Set<string>
anchorId: string | null
}
export function getWorktreeSelectionIntent(
export function getSelectionIntent(
event: Pick<MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>,
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<string>
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<string>,
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<string>
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<string>,
b: ReadonlySet<string>
): boolean {
export function areSelectionsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
if (a.size !== b.size) {
return false
}