mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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:
@@ -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'
|
||||
}))
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
-1
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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'
|
||||
+24
-24
@@ -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',
|
||||
+12
-15
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user