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. */