diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f8103a1d3b9..e42aaa50128 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -4618,6 +4618,24 @@ describe('Store', () => { expect(store.getUI().sortBy).toBe('recent') }) + it('defaults workspace board task status sync off and normalizes persisted values', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { syncTaskStatusFromWorkspaceBoard: 'yes' }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(false) + + store.updateUI({ syncTaskStatusFromWorkspaceBoard: true }) + expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true) + }) + it('repairs the known-bad reordered default workspace statuses once on load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7c6f03be33c..19e6fd3ec4d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4575,6 +4575,7 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), + syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds @@ -4647,6 +4648,10 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + syncTaskStatusFromWorkspaceBoard: + sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined + ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true + : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 1dc28f35082..04318f51c39 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -179,6 +179,7 @@ const UiUpdate = z workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(), workspaceBoardOpacity: z.number().finite().optional(), workspaceBoardColumnWidth: z.number().finite().optional(), + syncTaskStatusFromWorkspaceBoard: z.boolean().optional(), _workspaceStatusesDefaultOrderMigrated: z.boolean().optional(), _workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(), _workspaceStatusesDefaultVisualsMigrated: z.boolean().optional(), diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx index 27c77d1fe5b..ce8603dbd42 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.test.tsx @@ -1,8 +1,8 @@ import type { ComponentProps } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { TooltipProvider } from '@/components/ui/tooltip' +import { getDefaultOnboardingState, getDefaultSettings } from '../../../../shared/constants' import { useAppStore } from '@/store' import OnboardingFlow from './OnboardingFlow' import { ONBOARDING_SKIP_CONFIRMATION_COPY } from './OnboardingSkipConfirmationDialog' 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 new file mode 100644 index 00000000000..d0862b78e32 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.task-status-sync.test.tsx @@ -0,0 +1,382 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { Repo, Worktree } from '../../../../shared/types' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' +import type * as WorkspaceBoardTaskStatusSync from './workspace-board-task-status-sync' + +type PointerDragParams = { + onDropWorktreesInStatus: (args: { + worktreeIds: readonly string[] + status: string + dropIndex: number + }) => void + onPinWorktrees: (worktreeIds: readonly string[]) => void +} + +type DocumentDropCapture = { + onMoveWorktreeToStatus: (worktreeId: string, status: string) => void + onPinWorktree: (worktreeId: string) => void + options?: { + onMoveWorktreesToStatus?: (worktreeIds: readonly string[], status: string) => void + onPinWorktrees?: (worktreeIds: readonly string[]) => void + } +} + +const { + syncWorkspaceBoardTaskStatusesMock, + toastErrorMock, + toastWarningMock, + pointerDragState, + documentDropState +} = vi.hoisted(() => ({ + syncWorkspaceBoardTaskStatusesMock: vi.fn(() => + Promise.resolve({ + updated: 1, + skipped: 0, + failed: 0, + messages: [] as WorkspaceBoardTaskStatusSync.WorkspaceBoardTaskStatusSyncMessage[] + }) + ), + toastErrorMock: vi.fn(), + toastWarningMock: vi.fn(), + pointerDragState: { current: null as PointerDragParams | null }, + documentDropState: { current: null as DocumentDropCapture | null } +})) + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('sonner', () => ({ + toast: { + error: toastErrorMock, + warning: toastWarningMock + } +})) + +vi.mock('@/components/ui/sheet', () => ({ + Sheet: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ) +})) + +vi.mock('./WorkspaceKanbanDrawerHeader', () => ({ + default: () =>
+})) + +vi.mock('./WorkspaceKanbanLaneGrid', () => ({ + default: () =>
+})) + +vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({ + default: React.forwardRef((_, ref) =>
) +})) + +vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ + default: () =>
+})) + +vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({ + useVisibleWorkspaceKanbanWorktreeIds: ({ allWorktrees }: { allWorktrees: readonly Worktree[] }) => + new Set(allWorktrees.map((worktree) => worktree.id)) +})) + +vi.mock('./use-workspace-kanban-selection', () => ({ + useWorkspaceKanbanSelection: () => ({ + selectedWorktreeIds: new Set(), + selectedWorktrees: [], + selectionAnchorId: null, + updateSelectionForGesture: vi.fn(), + updateSelectionForArea: vi.fn(), + clearSelection: vi.fn(), + selectForContextMenu: vi.fn(() => []) + }) +})) + +vi.mock('./use-workspace-kanban-area-selection', () => ({ + useWorkspaceKanbanAreaSelection: () => ({ + handleAreaSelectionPointerDown: vi.fn() + }) +})) + +vi.mock('./use-workspace-kanban-column-resize', () => ({ + useWorkspaceKanbanColumnResize: () => ({ + columnWidth: 308, + isResizingColumn: false, + onColumnResizeStart: vi.fn(), + onColumnResizeKeyDown: vi.fn() + }) +})) + +vi.mock('./use-workspace-kanban-create-worktree', () => ({ + useWorkspaceKanbanCreateWorktree: () => ({ + canCreateWorktree: true, + createWorktreeForStatus: vi.fn() + }) +})) + +vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({ + useWorkspaceKanbanShiftWheelScroll: vi.fn() +})) + +vi.mock('./use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: vi.fn() +})) + +vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({ + useContextualTour: vi.fn() +})) + +vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({ + useWorkspaceKanbanCardPointerDrag: (params: PointerDragParams) => { + pointerDragState.current = params + return { + isPointerDragActiveRef: { current: false }, + onCardPointerDownCapture: vi.fn() + } + } +})) + +vi.mock('./use-workspace-status-drop', () => ({ + useWorkspaceStatusDocumentDrop: ( + _containerRef: unknown, + onMoveWorktreeToStatus: DocumentDropCapture['onMoveWorktreeToStatus'], + onPinWorktree: DocumentDropCapture['onPinWorktree'], + _onDragFinish: () => void, + _enabled: boolean, + options?: DocumentDropCapture['options'] + ) => { + documentDropState.current = { onMoveWorktreeToStatus, onPinWorktree, options } + } +})) + +vi.mock('./workspace-board-task-status-sync', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + syncWorkspaceBoardTaskStatuses: syncWorkspaceBoardTaskStatusesMock + } +}) + +let container: HTMLDivElement +let root: Root +let consoleInfoSpy: ReturnType +let consoleWarnSpy: ReturnType + +const statuses = [ + { id: 'todo', label: 'Todo' }, + { id: 'in-review', label: 'In review' } +] + +function worktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-a::/worktree-a', + repoId: 'repo-a', + displayName: 'Worktree A', + path: '/worktree-a', + branch: 'feature/a', + baseBranch: 'main', + isPinned: false, + sortOrder: 1, + lastActivityAt: 1, + workspaceStatus: 'todo', + linkedLinearIssue: 'ORC-1', + linkedLinearIssueWorkspaceId: 'workspace-1', + ...overrides + } as Worktree +} + +function repo(overrides: Partial = {}): Repo { + return { + id: 'repo-a', + path: '/repo-a', + name: 'repo-a', + connectionId: null, + executionHostId: 'runtime:owner-env', + ...overrides + } as Repo +} + +function renderDrawer(item: Worktree, enabled = true): void { + const updateWorktreeMeta = vi.fn() + const updateWorktreesMeta = vi.fn() + const recordFeatureInteraction = vi.fn() + useAppStore.setState({ + repos: [repo()], + worktreesByRepo: { 'repo-a': [item] }, + activeWorktreeId: item.id, + workspaceStatuses: statuses, + syncTaskStatusFromWorkspaceBoard: enabled, + setSyncTaskStatusFromWorkspaceBoard: vi.fn(), + workspaceBoardColumnWidth: 308, + sidebarOpen: true, + sidebarWidth: 280, + sortBy: 'manual', + updateWorktreeMeta, + updateWorktreesMeta, + getKnownWorktreeById: (worktreeId: string) => (worktreeId === item.id ? item : undefined), + recordFeatureInteraction + }) + + act(() => { + root.render( + + ) + }) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + pointerDragState.current = null + documentDropState.current = null + syncWorkspaceBoardTaskStatusesMock.mockClear() + toastErrorMock.mockClear() + toastWarningMock.mockClear() + consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + act(() => { + root.unmount() + }) + consoleInfoSpy.mockRestore() + consoleWarnSpy.mockRestore() + container.remove() +}) + +describe('WorkspaceKanbanDrawer task status sync wiring', () => { + it('syncs Linear after a document-drop status move when the setting is enabled', () => { + const item = worktree() + renderDrawer(item) + + act(() => { + documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review') + }) + + expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeIds: [item.id], + targetStatus: { id: 'in-review', label: 'In review' }, + getSettingsForWorktree: expect.any(Function), + getLatestWorkspaceStatus: expect.any(Function) + }) + ) + }) + + it('does not sync when a document-drop status move happens while disabled', () => { + const item = worktree() + renderDrawer(item, false) + + act(() => { + documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review') + }) + + expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled() + }) + + it('syncs pointer-drop status changes through the board callback', () => { + const item = worktree() + renderDrawer(item) + + act(() => { + pointerDragState.current?.onDropWorktreesInStatus({ + worktreeIds: [item.id], + status: 'in-review', + dropIndex: 0 + }) + }) + + expect(syncWorkspaceBoardTaskStatusesMock).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeIds: [item.id], + targetStatus: { id: 'in-review', label: 'In review' } + }) + ) + }) + + it('does not sync manual-order-only drops that keep the same board status', () => { + const item = worktree({ workspaceStatus: 'in-review' }) + renderDrawer(item) + + act(() => { + pointerDragState.current?.onDropWorktreesInStatus({ + worktreeIds: [item.id], + status: 'in-review', + dropIndex: 0 + }) + }) + + expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled() + }) + + it('does not sync pin-only paths', () => { + const item = worktree() + renderDrawer(item) + + act(() => { + pointerDragState.current?.onPinWorktrees([item.id]) + documentDropState.current?.onPinWorktree(item.id) + documentDropState.current?.options?.onPinWorktrees?.([item.id]) + }) + + expect(syncWorkspaceBoardTaskStatusesMock).not.toHaveBeenCalled() + }) + + it('shows a warning toast when task status sync is skipped with a message', async () => { + syncWorkspaceBoardTaskStatusesMock.mockResolvedValueOnce({ + updated: 0, + skipped: 1, + failed: 0, + messages: [{ kind: 'missing-workflow-state', statusLabel: 'In review' }] + }) + const item = worktree() + renderDrawer(item) + + await act(async () => { + documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review') + await Promise.resolve() + }) + + expect(toastWarningMock).toHaveBeenCalledWith( + 'Task status sync skipped', + expect.objectContaining({ + description: '1 skipped. No matching Linear workflow state for In review.' + }) + ) + }) + + it('shows an error toast when task status sync unexpectedly rejects', async () => { + syncWorkspaceBoardTaskStatusesMock.mockRejectedValueOnce(new Error('Runtime disconnected')) + const item = worktree() + renderDrawer(item) + + await act(async () => { + documentDropState.current?.onMoveWorktreeToStatus(item.id, 'in-review') + await Promise.resolve() + }) + + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Workspace board task status sync failed', + expect.any(Error) + ) + expect(toastErrorMock).toHaveBeenCalledWith( + 'Task status sync failed', + expect.objectContaining({ + description: '1 failed. Task status sync could not finish.' + }) + ) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 4eeea8a6db8..7aa81b98091 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { Sheet, SheetContent } from '@/components/ui/sheet' +import { toast } from 'sonner' import WorkspaceKanbanAreaSelectionOverlay from './WorkspaceKanbanAreaSelectionOverlay' import WorkspaceKanbanDrawerHeader from './WorkspaceKanbanDrawerHeader' import WorkspaceKanbanLaneGrid from './WorkspaceKanbanLaneGrid' @@ -24,7 +25,14 @@ import { useWorkspaceKanbanOutsideDismiss } from './use-workspace-kanban-outside-dismiss' import { useVisibleWorkspaceKanbanWorktreeIds } from './use-visible-workspace-kanban-worktree-ids' +import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner' import { groupWorkspaceKanbanWorktrees } from './workspace-kanban-worktree-groups' +import { + getWorkspaceBoardTaskStatusSyncRequest, + syncWorkspaceBoardTaskStatuses, + type WorkspaceBoardTaskStatusSyncMessage, + type WorkspaceBoardTaskStatusSyncResult +} from './workspace-board-task-status-sync' import { buildManualOrderUpdatesForGroupDrop, shouldWriteManualOrderForGroupDrop, @@ -33,6 +41,7 @@ import { import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types' import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' +import { translate } from '@/i18n/i18n' type WorkspaceKanbanDrawerProps = { leftSidebarStyle?: React.CSSProperties @@ -43,6 +52,80 @@ type WorkspaceKanbanDrawerProps = { onMenuOpenChange: (open: boolean) => void } +function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string { + switch (message.kind) { + case 'issue-read-failed': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.c1d2e3f4a5', + 'Linear issue {{value0}} could not be read.', + { value0: message.issueIdentifier } + ) + case 'missing-workflow-state': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.d2e3f4a5b6', + 'No matching Linear workflow state for {{value0}}.', + { value0: message.statusLabel } + ) + case 'ambiguous-workflow-state': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.e3f4a5b6c7', + 'Multiple Linear workflow states match {{value0}}.', + { value0: message.statusLabel } + ) + case 'update-failed': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.f4a5b6c7d8', + 'Could not update Linear issue {{value0}}.', + { value0: message.issueIdentifier } + ) + case 'provider-error': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.a5b6c7d8e9', + 'Could not sync Linear issue {{value0}}.', + { value0: message.issueIdentifier } + ) + case 'unexpected-error': + return translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.b6c7d8e9f0', + 'Task status sync could not finish.' + ) + } +} + +function formatTaskStatusSyncDescription(result: WorkspaceBoardTaskStatusSyncResult): string { + const counts = [ + result.updated > 0 + ? translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.c7d8e9f0a1', + '{{value0}} updated', + { + value0: result.updated + } + ) + : null, + result.skipped > 0 + ? translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.d8e9f0a1b2', + '{{value0}} skipped', + { + value0: result.skipped + } + ) + : null, + result.failed > 0 + ? translate('auto.components.sidebar.WorkspaceKanbanDrawer.e9f0a1b2c3', '{{value0}} failed', { + value0: result.failed + }) + : null + ].filter((part): part is string => part !== null) + return [ + counts.join(', '), + result.messages[0] ? formatTaskStatusSyncMessage(result.messages[0]) : null + ] + .filter(Boolean) + .join('. ') +} + export default function WorkspaceKanbanDrawer({ leftSidebarStyle, open, @@ -58,6 +141,10 @@ export default function WorkspaceKanbanDrawer({ const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta) const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses) + const syncTaskStatusFromWorkspaceBoard = useAppStore((s) => s.syncTaskStatusFromWorkspaceBoard) + const setSyncTaskStatusFromWorkspaceBoard = useAppStore( + (s) => s.setSyncTaskStatusFromWorkspaceBoard + ) const workspaceBoardColumnWidth = useAppStore((s) => s.workspaceBoardColumnWidth) const setWorkspaceBoardColumnWidth = useAppStore((s) => s.setWorkspaceBoardColumnWidth) const sortBy = useAppStore((s) => s.sortBy) @@ -117,6 +204,73 @@ export default function WorkspaceKanbanDrawer({ }) const { columnWidth, isResizingColumn, onColumnResizeStart, onColumnResizeKeyDown } = useWorkspaceKanbanColumnResize(workspaceBoardColumnWidth, setWorkspaceBoardColumnWidth) + const handleTaskStatusSyncResult = useCallback((result: WorkspaceBoardTaskStatusSyncResult) => { + if (result.failed === 0 && result.messages.length === 0) { + return + } + const description = formatTaskStatusSyncDescription(result) + if (result.failed > 0) { + toast.error( + translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.1975a4e480', + 'Task status sync failed' + ), + { description } + ) + return + } + toast.warning( + translate( + 'auto.components.sidebar.WorkspaceKanbanDrawer.e02b0d92ff', + 'Task status sync skipped' + ), + { description } + ) + }, []) + const maybeSyncWorkspaceBoardTaskStatuses = useCallback( + (worktreeIds: readonly string[], status: WorkspaceStatus) => { + const request = getWorkspaceBoardTaskStatusSyncRequest({ + enabled: syncTaskStatusFromWorkspaceBoard, + worktreeIds, + status, + worktreesById: worktreeById, + workspaceStatuses + }) + if (!request) { + return + } + void syncWorkspaceBoardTaskStatuses({ + worktreeIds: request.worktreeIds, + targetStatus: request.targetStatus, + worktreesById: worktreeById, + getSettingsForWorktree: (worktreeId) => + getSettingsForWorktreeRuntimeOwner(useAppStore.getState(), worktreeId), + getLatestWorkspaceStatus: (worktreeId) => + useAppStore.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus + }) + .then((result) => { + if (result.updated > 0 || result.failed > 0 || result.messages.length > 0) { + console.info('Workspace board task status sync result', result) + } + handleTaskStatusSyncResult(result) + }) + .catch((error: unknown) => { + console.warn('Workspace board task status sync failed', error) + handleTaskStatusSyncResult({ + updated: 0, + skipped: 0, + failed: request.worktreeIds.length, + messages: [ + { + kind: 'unexpected-error', + detail: error instanceof Error ? error.message : undefined + } + ] + }) + }) + }, + [handleTaskStatusSyncResult, syncTaskStatusFromWorkspaceBoard, workspaceStatuses, worktreeById] + ) const moveWorktreeToStatus = useCallback( (worktreeId: string, status: WorkspaceStatus) => { const current = worktreeById.get(worktreeId) @@ -125,8 +279,9 @@ export default function WorkspaceKanbanDrawer({ } useAppStore.getState().recordFeatureInteraction('workspace-board-actions') void updateWorktreeMeta(worktreeId, { workspaceStatus: status }) + maybeSyncWorkspaceBoardTaskStatuses([worktreeId], status) }, - [updateWorktreeMeta, workspaceStatuses, worktreeById] + [maybeSyncWorkspaceBoardTaskStatuses, updateWorktreeMeta, workspaceStatuses, worktreeById] ) const getSourceStatusKeys = useCallback( (worktreeIds: readonly string[]): WorkspaceStatus[] => @@ -217,9 +372,11 @@ export default function WorkspaceKanbanDrawer({ } useAppStore.getState().recordFeatureInteraction('workspace-board-actions') void updateWorktreesMeta(updates) + maybeSyncWorkspaceBoardTaskStatuses(args.worktreeIds, args.status) }, [ boardDragGroups, + maybeSyncWorkspaceBoardTaskStatuses, setSortBy, shouldWriteDropManualOrder, updateWorktreesMeta, @@ -555,6 +712,8 @@ export default function WorkspaceKanbanDrawer({ void): React.ReactElement { return WorkspaceKanbanDrawerHeader({ selectedCount: 0, workspaceStatuses: statuses, + syncTaskStatusFromWorkspaceBoard: false, + onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(), onRenameStatus: vi.fn(), onChangeStatusColor: vi.fn(), onChangeStatusIcon: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx index 70a91a20bf7..723f373eab6 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawerHeader.tsx @@ -10,6 +10,8 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanDrawerHeaderProps = { selectedCount: number workspaceStatuses: readonly WorkspaceStatusDefinition[] + syncTaskStatusFromWorkspaceBoard: boolean + onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void onRenameStatus: (statusId: string, label: string) => void onChangeStatusColor: (statusId: string, color: string) => void onChangeStatusIcon: (statusId: string, icon: string) => void @@ -23,6 +25,8 @@ type WorkspaceKanbanDrawerHeaderProps = { export default function WorkspaceKanbanDrawerHeader({ selectedCount, workspaceStatuses, + syncTaskStatusFromWorkspaceBoard, + onSyncTaskStatusFromWorkspaceBoardChange, onRenameStatus, onChangeStatusColor, onChangeStatusIcon, @@ -69,6 +73,8 @@ export default function WorkspaceKanbanDrawerHeader({ /> ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => {children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + ) + }) +} + +afterEach(() => { + act(() => { + root?.unmount() + }) + root = null + container?.remove() + container = null + document.body.innerHTML = '' +}) + +describe('WorkspaceKanbanSettingsMenu', () => { + it('renders the task status sync switch and forwards changes', async () => { + const onChange = vi.fn() + renderMenu(onChange) + + const toggle = document.querySelector( + 'button[role="switch"][aria-label="Sync board and issue status"]' + ) + expect(toggle).not.toBeNull() + expect(toggle?.getAttribute('aria-checked')).toBe('false') + + await act(async () => { + toggle?.click() + await Promise.resolve() + }) + + expect(onChange).toHaveBeenCalledWith(true) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx index bd435b5a237..a7fd4693143 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' +import { SettingsSwitch } from '../settings/SettingsFormControls' import type { WorkspaceStatusDefinition } from '../../../../shared/types' import { getWorkspaceStatusVisualMeta } from './workspace-status' import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover' @@ -16,6 +17,8 @@ import { translate } from '@/i18n/i18n' type WorkspaceKanbanSettingsMenuProps = { workspaceStatuses: readonly WorkspaceStatusDefinition[] + syncTaskStatusFromWorkspaceBoard: boolean + onSyncTaskStatusFromWorkspaceBoardChange: (enabled: boolean) => void onRenameStatus: (statusId: string, label: string) => void onChangeStatusColor: (statusId: string, color: string) => void onChangeStatusIcon: (statusId: string, icon: string) => void @@ -26,6 +29,8 @@ type WorkspaceKanbanSettingsMenuProps = { export default function WorkspaceKanbanSettingsMenu({ workspaceStatuses, + syncTaskStatusFromWorkspaceBoard, + onSyncTaskStatusFromWorkspaceBoardChange, onRenameStatus, onChangeStatusColor, onChangeStatusIcon, @@ -74,6 +79,34 @@ export default function WorkspaceKanbanSettingsMenu({ } }} > +
+
+ + + {translate( + 'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f', + 'Sync board and issue status' + )} + + + {translate( + 'auto.components.sidebar.WorkspaceKanbanSettingsMenu.4c2eaa78cc', + 'Moving a linked workspace updates its Linear issue status when a matching workflow state exists.' + )} + + + + onSyncTaskStatusFromWorkspaceBoardChange(!syncTaskStatusFromWorkspaceBoard) + } + ariaLabel={translate( + 'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f', + 'Sync board and issue status' + )} + /> +
+
{translate('auto.components.sidebar.WorkspaceKanbanSettingsMenu.395e541d5d', 'Statuses')} diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts new file mode 100644 index 00000000000..17c0c2d2f14 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts @@ -0,0 +1,431 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + LinearIssue, + LinearWorkflowState, + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/types' +import type { LinearMutationResult } from '@/runtime/runtime-linear-client' +import { + getWorkspaceBoardTaskStatusSyncRequest, + syncWorkspaceBoardTaskStatuses +} from './workspace-board-task-status-sync' + +function issue(overrides: Partial = {}): LinearIssue { + return { + id: 'issue-1', + identifier: 'ORC-1', + title: 'Sync the board', + description: '', + url: 'https://linear.app/orca/issue/ORC-1/sync-the-board', + state: { name: 'Todo', type: 'unstarted', color: '#999' }, + team: { id: 'team-1', name: 'Orca', key: 'ORC' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '2026-06-15T00:00:00.000Z', + ...overrides + } +} + +function state(overrides: Partial = {}): LinearWorkflowState { + return { + id: 'state-review', + name: 'In review', + type: 'started', + color: '#111', + position: 1, + ...overrides + } +} + +function worktree(overrides: Partial = {}): Worktree { + return { + id: 'repo::/worktree', + linkedLinearIssue: 'ORC-1', + linkedLinearIssueWorkspaceId: 'workspace-1', + ...overrides + } as Worktree +} + +function targetStatus( + overrides: Partial = {} +): WorkspaceStatusDefinition { + return { id: 'in-review', label: 'In review', ...overrides } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + return { promise, resolve, reject } +} + +async function flushMicrotasks(count = 5): Promise { + for (let i = 0; i < count; i += 1) { + await Promise.resolve() + } +} + +function setup(overrides: Partial = {}) { + const target = targetStatus() + const item = worktree(overrides) + const getIssue = vi.fn().mockResolvedValue(issue()) + const teamStates = vi.fn().mockResolvedValue([state()]) + const updateIssue = vi.fn<() => Promise>().mockResolvedValue({ ok: true }) + + return { + item, + target, + getIssue, + teamStates, + updateIssue, + run: () => + syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: target, + worktreesById: new Map([[item.id, item]]), + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + getLatestWorkspaceStatus: () => target.id, + deps: { getIssue, teamStates, updateIssue } + }) + } +} + +describe('syncWorkspaceBoardTaskStatuses', () => { + it('updates Linear when exactly one workflow state matches the board status', async () => { + const { run, getIssue, teamStates, updateIssue } = setup() + + await expect(run()).resolves.toEqual({ updated: 1, skipped: 0, failed: 0, messages: [] }) + + expect(getIssue).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'runtime-1' }, + 'ORC-1', + 'workspace-1' + ) + expect(teamStates).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'runtime-1' }, + 'team-1', + 'workspace-1' + ) + expect(updateIssue).toHaveBeenCalledWith( + { activeRuntimeEnvironmentId: 'runtime-1' }, + 'issue-1', + { stateId: 'state-review' }, + 'workspace-1' + ) + }) + + it('uses the fetched issue workspace for state reads and writes when the link lacks one', async () => { + const { item, target, getIssue, teamStates, updateIssue } = setup({ + linkedLinearIssueWorkspaceId: null + }) + getIssue.mockResolvedValueOnce(issue({ workspaceId: 'issue-workspace' })) + + await syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: target, + worktreesById: new Map([[item.id, item]]), + settings: null, + getLatestWorkspaceStatus: () => target.id, + deps: { getIssue, teamStates, updateIssue } + }) + + expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', undefined) + expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'issue-workspace') + expect(updateIssue).toHaveBeenCalledWith( + null, + 'issue-1', + { stateId: 'state-review' }, + 'issue-workspace' + ) + }) + + it('routes each Linear update through the moved worktree owner settings', async () => { + const target = targetStatus() + const first = worktree({ id: 'repo-a::/worktree-a', linkedLinearIssue: 'ORC-1' }) + const second = worktree({ id: 'repo-b::/worktree-b', linkedLinearIssue: 'ORC-2' }) + const getIssue = vi + .fn() + .mockResolvedValueOnce(issue({ id: 'issue-1', identifier: 'ORC-1' })) + .mockResolvedValueOnce(issue({ id: 'issue-2', identifier: 'ORC-2' })) + const teamStates = vi.fn().mockResolvedValue([state()]) + const updateIssue = vi.fn<() => Promise>().mockResolvedValue({ ok: true }) + const getSettingsForWorktree = vi.fn((worktreeId: string) => ({ + activeRuntimeEnvironmentId: worktreeId.startsWith('repo-a') ? 'runtime-a' : 'runtime-b' + })) + + await syncWorkspaceBoardTaskStatuses({ + worktreeIds: [first.id, second.id], + targetStatus: target, + worktreesById: new Map([ + [first.id, first], + [second.id, second] + ]), + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + getSettingsForWorktree, + getLatestWorkspaceStatus: () => target.id, + deps: { getIssue, teamStates, updateIssue } + }) + + expect(getSettingsForWorktree).toHaveBeenCalledWith(first.id) + expect(getSettingsForWorktree).toHaveBeenCalledWith(second.id) + expect(getIssue).toHaveBeenNthCalledWith( + 1, + { activeRuntimeEnvironmentId: 'runtime-a' }, + 'ORC-1', + 'workspace-1' + ) + expect(getIssue).toHaveBeenNthCalledWith( + 2, + { activeRuntimeEnvironmentId: 'runtime-b' }, + 'ORC-2', + 'workspace-1' + ) + expect(updateIssue).toHaveBeenNthCalledWith( + 1, + { activeRuntimeEnvironmentId: 'runtime-a' }, + 'issue-1', + { stateId: 'state-review' }, + 'workspace-1' + ) + expect(updateIssue).toHaveBeenNthCalledWith( + 2, + { activeRuntimeEnvironmentId: 'runtime-b' }, + 'issue-2', + { stateId: 'state-review' }, + 'workspace-1' + ) + }) + + it('preserves null settings from the moved worktree resolver', async () => { + const { item, target, getIssue, teamStates, updateIssue } = setup() + const getSettingsForWorktree = vi.fn(() => null) + + await syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: target, + worktreesById: new Map([[item.id, item]]), + settings: { activeRuntimeEnvironmentId: 'focused-runtime' }, + getSettingsForWorktree, + getLatestWorkspaceStatus: () => target.id, + deps: { getIssue, teamStates, updateIssue } + }) + + expect(getSettingsForWorktree).toHaveBeenCalledWith(item.id) + expect(getIssue).toHaveBeenCalledWith(null, 'ORC-1', 'workspace-1') + expect(teamStates).toHaveBeenCalledWith(null, 'team-1', 'workspace-1') + expect(updateIssue).toHaveBeenCalledWith( + null, + 'issue-1', + { stateId: 'state-review' }, + 'workspace-1' + ) + }) + + it('skips worktrees without linked Linear issues', async () => { + const { item, target, getIssue, teamStates, updateIssue } = setup({ linkedLinearIssue: null }) + + const result = await syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: target, + worktreesById: new Map([[item.id, item]]), + settings: null, + getLatestWorkspaceStatus: () => target.id, + deps: { getIssue, teamStates, updateIssue } + }) + + expect(result).toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] }) + expect(getIssue).not.toHaveBeenCalled() + expect(teamStates).not.toHaveBeenCalled() + expect(updateIssue).not.toHaveBeenCalled() + }) + + it('skips when the Linear issue is already in the matching state', async () => { + const { run, getIssue, updateIssue } = setup() + getIssue.mockResolvedValueOnce( + issue({ state: { name: 'In review', type: 'started', color: '#111' } }) + ) + + await expect(run()).resolves.toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] }) + + expect(updateIssue).not.toHaveBeenCalled() + }) + + it('skips missing or ambiguous workflow state matches', async () => { + const missing = setup() + missing.teamStates.mockResolvedValueOnce([state({ name: 'Done' })]) + + await expect(missing.run()).resolves.toMatchObject({ + updated: 0, + skipped: 1, + failed: 0, + messages: [{ kind: 'missing-workflow-state', statusLabel: 'In review' }] + }) + expect(missing.updateIssue).not.toHaveBeenCalled() + + const ambiguous = setup() + ambiguous.teamStates.mockResolvedValueOnce([ + state({ id: 'state-1', name: 'In review' }), + state({ id: 'state-2', name: ' in REVIEW ' }) + ]) + + await expect(ambiguous.run()).resolves.toMatchObject({ + updated: 0, + skipped: 1, + failed: 0, + messages: [{ kind: 'ambiguous-workflow-state', statusLabel: 'In review' }] + }) + expect(ambiguous.updateIssue).not.toHaveBeenCalled() + }) + + it('skips stale async writes when the local workspace status changed again', async () => { + const { item, target, getIssue, teamStates, updateIssue } = setup() + + const result = await syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: target, + worktreesById: new Map([[item.id, item]]), + settings: null, + getLatestWorkspaceStatus: () => 'done', + deps: { getIssue, teamStates, updateIssue } + }) + + expect(result).toEqual({ updated: 0, skipped: 1, failed: 0, messages: [] }) + expect(updateIssue).not.toHaveBeenCalled() + }) + + it('serializes repeated moves for the same worktree so the latest status wins', async () => { + const item = worktree() + const firstUpdate = deferred() + const getIssue = vi.fn().mockResolvedValue(issue()) + const teamStates = vi + .fn() + .mockResolvedValueOnce([state()]) + .mockResolvedValueOnce([state({ id: 'state-done', name: 'Done', type: 'completed' })]) + const updateIssue = vi + .fn<() => Promise>() + .mockReturnValueOnce(firstUpdate.promise) + .mockResolvedValueOnce({ ok: true }) + + const firstSync = syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: targetStatus(), + worktreesById: new Map([[item.id, item]]), + settings: null, + getLatestWorkspaceStatus: () => 'in-review', + deps: { getIssue, teamStates, updateIssue } + }) + await flushMicrotasks() + expect(updateIssue).toHaveBeenCalledTimes(1) + + const secondSync = syncWorkspaceBoardTaskStatuses({ + worktreeIds: [item.id], + targetStatus: targetStatus({ id: 'done', label: 'Done' }), + worktreesById: new Map([[item.id, item]]), + settings: null, + getLatestWorkspaceStatus: () => 'done', + deps: { getIssue, teamStates, updateIssue } + }) + await flushMicrotasks() + expect(updateIssue).toHaveBeenCalledTimes(1) + + firstUpdate.resolve({ ok: true }) + await firstSync + await secondSync + + expect(updateIssue).toHaveBeenNthCalledWith( + 1, + null, + 'issue-1', + { stateId: 'state-review' }, + 'workspace-1' + ) + expect(updateIssue).toHaveBeenNthCalledWith( + 2, + null, + 'issue-1', + { stateId: 'state-done' }, + 'workspace-1' + ) + }) + + it('aggregates provider write failures without throwing', async () => { + const { run, updateIssue } = setup() + updateIssue.mockResolvedValueOnce({ ok: false, error: 'Linear is unavailable' }) + + await expect(run()).resolves.toEqual({ + updated: 0, + skipped: 0, + failed: 1, + messages: [ + { + kind: 'update-failed', + issueIdentifier: 'ORC-1', + detail: 'Linear is unavailable' + } + ] + }) + }) +}) + +describe('getWorkspaceBoardTaskStatusSyncRequest', () => { + const workspaceStatuses: WorkspaceStatusDefinition[] = [ + { id: 'todo', label: 'Todo' }, + { id: 'in-review', label: 'In review' } + ] + + it('builds a sync request for enabled status moves', () => { + const request = getWorkspaceBoardTaskStatusSyncRequest({ + enabled: true, + worktreeIds: ['repo::/a'], + status: 'in-review', + worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]), + workspaceStatuses + }) + + expect(request).toEqual({ + worktreeIds: ['repo::/a'], + targetStatus: { id: 'in-review', label: 'In review' } + }) + }) + + it('does not build a sync request while the board setting is disabled', () => { + expect( + getWorkspaceBoardTaskStatusSyncRequest({ + enabled: false, + worktreeIds: ['repo::/a'], + status: 'in-review', + worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]), + workspaceStatuses + }) + ).toBeNull() + }) + + it('skips same-status and duplicate ids so manual-order-only drops do not sync', () => { + expect( + getWorkspaceBoardTaskStatusSyncRequest({ + enabled: true, + worktreeIds: ['repo::/a', 'repo::/a'], + status: 'in-review', + worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'in-review' })]]), + workspaceStatuses + }) + ).toBeNull() + }) + + it('does not build a sync request without a board status target', () => { + expect( + getWorkspaceBoardTaskStatusSyncRequest({ + enabled: true, + worktreeIds: ['repo::/a'], + status: 'unknown-status', + worktreesById: new Map([['repo::/a', worktree({ workspaceStatus: 'todo' })]]), + workspaceStatuses + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts new file mode 100644 index 00000000000..15409ddcfe6 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts @@ -0,0 +1,271 @@ +import { + linearGetIssue, + linearTeamStates, + linearUpdateIssue, + type LinearMutationResult, + type RuntimeLinearSettings +} from '@/runtime/runtime-linear-client' +import type { + LinearIssue, + LinearWorkflowState, + WorkspaceStatus, + WorkspaceStatusDefinition, + Worktree +} from '../../../../shared/types' +import { getWorkspaceStatus } from '../../../../shared/workspace-statuses' + +export type WorkspaceBoardTaskStatusSyncResult = { + updated: number + skipped: number + failed: number + messages: WorkspaceBoardTaskStatusSyncMessage[] +} + +export type WorkspaceBoardTaskStatusSyncMessage = + | { kind: 'issue-read-failed'; issueIdentifier: string } + | { kind: 'missing-workflow-state'; statusLabel: string } + | { kind: 'ambiguous-workflow-state'; statusLabel: string } + | { kind: 'update-failed'; issueIdentifier: string; detail?: string } + | { kind: 'provider-error'; issueIdentifier: string; detail?: string } + | { kind: 'unexpected-error'; detail?: string } + +type WorkspaceBoardTaskStatusSyncDependencies = { + getIssue: typeof linearGetIssue + teamStates: typeof linearTeamStates + updateIssue: typeof linearUpdateIssue +} + +export type SyncWorkspaceBoardTaskStatusesArgs = { + worktreeIds: readonly string[] + targetStatus: WorkspaceStatusDefinition + worktreesById: ReadonlyMap< + string, + Pick + > + settings?: RuntimeLinearSettings + getSettingsForWorktree?: (worktreeId: string) => RuntimeLinearSettings + getLatestWorkspaceStatus: (worktreeId: string) => WorkspaceStatus | null | undefined + deps?: Partial +} + +export type WorkspaceBoardTaskStatusSyncRequest = { + worktreeIds: string[] + targetStatus: WorkspaceStatusDefinition +} + +export function getWorkspaceBoardTaskStatusSyncRequest(args: { + enabled: boolean + worktreeIds: readonly string[] + status: WorkspaceStatus + worktreesById: ReadonlyMap> + workspaceStatuses: readonly WorkspaceStatusDefinition[] +}): WorkspaceBoardTaskStatusSyncRequest | null { + if (!args.enabled || args.worktreeIds.length === 0) { + return null + } + const targetStatus = args.workspaceStatuses.find((item) => item.id === args.status) + if (!targetStatus) { + return null + } + const changedWorktreeIds = [...new Set(args.worktreeIds)].filter((worktreeId) => { + const worktree = args.worktreesById.get(worktreeId) + return worktree ? getWorkspaceStatus(worktree, args.workspaceStatuses) !== args.status : false + }) + if (changedWorktreeIds.length === 0) { + return null + } + return { worktreeIds: changedWorktreeIds, targetStatus } +} + +const defaultDeps: WorkspaceBoardTaskStatusSyncDependencies = { + getIssue: linearGetIssue, + teamStates: linearTeamStates, + updateIssue: linearUpdateIssue +} + +const worktreeSyncQueues = new Map>() + +function normalizeStateName(name: string): string { + return name.trim().toLowerCase() +} + +function matchingWorkflowStates( + states: readonly LinearWorkflowState[], + targetStatus: WorkspaceStatusDefinition +): LinearWorkflowState[] { + const targetName = normalizeStateName(targetStatus.label) + return states.filter((state) => normalizeStateName(state.name) === targetName) +} + +function getMessageKey(message: WorkspaceBoardTaskStatusSyncMessage): string { + return JSON.stringify(message) +} + +function addMessage( + result: WorkspaceBoardTaskStatusSyncResult, + message: WorkspaceBoardTaskStatusSyncMessage +): void { + const key = getMessageKey(message) + if (!result.messages.some((item) => getMessageKey(item) === key)) { + result.messages.push(message) + } +} + +function skipped( + result: WorkspaceBoardTaskStatusSyncResult, + message?: WorkspaceBoardTaskStatusSyncMessage +): WorkspaceBoardTaskStatusSyncResult { + result.skipped += 1 + if (message) { + addMessage(result, message) + } + return result +} + +function failed( + result: WorkspaceBoardTaskStatusSyncResult, + message: WorkspaceBoardTaskStatusSyncMessage +): WorkspaceBoardTaskStatusSyncResult { + result.failed += 1 + addMessage(result, message) + return result +} + +function isAlreadyInState(issue: LinearIssue, workflowState: LinearWorkflowState): boolean { + return ( + normalizeStateName(issue.state.name) === normalizeStateName(workflowState.name) && + issue.state.type === workflowState.type + ) +} + +function mergeResult( + aggregate: WorkspaceBoardTaskStatusSyncResult, + item: WorkspaceBoardTaskStatusSyncResult +): void { + aggregate.updated += item.updated + aggregate.skipped += item.skipped + aggregate.failed += item.failed + for (const message of item.messages) { + addMessage(aggregate, message) + } +} + +async function enqueueWorktreeSync( + worktreeId: string, + task: () => Promise +): Promise { + const previous = worktreeSyncQueues.get(worktreeId) ?? Promise.resolve() + const next = previous.catch(() => undefined).then(task) + const cleanup = next.finally(() => { + if (worktreeSyncQueues.get(worktreeId) === cleanup) { + worktreeSyncQueues.delete(worktreeId) + } + }) + worktreeSyncQueues.set(worktreeId, cleanup) + return next +} + +async function syncLinearWorktreeStatus( + args: SyncWorkspaceBoardTaskStatusesArgs, + worktreeId: string, + deps: WorkspaceBoardTaskStatusSyncDependencies +): Promise { + const result: WorkspaceBoardTaskStatusSyncResult = { + updated: 0, + skipped: 0, + failed: 0, + messages: [] + } + const worktree = args.worktreesById.get(worktreeId) + if (!worktree?.linkedLinearIssue) { + return skipped(result) + } + + const settings = args.getSettingsForWorktree + ? args.getSettingsForWorktree(worktreeId) + : args.settings + const linkedWorkspaceId = worktree.linkedLinearIssueWorkspaceId ?? undefined + + try { + const issue = await deps.getIssue(settings, worktree.linkedLinearIssue, linkedWorkspaceId) + if (!issue?.team?.id) { + return skipped(result, { + kind: 'issue-read-failed', + issueIdentifier: worktree.linkedLinearIssue + }) + } + + const workspaceId = linkedWorkspaceId ?? issue.workspaceId + const states = await deps.teamStates(settings, issue.team.id, workspaceId) + const matches = matchingWorkflowStates(states, args.targetStatus) + if (matches.length === 0) { + return skipped(result, { + kind: 'missing-workflow-state', + statusLabel: args.targetStatus.label + }) + } + if (matches.length > 1) { + return skipped(result, { + kind: 'ambiguous-workflow-state', + statusLabel: args.targetStatus.label + }) + } + + const [workflowState] = matches + if (isAlreadyInState(issue, workflowState)) { + return skipped(result) + } + + // Why: board moves are local-first; slow provider reads must not let an + // older board move overwrite a newer local status in Linear. + if (args.getLatestWorkspaceStatus(worktreeId) !== args.targetStatus.id) { + return skipped(result) + } + + const updateResult: LinearMutationResult = await deps.updateIssue( + settings, + issue.id, + { stateId: workflowState.id }, + workspaceId + ) + if (updateResult.ok === false) { + return failed(result, { + kind: 'update-failed', + issueIdentifier: issue.identifier, + detail: updateResult.error + }) + } + result.updated += 1 + return result + } catch (error) { + return failed(result, { + kind: 'provider-error', + issueIdentifier: worktree.linkedLinearIssue, + detail: error instanceof Error ? error.message : undefined + }) + } +} + +export async function syncWorkspaceBoardTaskStatuses( + args: SyncWorkspaceBoardTaskStatusesArgs +): Promise { + const deps = { ...defaultDeps, ...args.deps } + const aggregate: WorkspaceBoardTaskStatusSyncResult = { + updated: 0, + skipped: 0, + failed: 0, + messages: [] + } + + const uniqueIds = new Set(args.worktreeIds) + await Promise.all( + [...uniqueIds].map(async (worktreeId) => { + const item = await enqueueWorktreeSync(worktreeId, () => + syncLinearWorktreeStatus(args, worktreeId, deps) + ) + mergeResult(aggregate, item) + }) + ) + + return aggregate +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6b4591917a9..12034398d0f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3690,7 +3690,9 @@ "8ce44af9a8": "Rename {{value0}}", "395e541d5d": "Statuses", "34f03eb0de": "Board settings", - "26cbc92150": "Workspace board settings" + "26cbc92150": "Workspace board settings", + "87d24a0c2f": "Sync board and issue status", + "4c2eaa78cc": "Moving a linked workspace updates its Linear issue status when a matching workflow state exists." }, "WorkspaceKanbanStatusLane": { "8ad104642b": "Empty", @@ -4129,6 +4131,19 @@ "822ff300ad": "Dismiss", "b7e3a91c4f": "Drag project headers to reorder, or switch to", "e8c1f4a2b9": "in workspace options." + }, + "WorkspaceKanbanDrawer": { + "1975a4e480": "Task status sync failed", + "e02b0d92ff": "Task status sync skipped", + "c1d2e3f4a5": "Linear issue {{value0}} could not be read.", + "d2e3f4a5b6": "No matching Linear workflow state for {{value0}}.", + "e3f4a5b6c7": "Multiple Linear workflow states match {{value0}}.", + "f4a5b6c7d8": "Could not update Linear issue {{value0}}.", + "a5b6c7d8e9": "Could not sync Linear issue {{value0}}.", + "b6c7d8e9f0": "Task status sync could not finish.", + "c7d8e9f0a1": "{{value0}} updated", + "d8e9f0a1b2": "{{value0}} skipped", + "e9f0a1b2c3": "{{value0}} failed" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 0555cdda068..ac50ba07569 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3676,7 +3676,9 @@ "8ce44af9a8": "Cambiar nombre {{value0}}", "395e541d5d": "Estados", "34f03eb0de": "Configuración del tablero", - "26cbc92150": "Configuración del tablero del espacio de trabajo" + "26cbc92150": "Configuración del tablero del espacio de trabajo", + "87d24a0c2f": "Sincronizar el estado del tablero y de incidencias", + "4c2eaa78cc": "Al mover un espacio de trabajo vinculado, se actualiza el estado de su incidencia de Linear cuando existe un estado de flujo de trabajo coincidente." }, "WorkspaceKanbanStatusLane": { "8ad104642b": "Vacío", @@ -4129,6 +4131,19 @@ "2d3e4f5a6b": "Check connection", "3c4d5e6f7a": "Manage host…", "6e7f8a9b0c": "Remove host…" + }, + "WorkspaceKanbanDrawer": { + "1975a4e480": "Error al sincronizar el estado de la tarea", + "e02b0d92ff": "Sincronización del estado de la tarea omitida", + "c1d2e3f4a5": "No se pudo leer la incidencia {{value0}} de Linear.", + "d2e3f4a5b6": "No hay ningún estado de flujo de trabajo de Linear que coincida con {{value0}}.", + "e3f4a5b6c7": "Varios estados de flujo de trabajo de Linear coinciden con {{value0}}.", + "f4a5b6c7d8": "No se pudo actualizar la incidencia {{value0}} de Linear.", + "a5b6c7d8e9": "No se pudo sincronizar la incidencia {{value0}} de Linear.", + "b6c7d8e9f0": "No se pudo completar la sincronización del estado de la tarea.", + "c7d8e9f0a1": "{{value0}} actualizadas", + "d8e9f0a1b2": "{{value0}} omitidas", + "e9f0a1b2c3": "{{value0}} fallidas" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 5e4dab6e9da..33ab6017686 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3657,7 +3657,9 @@ "8ce44af9a8": "{{value0}} の名前を変更", "395e541d5d": "ステータス", "34f03eb0de": "ボードの設定", - "26cbc92150": "ワークスペースボードの設定" + "26cbc92150": "ワークスペースボードの設定", + "87d24a0c2f": "ボードとイシューのステータスを同期", + "4c2eaa78cc": "リンク済みワークスペースを移動すると、一致するワークフロー状態がある場合に Linear イシューのステータスが更新されます。" }, "WorkspaceKanbanStatusLane": { "8ad104642b": "空の", @@ -4129,6 +4131,19 @@ "2d3e4f5a6b": "Check connection", "3c4d5e6f7a": "Manage host…", "6e7f8a9b0c": "Remove host…" + }, + "WorkspaceKanbanDrawer": { + "1975a4e480": "タスクステータスの同期に失敗しました", + "e02b0d92ff": "タスクステータスの同期をスキップしました", + "c1d2e3f4a5": "Linear イシュー {{value0}} を読み取れませんでした。", + "d2e3f4a5b6": "{{value0}} に一致する Linear ワークフロー状態がありません。", + "e3f4a5b6c7": "{{value0}} に一致する Linear ワークフロー状態が複数あります。", + "f4a5b6c7d8": "Linear イシュー {{value0}} を更新できませんでした。", + "a5b6c7d8e9": "Linear イシュー {{value0}} を同期できませんでした。", + "b6c7d8e9f0": "タスクステータスの同期を完了できませんでした。", + "c7d8e9f0a1": "{{value0}} 件更新", + "d8e9f0a1b2": "{{value0}} 件スキップ", + "e9f0a1b2c3": "{{value0}} 件失敗" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1e768ebba05..17e942621ef 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3657,7 +3657,9 @@ "8ce44af9a8": "{{value0}} 이름 바꾸기", "395e541d5d": "상태", "34f03eb0de": "보드 설정", - "26cbc92150": "워크스페이스 보드 설정" + "26cbc92150": "워크스페이스 보드 설정", + "87d24a0c2f": "보드와 이슈 상태 동기화", + "4c2eaa78cc": "연결된 워크스페이스를 이동하면 일치하는 워크플로 상태가 있을 때 Linear 이슈 상태가 업데이트됩니다." }, "WorkspaceKanbanStatusLane": { "8ad104642b": "비어 있는", @@ -4129,6 +4131,19 @@ "2d3e4f5a6b": "Check connection", "3c4d5e6f7a": "Manage host…", "6e7f8a9b0c": "Remove host…" + }, + "WorkspaceKanbanDrawer": { + "1975a4e480": "작업 상태 동기화 실패", + "e02b0d92ff": "작업 상태 동기화 건너뜀", + "c1d2e3f4a5": "Linear 이슈 {{value0}}을(를) 읽을 수 없습니다.", + "d2e3f4a5b6": "{{value0}}에 일치하는 Linear 워크플로 상태가 없습니다.", + "e3f4a5b6c7": "{{value0}}에 일치하는 Linear 워크플로 상태가 여러 개입니다.", + "f4a5b6c7d8": "Linear 이슈 {{value0}}을(를) 업데이트할 수 없습니다.", + "a5b6c7d8e9": "Linear 이슈 {{value0}}을(를) 동기화할 수 없습니다.", + "b6c7d8e9f0": "작업 상태 동기화를 완료할 수 없습니다.", + "c7d8e9f0a1": "{{value0}}개 업데이트됨", + "d8e9f0a1b2": "{{value0}}개 건너뜀", + "e9f0a1b2c3": "{{value0}}개 실패" } }, "shared": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c9551866657..ddd16b53a85 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3657,7 +3657,9 @@ "8ce44af9a8": "重命名 {{value0}}", "395e541d5d": "状态", "34f03eb0de": "董事会设置", - "26cbc92150": "工作区板设置" + "26cbc92150": "工作区板设置", + "87d24a0c2f": "同步看板和议题状态", + "4c2eaa78cc": "移动已关联的工作区时,如果存在匹配的工作流状态,将更新其 Linear 议题状态。" }, "WorkspaceKanbanStatusLane": { "8ad104642b": "空的", @@ -4129,6 +4131,19 @@ "2d3e4f5a6b": "检查连接", "3c4d5e6f7a": "Manage host…", "6e7f8a9b0c": "删除主机..." + }, + "WorkspaceKanbanDrawer": { + "1975a4e480": "任务状态同步失败", + "e02b0d92ff": "任务状态同步已跳过", + "c1d2e3f4a5": "无法读取 Linear 议题 {{value0}}。", + "d2e3f4a5b6": "没有与 {{value0}} 匹配的 Linear 工作流状态。", + "e3f4a5b6c7": "有多个 Linear 工作流状态与 {{value0}} 匹配。", + "f4a5b6c7d8": "无法更新 Linear 议题 {{value0}}。", + "a5b6c7d8e9": "无法同步 Linear 议题 {{value0}}。", + "b6c7d8e9f0": "无法完成任务状态同步。", + "c7d8e9f0a1": "已更新 {{value0}} 个", + "d8e9f0a1b2": "已跳过 {{value0}} 个", + "e9f0a1b2c3": "失败 {{value0}} 个" } }, "shared": { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 4932b1b858a..d6a7c6dac7c 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -1101,6 +1101,26 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().workspaceBoardColumnWidth).toBe(520) }) + it('defaults workspace board task status sync off and persists changes', () => { + const setUI = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + + expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(false) + + store.getState().hydratePersistedUI( + makePersistedUI({ + syncTaskStatusFromWorkspaceBoard: true + }) + ) + expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(true) + + store.getState().setSyncTaskStatusFromWorkspaceBoard(false) + + expect(store.getState().syncTaskStatusFromWorkspaceBoard).toBe(false) + expect(setUI).toHaveBeenCalledWith({ syncTaskStatusFromWorkspaceBoard: false }) + }) + it('hydrates a valid Kagi session link', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index bf336b3bfc5..30ad221bb38 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -811,6 +811,8 @@ export type UISlice = { setWorkspaceBoardOpacity: (opacity: number) => void workspaceBoardColumnWidth: number setWorkspaceBoardColumnWidth: (width: number) => void + syncTaskStatusFromWorkspaceBoard: boolean + setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void statusBarItems: StatusBarItem[] toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean @@ -1995,6 +1997,12 @@ export const createUISlice: StateCreator = (set, get) set({ workspaceBoardColumnWidth: clamped }) }, + syncTaskStatusFromWorkspaceBoard: false, + setSyncTaskStatusFromWorkspaceBoard: (enabled) => { + window.api.ui.set({ syncTaskStatusFromWorkspaceBoard: enabled }).catch(console.error) + set({ syncTaskStatusFromWorkspaceBoard: enabled }) + }, + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], toggleStatusBarItem: (item) => set((s) => { @@ -2222,6 +2230,7 @@ export const createUISlice: StateCreator = (set, get) workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), + syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true, statusBarItems, statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time diff --git a/src/shared/constants.ts b/src/shared/constants.ts index aa214f714ab..376a86541af 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -454,6 +454,7 @@ export function getDefaultUIState(): PersistedUIState { workspaceStatuses: cloneDefaultWorkspaceStatuses(), workspaceBoardOpacity: 1, workspaceBoardColumnWidth: 308, + syncTaskStatusFromWorkspaceBoard: false, _workspaceStatusesDefaultOrderMigrated: true, _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, diff --git a/src/shared/types.ts b/src/shared/types.ts index 21c63d81ee9..cb2acf5804c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3017,6 +3017,7 @@ export type PersistedUIState = { workspaceStatuses?: WorkspaceStatusDefinition[] workspaceBoardOpacity?: number workspaceBoardColumnWidth?: number + syncTaskStatusFromWorkspaceBoard?: boolean /** One-shot migration flag for a short-lived build that persisted the * default workspace statuses in reverse workflow order. Once stamped, * user-authored status ordering is never inferred from IDs/labels again. */