From 0bb755151ce8930cfe4ab189f5b30164215afc3c Mon Sep 17 00:00:00 2001 From: ElNelyo Date: Fri, 24 Jul 2026 04:23:46 +0200 Subject: [PATCH] fix(workspace-board): sync Linear on context-menu Move to Status (#10176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --------- Co-authored-by: ElNelyo Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../sidebar/WorkspaceKanbanCard.tsx | 7 ++- ...paceKanbanDrawer.task-status-sync.test.tsx | 56 +++++++++++++++++- .../sidebar/WorkspaceKanbanDrawer.tsx | 25 ++++++++ .../sidebar/WorkspaceKanbanLaneGrid.tsx | 3 + .../sidebar/WorkspaceKanbanStatusLane.tsx | 10 +++- .../src/components/sidebar/WorktreeCard.tsx | 4 ++ .../sidebar/WorktreeContextMenu.test.ts | 49 +++++++++++++++- .../sidebar/WorktreeContextMenu.tsx | 58 ++++++++++++++++--- 8 files changed, 198 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx index 5df17c4a3..601bef497 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanCard.tsx @@ -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, 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} /> ) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx index a5126be15..25ae48868 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx @@ -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: () =>
+ default: ({ + onAssignWorkspaceStatus + }: { + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: string) => void + }) => { + laneAssignStatusState.current = onAssignWorkspaceStatus ?? null + return
+ } })) 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() diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 9b73e8e79..1809a63eb 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -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>() + 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} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx index 777fd1fa2..889b3cd3b 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanLaneGrid.tsx @@ -27,6 +27,7 @@ type WorkspaceKanbanLaneGridProps = { event: React.MouseEvent, worktree: Worktree ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void onCreateWorktree: (statusId: string) => void onColumnResizeStart: (event: React.PointerEvent) => void onColumnResizeKeyDown: (event: React.KeyboardEvent) => 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} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index 033350a5f..c4016cbbb 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -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, worktree: Worktree ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void onCreateWorktree: (statusId: string) => void onColumnResizeStart: (event: React.PointerEvent) => void onColumnResizeKeyDown: (event: React.KeyboardEvent) => 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} /> ) })} diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index dd181ceb9..b50de019b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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, worktree: Worktree ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void onCardDragStart?: ( event: React.DragEvent, 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} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index f036894f8..161694086 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -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: [] }) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 5a67e5450..e64a79a7e 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -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) => 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(() => {