Sync Linear statuses from the workspace board (#5430)

This commit is contained in:
Brennan Benson
2026-06-17 16:53:18 -07:00
committed by GitHub
parent 0f240533e1
commit 3eb0dbdd55
21 changed files with 1498 additions and 7 deletions
+18
View File
@@ -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,
+5
View File
@@ -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
),
@@ -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(),
@@ -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'
@@ -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 }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="sheet-content">{children}</div>
)
}))
vi.mock('./WorkspaceKanbanDrawerHeader', () => ({
default: () => <div data-testid="workspace-board-header" />
}))
vi.mock('./WorkspaceKanbanLaneGrid', () => ({
default: () => <div data-testid="workspace-board-lanes" />
}))
vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({
default: React.forwardRef<HTMLDivElement>((_, ref) => <div ref={ref} />)
}))
vi.mock('./WorkspaceKanbanPinDropTarget', () => ({
default: () => <div data-testid="workspace-board-pin-target" />
}))
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<string>(),
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<typeof WorkspaceBoardTaskStatusSync>()
return {
...actual,
syncWorkspaceBoardTaskStatuses: syncWorkspaceBoardTaskStatusesMock
}
})
let container: HTMLDivElement
let root: Root
let consoleInfoSpy: ReturnType<typeof vi.spyOn>
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
const statuses = [
{ id: 'todo', label: 'Todo' },
{ id: 'in-review', label: 'In review' }
]
function worktree(overrides: Partial<Worktree> = {}): 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> = {}): 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(
<WorkspaceKanbanDrawer
open={true}
dragPreview={false}
preserveOpenForMenu={false}
onOpenChange={vi.fn()}
onMenuOpenChange={vi.fn()}
/>
)
})
}
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.'
})
)
})
})
@@ -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({
<WorkspaceKanbanDrawerHeader
selectedCount={selectedWorktrees.length}
workspaceStatuses={workspaceStatuses}
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
onSyncTaskStatusFromWorkspaceBoardChange={setSyncTaskStatusFromWorkspaceBoard}
onRenameStatus={handleRenameStatus}
onChangeStatusColor={handleChangeStatusColor}
onChangeStatusIcon={handleChangeStatusIcon}
@@ -35,6 +35,8 @@ function renderHeader(onClose: () => void): React.ReactElement {
return WorkspaceKanbanDrawerHeader({
selectedCount: 0,
workspaceStatuses: statuses,
syncTaskStatusFromWorkspaceBoard: false,
onSyncTaskStatusFromWorkspaceBoardChange: vi.fn(),
onRenameStatus: vi.fn(),
onChangeStatusColor: vi.fn(),
onChangeStatusIcon: vi.fn(),
@@ -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({
/>
<WorkspaceKanbanSettingsMenu
workspaceStatuses={workspaceStatuses}
syncTaskStatusFromWorkspaceBoard={syncTaskStatusFromWorkspaceBoard}
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
onRenameStatus={onRenameStatus}
onChangeStatusColor={onChangeStatusColor}
onChangeStatusIcon={onChangeStatusIcon}
@@ -0,0 +1,77 @@
// @vitest-environment happy-dom
import { createRoot, type Root } from 'react-dom/client'
import { act, type ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }]
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.mock('@/components/ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
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(
<WorkspaceKanbanSettingsMenu
workspaceStatuses={statuses}
syncTaskStatusFromWorkspaceBoard={false}
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
onRenameStatus={vi.fn()}
onChangeStatusColor={vi.fn()}
onChangeStatusIcon={vi.fn()}
onMoveStatus={vi.fn()}
onRemoveStatus={vi.fn()}
onAddStatus={vi.fn()}
/>
)
})
}
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<HTMLButtonElement>(
'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)
})
})
@@ -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({
}
}}
>
<div className="px-1 pb-2">
<div className="flex items-start justify-between gap-3 rounded-md px-1.5 py-1.5 hover:bg-worktree-sidebar-accent/70">
<span className="min-w-0 space-y-0.5">
<span className="block text-[12px] font-medium leading-4 text-foreground">
{translate(
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f',
'Sync board and issue status'
)}
</span>
<span className="block text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.4c2eaa78cc',
'Moving a linked workspace updates its Linear issue status when a matching workflow state exists.'
)}
</span>
</span>
<SettingsSwitch
checked={syncTaskStatusFromWorkspaceBoard}
onChange={() =>
onSyncTaskStatusFromWorkspaceBoardChange(!syncTaskStatusFromWorkspaceBoard)
}
ariaLabel={translate(
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.87d24a0c2f',
'Sync board and issue status'
)}
/>
</div>
</div>
<DropdownMenuLabel>
{translate('auto.components.sidebar.WorkspaceKanbanSettingsMenu.395e541d5d', 'Statuses')}
</DropdownMenuLabel>
@@ -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> = {}): 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> = {}): LinearWorkflowState {
return {
id: 'state-review',
name: 'In review',
type: 'started',
color: '#111',
position: 1,
...overrides
}
}
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo::/worktree',
linkedLinearIssue: 'ORC-1',
linkedLinearIssueWorkspaceId: 'workspace-1',
...overrides
} as Worktree
}
function targetStatus(
overrides: Partial<WorkspaceStatusDefinition> = {}
): WorkspaceStatusDefinition {
return { id: 'in-review', label: 'In review', ...overrides }
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, resolve, reject }
}
async function flushMicrotasks(count = 5): Promise<void> {
for (let i = 0; i < count; i += 1) {
await Promise.resolve()
}
}
function setup(overrides: Partial<Worktree> = {}) {
const target = targetStatus()
const item = worktree(overrides)
const getIssue = vi.fn().mockResolvedValue(issue())
const teamStates = vi.fn().mockResolvedValue([state()])
const updateIssue = vi.fn<() => Promise<LinearMutationResult>>().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<LinearMutationResult>>().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<LinearMutationResult>()
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<LinearMutationResult>>()
.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()
})
})
@@ -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<Worktree, 'linkedLinearIssue' | 'linkedLinearIssueWorkspaceId'>
>
settings?: RuntimeLinearSettings
getSettingsForWorktree?: (worktreeId: string) => RuntimeLinearSettings
getLatestWorkspaceStatus: (worktreeId: string) => WorkspaceStatus | null | undefined
deps?: Partial<WorkspaceBoardTaskStatusSyncDependencies>
}
export type WorkspaceBoardTaskStatusSyncRequest = {
worktreeIds: string[]
targetStatus: WorkspaceStatusDefinition
}
export function getWorkspaceBoardTaskStatusSyncRequest(args: {
enabled: boolean
worktreeIds: readonly string[]
status: WorkspaceStatus
worktreesById: ReadonlyMap<string, Pick<Worktree, 'workspaceStatus'>>
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<string, Promise<unknown>>()
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<WorkspaceBoardTaskStatusSyncResult>
): Promise<WorkspaceBoardTaskStatusSyncResult> {
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<WorkspaceBoardTaskStatusSyncResult> {
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<WorkspaceBoardTaskStatusSyncResult> {
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
}
+16 -1
View File
@@ -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": {
+16 -1
View File
@@ -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": {
+16 -1
View File
@@ -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": {
+16 -1
View File
@@ -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": {
+16 -1
View File
@@ -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": {
+20
View File
@@ -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()
+9
View File
@@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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
+1
View File
@@ -454,6 +454,7 @@ export function getDefaultUIState(): PersistedUIState {
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
workspaceBoardOpacity: 1,
workspaceBoardColumnWidth: 308,
syncTaskStatusFromWorkspaceBoard: false,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultWorkflowMigrated: true,
_workspaceStatusesDefaultVisualsMigrated: true,
+1
View File
@@ -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. */