diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx
index 35e9f8a0c92..36a2d63b551 100644
--- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx
@@ -3,6 +3,7 @@ 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'
+import { MAX_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }]
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
@@ -25,14 +26,22 @@ import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
let root: Root | null = null
let container: HTMLDivElement | null = null
-function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void {
+function renderMenu({
+ workspaceStatuses = statuses,
+ onSyncTaskStatusFromWorkspaceBoardChange = vi.fn<(enabled: boolean) => void>(),
+ onAddStatus = vi.fn<() => void>()
+}: {
+ workspaceStatuses?: WorkspaceStatusDefinition[]
+ onSyncTaskStatusFromWorkspaceBoardChange?: (enabled: boolean) => void
+ onAddStatus?: () => void
+} = {}): void {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root?.render(
)
})
@@ -59,7 +68,7 @@ afterEach(() => {
describe('WorkspaceKanbanSettingsMenu', () => {
it('renders the task status sync switch and forwards changes', async () => {
const onChange = vi.fn()
- renderMenu(onChange)
+ renderMenu({ onSyncTaskStatusFromWorkspaceBoardChange: onChange })
const toggle = document.querySelector(
'button[role="switch"][aria-label="Sync board and issue status"]'
@@ -74,4 +83,42 @@ describe('WorkspaceKanbanSettingsMenu', () => {
expect(onChange).toHaveBeenCalledWith(true)
})
+
+ it('adds another status below the board limit', () => {
+ const onAddStatus = vi.fn()
+ renderMenu({ onAddStatus })
+
+ const addStatus = Array.from(document.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim() === 'Add status'
+ )
+
+ expect(addStatus?.disabled).toBe(false)
+ addStatus?.click()
+ expect(onAddStatus).toHaveBeenCalledOnce()
+ })
+
+ it('disables adding another status at the explicit board limit', () => {
+ const onAddStatus = vi.fn()
+ renderMenu({
+ workspaceStatuses: Array.from({ length: MAX_WORKSPACE_STATUSES }, (_, index) => ({
+ id: `state-${index + 1}`,
+ label: `State ${index + 1}`
+ })),
+ onAddStatus
+ })
+
+ const addStatus = Array.from(document.querySelectorAll('button')).find(
+ (button) => button.textContent?.trim().startsWith('Add status') === true
+ )
+
+ expect(document.body.textContent).toContain(
+ `${MAX_WORKSPACE_STATUSES} / ${MAX_WORKSPACE_STATUSES}`
+ )
+ expect(addStatus?.textContent).toContain(
+ `${MAX_WORKSPACE_STATUSES} / ${MAX_WORKSPACE_STATUSES}`
+ )
+ expect(addStatus?.disabled).toBe(true)
+ addStatus?.click()
+ expect(onAddStatus).not.toHaveBeenCalled()
+ })
})
diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx
index a7fd4693143..9ec24a3d04d 100644
--- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx
+++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx
@@ -11,6 +11,7 @@ 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 { MAX_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
import { getWorkspaceStatusVisualMeta } from './workspace-status'
import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover'
import { translate } from '@/i18n/i18n'
@@ -38,6 +39,8 @@ export default function WorkspaceKanbanSettingsMenu({
onRemoveStatus,
onAddStatus
}: WorkspaceKanbanSettingsMenuProps): React.JSX.Element {
+ const statusLimitReached = workspaceStatuses.length >= MAX_WORKSPACE_STATUSES
+
return (
@@ -195,6 +198,7 @@ export default function WorkspaceKanbanSettingsMenu({
variant="ghost"
size="xs"
className="mt-1 h-7 w-full justify-start text-[12px]"
+ disabled={statusLimitReached}
onClick={onAddStatus}
>
@@ -202,6 +206,11 @@ export default function WorkspaceKanbanSettingsMenu({
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.79eb990aa4',
'Add status'
)}
+ {statusLimitReached ? (
+
+ {workspaceStatuses.length} / {MAX_WORKSPACE_STATUSES}
+
+ ) : null}
diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts
index 2c46a41aae7..236501b9096 100644
--- a/src/shared/workspace-statuses.test.ts
+++ b/src/shared/workspace-statuses.test.ts
@@ -3,6 +3,7 @@ import {
WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT,
WORKSPACE_BOARD_COLUMN_WIDTH_MAX,
WORKSPACE_BOARD_COLUMN_WIDTH_MIN,
+ MAX_WORKSPACE_STATUSES,
clampWorkspaceBoardColumnWidth,
cloneDefaultWorkspaceStatuses,
normalizePersistedWorkspaceStatuses,
@@ -10,28 +11,25 @@ import {
} from './workspace-statuses'
describe('workspace status visuals', () => {
- it('keeps workflows longer than a dozen columns', () => {
- // Why: mirroring an external workflow (Jira, Linear) routinely needs more
- // than a dozen states. The old cap of 12 dropped the extra ones on the
- // next normalize, with no error and nothing in the UI preventing them.
- const authored = Array.from({ length: 20 }, (_, index) => ({
- id: `state-${index}`,
- label: `State ${index}`
+ it.each([13, 20])('keeps all %i authored columns in order', (count) => {
+ const authored = Array.from({ length: count }, (_, index) => ({
+ id: `state-${index + 1}`,
+ label: `State ${index + 1}`
}))
const statuses = normalizeWorkspaceStatuses(authored)
- expect(statuses).toHaveLength(20)
- expect(statuses.at(-1)).toMatchObject({ id: 'state-19', label: 'State 19' })
+ expect(statuses).toHaveLength(count)
+ expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id))
})
- it('still truncates absurd payloads so corrupted data cannot explode the board', () => {
+ it('clamps corrupted payloads to the exact board limit', () => {
const corrupted = Array.from({ length: 500 }, (_, index) => ({
id: `state-${index}`,
label: `State ${index}`
}))
- expect(normalizeWorkspaceStatuses(corrupted).length).toBeLessThanOrEqual(64)
+ expect(normalizeWorkspaceStatuses(corrupted)).toHaveLength(MAX_WORKSPACE_STATUSES)
})
it('keeps the default workflow order', () => {
diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts
index 245ebee1ab6..74acb58bbe5 100644
--- a/src/shared/workspace-statuses.ts
+++ b/src/shared/workspace-statuses.ts
@@ -9,13 +9,8 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults'
const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:'
const MAX_STATUS_LABEL_LENGTH = 32
-// Why: corruption guard only — nothing in the board or the sidebar depends on
-// this number, and the renderer never enforces it, so a low cap just truncated
-// legitimate columns on the next normalize. Teams mirroring an external
-// workflow (Jira, Linear) routinely need more than a dozen states; the board
-// already scrolls horizontally and groups by status, so the extra columns cost
-// nothing structurally.
-const MAX_WORKSPACE_STATUSES = 64
+// Bounds corrupted payloads and the board's eager lane rendering.
+export const MAX_WORKSPACE_STATUSES = 64
type WorkspaceStatusNormalizationOptions = {
migrateDefaultWorkflowStatuses?: boolean
migrateLegacyDefaultStatusVisuals?: boolean