fix(workspace-board): sync Linear on context-menu Move to Status (#10176)

* fix(workspace-board): sync Linear on context-menu Move to Status

The board's right-click "Move to Status" only wrote the local
workspaceStatus and silently dropped the Linear sync that drag-and-drop
performs. Thread an onAssignWorkspaceStatus callback from the drawer
through the kanban card chain into WorktreeContextMenu so the menu
funnels through the same local-first + Linear-sync path
(moveWorktreesToStatus) as drag-and-drop. Outside the board (sidebar
list) the menu keeps its local-only behavior.

* test(workspace-board): guard context-menu Move to Status routing

Extract the context-menu status-assign routing into a pure
planWorkspaceStatusAssignment helper (behavior-preserving) and unit-test
it, so the board Linear-sync vs sidebar local-only branch — the exact
path #10175 regressed on — cannot silently flip back unnoticed. Covers
board-sync-forwards-all-ids, local-only-writes-only-changed, and the
same-status no-op case.

Addresses code-review finding: the added drawer tests exercised the
sync wiring via a mocked LaneGrid but never the menu's routing branch.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: ElNelyo <ElNelyo@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
ElNelyo 2026-07-24 04:23:46 +02:00 committed by GitHub
parent 1b01385872
commit 0bb755151c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 198 additions and 14 deletions

View File

@ -1,7 +1,7 @@
import React from 'react'
import { Pin } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import type { Repo, Worktree } from '../../../../shared/types'
import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types'
import WorktreeCard from './WorktreeCard'
import { translate } from '@/i18n/i18n'
@ -18,6 +18,7 @@ type WorkspaceKanbanCardProps = {
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
}
function WorkspaceKanbanCard({
@ -29,7 +30,8 @@ function WorkspaceKanbanCard({
nativeDragEnabled = true,
onActivate,
onSelectionGesture,
onContextMenuSelect
onContextMenuSelect,
onAssignWorkspaceStatus
}: WorkspaceKanbanCardProps): React.JSX.Element {
const contextWorktrees =
isSelected && selectedWorktrees && selectedWorktrees.length > 0 ? selectedWorktrees : undefined
@ -61,6 +63,7 @@ function WorkspaceKanbanCard({
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={(event) => onContextMenuSelect(event, worktree)}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
/>
</div>
)

View File

@ -31,7 +31,8 @@ const {
toastErrorMock,
toastWarningMock,
pointerDragState,
documentDropState
documentDropState,
laneAssignStatusState
} = vi.hoisted(() => ({
syncWorkspaceBoardTaskStatusesMock: vi.fn(() =>
Promise.resolve({
@ -44,7 +45,10 @@ const {
toastErrorMock: vi.fn(),
toastWarningMock: vi.fn(),
pointerDragState: { current: null as PointerDragParams | null },
documentDropState: { current: null as DocumentDropCapture | null }
documentDropState: { current: null as DocumentDropCapture | null },
laneAssignStatusState: {
current: null as ((worktreeIds: readonly string[], status: string) => void) | null
}
}))
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@ -81,7 +85,14 @@ vi.mock('./WorkspaceKanbanDrawerHeader', () => ({
}))
vi.mock('./WorkspaceKanbanLaneGrid', () => ({
default: () => <div data-testid="workspace-board-lanes" />
default: ({
onAssignWorkspaceStatus
}: {
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: string) => void
}) => {
laneAssignStatusState.current = onAssignWorkspaceStatus ?? null
return <div data-testid="workspace-board-lanes" />
}
}))
vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({
@ -255,6 +266,7 @@ beforeEach(() => {
root = createRoot(container)
pointerDragState.current = null
documentDropState.current = null
laneAssignStatusState.current = null
syncWorkspaceBoardTaskStatusesMock.mockClear()
toastErrorMock.mockClear()
toastWarningMock.mockClear()
@ -400,6 +412,44 @@ describe('WorkspaceKanbanDrawer task status sync wiring', () => {
)
})
it('syncs Linear when the board context-menu "Move to Status" assigns a status', () => {
const item = worktree()
renderDrawer(item)
act(() => {
laneAssignStatusState.current?.([item.id], 'in-review')
})
expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith(
expect.objectContaining({
worktreeIds: [item.id],
targetStatus: { id: 'in-review', label: 'In review' }
})
)
})
it('does not sync a context-menu status move when the setting is disabled', () => {
const item = worktree()
renderDrawer(item, false)
act(() => {
laneAssignStatusState.current?.([item.id], 'in-review')
})
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
})
it('does not sync a context-menu status move that keeps the same board status', () => {
const item = worktree({ workspaceStatus: 'in-review' })
renderDrawer(item)
act(() => {
laneAssignStatusState.current?.([item.id], 'in-review')
})
expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled()
})
it('shows an error toast when task status sync unexpectedly rejects', async () => {
syncWorkspaceBoardTaskStatusesMock.mockRejectedValueOnce(new Error('Runtime disconnected'))
const item = worktree()

View File

@ -288,6 +288,30 @@ export default function WorkspaceKanbanDrawer({
},
[maybeSyncWorkspaceBoardTaskStatuses, updateWorktreeMeta, workspaceStatuses, worktreeById]
)
// Why: the board's context-menu "Move to Status" must funnel through the same
// local-first + Linear-sync path as drag-and-drop. Without this callback the
// menu only writes the local status and silently drops the Linear sync.
const moveWorktreesToStatus = useCallback(
(worktreeIds: readonly string[], status: WorkspaceStatus) => {
const updates = new Map<string, Partial<WorktreeMeta>>()
const changedIds: string[] = []
for (const worktreeId of worktreeIds) {
const current = worktreeById.get(worktreeId)
if (!current || getWorkspaceStatus(current, workspaceStatuses) === status) {
continue
}
changedIds.push(worktreeId)
updates.set(worktreeId, { workspaceStatus: status })
}
if (changedIds.length === 0) {
return
}
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
void updateWorktreesMeta(updates)
maybeSyncWorkspaceBoardTaskStatuses(changedIds, status)
},
[maybeSyncWorkspaceBoardTaskStatuses, updateWorktreesMeta, workspaceStatuses, worktreeById]
)
const getSourceStatusKeys = useCallback(
(worktreeIds: readonly string[]): WorkspaceStatus[] =>
worktreeIds.flatMap((worktreeId) => {
@ -775,6 +799,7 @@ export default function WorkspaceKanbanDrawer({
onActivate={handleWorktreeActivate}
onSelectionGesture={updateSelectionForGesture}
onContextMenuSelect={selectForContextMenu}
onAssignWorkspaceStatus={moveWorktreesToStatus}
onCreateWorktree={createWorktreeForStatus}
onColumnResizeStart={onColumnResizeStart}
onColumnResizeKeyDown={onColumnResizeKeyDown}

View File

@ -27,6 +27,7 @@ type WorkspaceKanbanLaneGridProps = {
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onCreateWorktree: (statusId: string) => void
onColumnResizeStart: (event: React.PointerEvent<HTMLElement>) => void
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
@ -49,6 +50,7 @@ export default function WorkspaceKanbanLaneGrid({
onActivate,
onSelectionGesture,
onContextMenuSelect,
onAssignWorkspaceStatus,
onCreateWorktree,
onColumnResizeStart,
onColumnResizeKeyDown
@ -81,6 +83,7 @@ export default function WorkspaceKanbanLaneGrid({
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
onCreateWorktree={onCreateWorktree}
onColumnResizeStart={onColumnResizeStart}
onColumnResizeKeyDown={onColumnResizeKeyDown}

View File

@ -1,6 +1,11 @@
import React from 'react'
import { Plus } from 'lucide-react'
import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types'
import type {
Repo,
WorkspaceStatus,
WorkspaceStatusDefinition,
Worktree
} from '../../../../shared/types'
import {
WORKSPACE_BOARD_COLUMN_WIDTH_MAX,
WORKSPACE_BOARD_COLUMN_WIDTH_MIN
@ -33,6 +38,7 @@ type WorkspaceKanbanStatusLaneProps = {
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onCreateWorktree: (statusId: string) => void
onColumnResizeStart: (event: React.PointerEvent<HTMLElement>) => void
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => void
@ -56,6 +62,7 @@ export default function WorkspaceKanbanStatusLane({
onActivate,
onSelectionGesture,
onContextMenuSelect,
onAssignWorkspaceStatus,
onCreateWorktree,
onColumnResizeStart,
onColumnResizeKeyDown
@ -166,6 +173,7 @@ export default function WorkspaceKanbanStatusLane({
onActivate={onActivate}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
/>
)
})}

View File

@ -35,6 +35,7 @@ import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-revi
import type {
GitHubWorkItem,
Worktree,
WorkspaceStatus,
Repo,
IssueInfo,
LinearIssue
@ -133,6 +134,7 @@ type WorktreeCardProps = {
event: React.MouseEvent<HTMLElement>,
worktree: Worktree
) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onCardDragStart?: (
event: React.DragEvent<HTMLDivElement>,
worktreeId: string,
@ -216,6 +218,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
onImmediateActivate,
onSelectionGesture,
onContextMenuSelect,
onAssignWorkspaceStatus,
onCardDragStart,
onCardDragEnd,
nativeDragEnabled = true,
@ -1905,6 +1908,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree={worktree}
selectedWorktrees={selectedWorktrees}
onContextMenuSelect={handleContextMenuSelect}
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
>
{cardBody}
</WorktreeContextMenu>

View File

@ -11,9 +11,10 @@ import {
getWorktreeParentPickerLabel,
hasWorktreeParentLink,
isWorktreeParentPickerDisabled,
planWorkspaceStatusAssignment,
selectMenuScopedMap
} from './WorktreeContextMenu'
import type { Worktree, WorktreeLineage } from '../../../../shared/types'
import type { Worktree, WorktreeLineage, WorkspaceStatusDefinition } from '../../../../shared/types'
describe('selectMenuScopedMap (delete-teardown re-render guard)', () => {
// Why: the closed menu wrapper must stay inert to delete teardown's high-churn
@ -234,3 +235,49 @@ describe('project removal from workspace context menus', () => {
expect(isContextWorktreeDeletable({ isMainWorktree: false }, null)).toBe(false)
})
})
describe('planWorkspaceStatusAssignment (context-menu "Move to Status" routing)', () => {
// Why: this is the exact branch #10175 regressed on — the board must funnel
// through the Linear-sync callback, the sidebar list must stay local-only. A
// silent flip of either branch re-introduces the bug, so pin both here.
const statuses: WorkspaceStatusDefinition[] = [
{ id: 'todo', label: 'Todo' },
{ id: 'in-review', label: 'In review' }
]
const wt = (id: string, workspaceStatus: string): Worktree =>
({ id, workspaceStatus }) as Worktree
it('routes to board Linear-sync with ALL selected ids when the board wired a callback', () => {
// The board path forwards every id; moveWorktreesToStatus filters no-ops downstream.
expect(
planWorkspaceStatusAssignment(
[wt('a', 'todo'), wt('b', 'in-review')],
'in-review',
statuses,
true
)
).toEqual({ kind: 'board-sync', worktreeIds: ['a', 'b'] })
})
it('falls back to local-only writes of only status-changed worktrees off the board', () => {
expect(
planWorkspaceStatusAssignment(
[wt('a', 'todo'), wt('b', 'in-review')],
'in-review',
statuses,
false
)
).toEqual({ kind: 'local-only', localWriteIds: ['a'] })
})
it('writes nothing on the local-only path when every worktree already has the target status', () => {
expect(
planWorkspaceStatusAssignment(
[wt('a', 'in-review'), wt('b', 'in-review')],
'in-review',
statuses,
false
)
).toEqual({ kind: 'local-only', localWriteIds: [] })
})
})

View File

@ -35,7 +35,12 @@ import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import { useAllWorktrees, useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
import { cn } from '@/lib/utils'
import type { Repo, Worktree } from '../../../../shared/types'
import type {
Repo,
Worktree,
WorkspaceStatus,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
import { runSleepWorktrees } from './sleep-worktree-flow'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
@ -65,6 +70,7 @@ type Props = {
contentClassName?: string
selectedWorktrees?: readonly Worktree[]
onContextMenuSelect?: (event: React.MouseEvent<HTMLElement>) => readonly Worktree[]
onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void
onOpenChange?: (open: boolean) => void
}
@ -278,12 +284,36 @@ function preserveDeleteSiblingPosition(scope: HTMLElement | null): () => void {
}
}
export type WorkspaceStatusAssignmentPlan =
| { readonly kind: 'board-sync'; readonly worktreeIds: readonly string[] }
| { readonly kind: 'local-only'; readonly localWriteIds: readonly string[] }
// Why: the context-menu "Move to Status" routes to the board's local-first +
// Linear-sync path when the board wired a callback, else a local-only write of
// only the status-changed worktrees. Extracted pure so the routing and the
// no-op filter stay unit-testable without opening the Radix menu.
export function planWorkspaceStatusAssignment(
worktrees: readonly Worktree[],
status: WorkspaceStatus,
workspaceStatuses: readonly WorkspaceStatusDefinition[],
boardSyncEnabled: boolean
): WorkspaceStatusAssignmentPlan {
if (boardSyncEnabled) {
return { kind: 'board-sync', worktreeIds: worktrees.map((item) => item.id) }
}
const localWriteIds = worktrees
.filter((item) => getWorkspaceStatus(item, workspaceStatuses) !== status)
.map((item) => item.id)
return { kind: 'local-only', localWriteIds }
}
const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
worktree,
children,
contentClassName,
selectedWorktrees,
onContextMenuSelect,
onAssignWorkspaceStatus,
onOpenChange
}: Props) {
const defaultSelectedWorktrees = useMemo(() => [worktree], [worktree])
@ -504,15 +534,29 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const handleAssignWorkspaceStatus = useCallback(
(status: string) => {
setMenuOpenState(false)
const plan = planWorkspaceStatusAssignment(
activeContextWorktrees,
status,
workspaceStatuses,
Boolean(onAssignWorkspaceStatus)
)
if (plan.kind === 'board-sync') {
onAssignWorkspaceStatus?.(plan.worktreeIds, status)
return
}
// Why: outside the workspace board (e.g. the sidebar list) status changes
// are local-only; Linear sync is scoped to board moves like drag-and-drop.
void Promise.all(
activeContextWorktrees.map((item) =>
getWorkspaceStatus(item, workspaceStatuses) === status
? Promise.resolve()
: updateWorktreeMeta(item.id, { workspaceStatus: status })
)
plan.localWriteIds.map((id) => updateWorktreeMeta(id, { workspaceStatus: status }))
)
},
[activeContextWorktrees, setMenuOpenState, updateWorktreeMeta, workspaceStatuses]
[
activeContextWorktrees,
onAssignWorkspaceStatus,
setMenuOpenState,
updateWorktreeMeta,
workspaceStatuses
]
)
const handleRename = useCallback(() => {