fix: address review findings (#2023)

This commit is contained in:
Jinjing 2026-05-15 19:36:46 -07:00 committed by GitHub
parent 8ec8044d14
commit ccd6350868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 661 additions and 46 deletions

View File

@ -15,6 +15,23 @@ const TEST_LEAF_1 = '11111111-1111-4111-8111-111111111111'
const TEST_LEAF_2 = '22222222-2222-4222-8222-222222222222'
const TEST_LEAF_LIVE = '33333333-3333-4333-8333-333333333333'
const TEST_LEAF_EXPIRED = '44444444-4444-4444-8444-444444444444'
const REORDERED_DEFAULT_WORKSPACE_STATUSES = [
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{
id: 'in-progress',
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
]
const LEGACY_DEFAULT_WORKSPACE_STATUSES = [
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
]
vi.mock('electron', () => ({
app: {
@ -924,6 +941,127 @@ describe('Store', () => {
expect(store.getUI().sortBy).toBe('recent')
})
it('repairs the known-bad reordered default workspace statuses once on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES },
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
const ui = store.getUI()
expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([
'todo',
'in-progress',
'in-review',
'completed'
])
expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true)
store.flush()
const persisted = readDataFile() as {
ui?: {
workspaceStatuses?: typeof REORDERED_DEFAULT_WORKSPACE_STATUSES
_workspaceStatusesDefaultOrderMigrated?: boolean
_workspaceStatusesDefaultVisualsMigrated?: boolean
}
}
expect(persisted.ui?._workspaceStatusesDefaultOrderMigrated).toBe(true)
expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true)
expect(persisted.ui?.workspaceStatuses?.map((status) => status.id)).toEqual([
'todo',
'in-progress',
'in-review',
'completed'
])
})
it('migrates legacy default workspace status visuals once on load', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {
workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES,
_workspaceStatusesDefaultOrderMigrated: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getUI().workspaceStatuses).toEqual([
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
{
id: 'in-progress',
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }
])
expect(store.getUI()._workspaceStatusesDefaultVisualsMigrated).toBe(true)
store.flush()
const persisted = readDataFile() as {
ui?: {
_workspaceStatusesDefaultVisualsMigrated?: boolean
}
}
expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true)
})
it('preserves legacy-looking workspace status visuals after the load migration', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {
workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultVisualsMigrated: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
const inProgress = store
.getUI()
.workspaceStatuses?.find((status) => status.id === 'in-progress')
expect(inProgress).toMatchObject({ color: 'blue', icon: 'circle-dot' })
})
it('preserves intentionally reordered default workspace statuses after the load migration', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {
workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES,
_workspaceStatusesDefaultOrderMigrated: true
},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual([
'completed',
'in-review',
'in-progress',
'todo'
])
})
// ── terminalMacOptionAsAlt migration (issue #903) ───────────────────
it('migrates legacy "true" terminalMacOptionAsAlt to "auto" on first load', async () => {

View File

@ -79,6 +79,7 @@ import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardOpacity,
normalizeWorkspaceBoardCompact,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
@ -157,7 +158,7 @@ function backupPath(dataFile: string, index: number): string {
}
function normalizeGroupBy(groupBy: unknown): PersistedState['ui']['groupBy'] {
if (groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') {
if (groupBy === 'flat' || groupBy === 'none' || groupBy === 'repo' || groupBy === 'pr-status') {
return groupBy
}
if (groupBy === 'workspace-status') {
@ -997,6 +998,7 @@ export class Store {
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
private gitUsernameCache = new Map<string, string>()
private loadNeedsSave = false
constructor() {
const loaded = this.load()
@ -1022,9 +1024,11 @@ export class Store {
this.state.legacyPaneKeyAliasEntries = entries
this.scheduleSave()
})
if (normalized.changed) {
if (normalized.changed || this.loadNeedsSave) {
// Why: upgraded sessions may contain legacy pane:1 leaves. Rewrite them at
// the main persistence boundary so older renderer writes cannot revive them.
// Other one-shot load migrations also set loadNeedsSave to persist their
// guard flags before the next restart.
this.scheduleSave()
}
}
@ -1216,6 +1220,25 @@ export class Store {
const rawSort = parsed.ui?.sortBy
const sort = normalizeSortBy(rawSort)
const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent'
const workspaceStatusesDefaultOrderMigrated =
parsed.ui?._workspaceStatusesDefaultOrderMigrated === true
// Why: visual migration has its own guard so later user choices
// of valid legacy color/icon IDs are preserved by runtime writes.
const workspaceStatusesDefaultVisualsMigrated =
parsed.ui?._workspaceStatusesDefaultVisualsMigrated === true
const workspaceStatuses = normalizePersistedWorkspaceStatuses(
parsed.ui?.workspaceStatuses,
{
repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated,
migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated
}
)
if (
!workspaceStatusesDefaultOrderMigrated ||
!workspaceStatusesDefaultVisualsMigrated
) {
this.loadNeedsSave = true
}
// Why: the 'inline-agents' card property was added after the
// feature shipped behind an experimental toggle. Now that the
// feature is default-on for everyone, every existing user needs
@ -1263,6 +1286,9 @@ export class Store {
...defaults.ui,
...parsed.ui,
sortBy: migrate ? ('smart' as const) : sort,
workspaceStatuses,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultVisualsMigrated: true,
_sortBySmartMigrated: true,
...(migratedCardProps !== undefined
? { worktreeCardProperties: migratedCardProps }
@ -2034,9 +2060,10 @@ export class Store {
sortBy: updates.sortBy
? normalizeSortBy(updates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy),
workspaceStatuses: normalizeWorkspaceStatuses(
updates.workspaceStatuses ?? this.state.ui?.workspaceStatuses
),
workspaceStatuses:
updates.workspaceStatuses !== undefined
? normalizeWorkspaceStatuses(updates.workspaceStatuses)
: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(
updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity
),

View File

@ -20,6 +20,7 @@ import SidebarFilter from './SidebarFilter'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
const GROUP_BY_OPTIONS = [
{ id: 'flat', label: 'None' },
{ id: 'none', label: 'Status' },
{ id: 'pr-status', label: 'PR' },
{ id: 'repo', label: 'Repo' }
@ -33,7 +34,7 @@ const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'pr', label: 'Linked PR' },
{ id: 'comment', label: 'Comment' },
// Why: toggles the inline "Agent activity" list rendered below each
// workspace card body (see WorktreeCard WorktreeCardAgents). Off hides
// workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides
// the list; there is no alternate surface.
{ id: 'inline-agents', label: 'Agent activity' }
]
@ -226,7 +227,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
return (
<>
<div
className="flex h-8 items-center justify-between px-2 gap-2"
className="mt-2 flex h-8 items-center justify-between px-2 gap-2"
onPointerEnter={handleWorkspaceHeaderPointerEnter}
onPointerLeave={handleWorkspaceHeaderPointerLeave}
>

View File

@ -698,7 +698,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
// First header sits directly under SidebarHeader, which already
// supplies its own spacing — only offset secondary group headers.
vItem.index !== firstHeaderIndex && 'mt-2',
row.repo ? 'overflow-hidden' : row.tone
row.repo && 'overflow-hidden'
)}
onDragOver={
isPinnedHeader
@ -736,10 +736,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
row.repo && 'text-muted-foreground'
row.repo ? 'text-muted-foreground' : row.tone
)}
>
<row.icon className="size-3.5" />
<row.icon className={row.repo ? 'size-3.5' : 'size-3'} />
</div>
) : null}

View File

@ -0,0 +1,74 @@
import React from 'react'
export function ConductorDoneIcon({ className }: { className?: string }): React.JSX.Element {
return React.createElement(
'svg',
{
className,
viewBox: '0 0 12 12',
fill: 'none',
'aria-hidden': true
},
React.createElement('circle', { cx: 6, cy: 6, r: 5.1, fill: 'currentColor' }),
React.createElement('path', {
d: 'M4 6.05 5.25 7.25 8.05 4.7',
stroke: 'white',
strokeWidth: 1.25,
strokeLinecap: 'round',
strokeLinejoin: 'round'
})
)
}
export function ConductorReviewIcon({ className }: { className?: string }): React.JSX.Element {
return React.createElement(
'svg',
{
className,
viewBox: '0 0 12 12',
fill: 'none',
'aria-hidden': true
},
React.createElement('circle', {
cx: 6,
cy: 6,
r: 4.9,
fill: 'var(--background)',
stroke: 'currentColor',
strokeWidth: 1.45
}),
React.createElement('path', {
d: 'M4.15 6.05 5.25 7.05 7.7 4.75',
stroke: 'currentColor',
strokeWidth: 1.2,
strokeLinecap: 'round',
strokeLinejoin: 'round'
})
)
}
export function ConductorProgressIcon({ className }: { className?: string }): React.JSX.Element {
return React.createElement(
'svg',
{
className,
viewBox: '0 0 12 12',
fill: 'none',
'aria-hidden': true
},
React.createElement('circle', {
cx: 6,
cy: 6,
r: 4.9,
fill: 'var(--background)',
stroke: 'currentColor',
strokeWidth: 1.45
}),
React.createElement('path', {
d: 'M6 3.75v2.7',
stroke: 'currentColor',
strokeWidth: 1.25,
strokeLinecap: 'round'
})
)
}

View File

@ -1,16 +1,14 @@
import type React from 'react'
import React from 'react'
import {
Ban,
Circle,
CircleAlert,
CircleCheckBig,
CircleDashed,
CircleDot,
CircleEllipsis,
CirclePause,
CirclePlay,
Flag,
GitPullRequest,
Timer
} from 'lucide-react'
import type { WorkspaceStatus, WorkspaceStatusDefinition } from '../../../../shared/types'
@ -26,6 +24,11 @@ import {
getWorkspaceStatusGroupKey,
isWorkspaceStatusId
} from '../../../../shared/workspace-statuses'
import {
ConductorDoneIcon,
ConductorProgressIcon,
ConductorReviewIcon
} from './workspace-status-icons'
export {
DEFAULT_WORKSPACE_STATUS_COLOR_ID,
@ -122,22 +125,50 @@ export const WORKSPACE_STATUS_COLOR_OPTIONS: WorkspaceStatusColorOption[] = [
swatch: 'bg-zinc-500',
border: 'border-t-zinc-500/70',
laneTint: 'bg-zinc-500/[0.04]'
},
{
id: 'conductor-done',
label: 'Conductor Done',
tone: 'text-[#c7a594]',
swatch: 'bg-[#c7a594]',
border: 'border-t-[#c7a594]/70',
laneTint: 'bg-[#c7a594]/[0.04]'
},
{
id: 'conductor-review',
label: 'Conductor Review',
tone: 'text-[#16a34a]',
swatch: 'bg-[#16a34a]',
border: 'border-t-[#16a34a]/70',
laneTint: 'bg-[#16a34a]/[0.04]'
},
{
id: 'conductor-progress',
label: 'Conductor Progress',
tone: 'text-[#d4a300]',
swatch: 'bg-[#d4a300]',
border: 'border-t-[#d4a300]/70',
laneTint: 'bg-[#d4a300]/[0.04]'
}
]
export const WORKSPACE_STATUS_ICON_OPTIONS: WorkspaceStatusIconOption[] = [
{ id: 'circle', label: 'Circle', icon: Circle },
{ id: 'circle-dot', label: 'Dot', icon: CircleDot },
{ id: 'circle-progress', label: 'Progress', icon: ConductorProgressIcon },
{ id: 'circle-dashed', label: 'Dashed', icon: CircleDashed },
{ id: 'circle-ellipsis', label: 'Waiting', icon: CircleEllipsis },
{ id: 'git-pull-request', label: 'Review', icon: GitPullRequest },
{ id: 'git-pull-request', label: 'Review', icon: ConductorReviewIcon },
{ id: 'timer', label: 'Timer', icon: Timer },
{ id: 'flag', label: 'Flag', icon: Flag },
{ id: 'circle-alert', label: 'Alert', icon: CircleAlert },
{ id: 'circle-pause', label: 'Paused', icon: CirclePause },
{ id: 'circle-play', label: 'Play', icon: CirclePlay },
{ id: 'circle-check', label: 'Done', icon: CircleCheckBig },
{ id: 'ban', label: 'Blocked', icon: Ban }
{ id: 'circle-check', label: 'Done', icon: ConductorDoneIcon },
{ id: 'ban', label: 'Blocked', icon: Ban },
{ id: 'conductor-done', label: 'Done', icon: ConductorDoneIcon },
{ id: 'conductor-review', label: 'In review', icon: ConductorReviewIcon },
{ id: 'conductor-progress', label: 'In progress', icon: ConductorProgressIcon }
]
const FALLBACK_COLOR_OPTION: WorkspaceStatusColorOption = WORKSPACE_STATUS_COLOR_OPTIONS[0] ?? {
@ -167,16 +198,16 @@ const DEFAULT_STATUS_VISUALS: Record<
icon: 'circle'
},
'in-progress': {
color: 'blue',
icon: 'circle-dot'
color: 'conductor-progress',
icon: 'conductor-progress'
},
'in-review': {
color: 'violet',
icon: 'git-pull-request'
color: 'conductor-review',
icon: 'conductor-review'
},
completed: {
color: 'emerald',
icon: 'circle-check'
color: 'conductor-done',
icon: 'conductor-done'
}
}

View File

@ -64,6 +64,26 @@ describe('buildRows with pinned worktrees', () => {
expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-pinned' } })
})
it('renders a flat list without status headers in groupBy flat', () => {
const rows = buildRows('flat', [unpinned1, unpinned2], repoMap, null, new Set())
expect(rows).toMatchObject([
{ type: 'item', worktree: { id: 'wt-1' } },
{ type: 'item', worktree: { id: 'wt-2' } }
])
})
it('keeps pinned worktrees above the flat list', () => {
const rows = buildRows('flat', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows).toMatchObject([
{ type: 'header', key: 'pinned', count: 1 },
{ type: 'item', worktree: { id: 'wt-pinned' } },
{ type: 'item', worktree: { id: 'wt-1' } },
{ type: 'item', worktree: { id: 'wt-2' } }
])
})
it('emits status headers for unpinned worktrees in groupBy none', () => {
const rows = buildRows('none', [unpinned1, pinned, unpinned2], repoMap, null, new Set())
expect(rows[2]).toMatchObject({
@ -310,6 +330,7 @@ describe('getRepoGroupOrdering', () => {
['repo', 'smart', 'visible-worktree-order'],
['repo', 'name', 'manual'],
['repo', 'repo', 'manual'],
['flat', 'recent', 'manual'],
['none', 'recent', 'manual'],
['pr-status', 'recent', 'manual']
] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {

View File

@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
import { CircleCheckBig, CircleDot, CircleX, Folder, GitPullRequest, Pin } from 'lucide-react'
import { CircleX, Folder, Pin } from 'lucide-react'
import type React from 'react'
import type {
Repo,
@ -14,12 +14,19 @@ import {
getWorkspaceStatusGroupKey,
getWorkspaceStatusVisualMeta
} from './workspace-status'
import {
ConductorDoneIcon,
ConductorProgressIcon,
ConductorReviewIcon
} from './workspace-status-icons'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type { SortBy } from './smart-sort'
export { branchName }
export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status'
// Why: `none` is the legacy persisted value for Status grouping. The actual
// ungrouped sidebar mode is `flat`.
export type WorktreeGroupBy = 'flat' | 'none' | 'repo' | 'pr-status'
export type RepoGroupOrdering = 'manual' | 'visible-worktree-order'
export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering {
@ -67,18 +74,18 @@ export const PR_GROUP_META: Record<
> = {
done: {
label: 'Done',
icon: CircleCheckBig,
tone: 'text-emerald-700 dark:text-emerald-200'
icon: ConductorDoneIcon,
tone: 'text-[#c7a594]'
},
'in-review': {
label: 'In review',
icon: GitPullRequest,
tone: 'text-sky-700 dark:text-sky-200'
icon: ConductorReviewIcon,
tone: 'text-[#16a34a]'
},
'in-progress': {
label: 'In progress',
icon: CircleDot,
tone: 'text-amber-700 dark:text-amber-200'
icon: ConductorProgressIcon,
tone: 'text-[#d4a300]'
},
closed: {
label: 'Closed',
@ -369,6 +376,15 @@ export function buildRows(
)
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
if (groupBy === 'flat') {
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
nestLineage,
showLineageContext: nestLineage,
collapsedGroups
})
return result
}
const grouped = new Map<string, { label: string; items: Worktree[]; repo?: Repo }>()
for (const w of unpinned) {
let key: string
@ -501,6 +517,9 @@ export function getGroupKeyForWorktree(
prCache: Record<string, unknown> | null,
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses()
): string | null {
if (groupBy === 'flat') {
return null
}
if (groupBy === 'none') {
return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses))
}

View File

@ -349,7 +349,7 @@ export type UISlice = {
) => void
markOrcaHookRepoAlwaysTrusted: (repoId: string) => void
clearOrcaHookTrustForRepo: (repoId: string) => void
groupBy: 'none' | 'repo' | 'pr-status'
groupBy: 'flat' | 'none' | 'repo' | 'pr-status'
setGroupBy: (g: UISlice['groupBy']) => void
showWorkspaceLineage: boolean
setShowWorkspaceLineage: (v: boolean) => void

View File

@ -353,6 +353,8 @@ export function getDefaultUIState(): PersistedUIState {
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
workspaceBoardOpacity: 1,
workspaceBoardCompact: false,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultVisualsMigrated: true,
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
statusBarVisible: true,
dismissedUpdateVersion: null,

View File

@ -1680,7 +1680,7 @@ export type PersistedUIState = {
lastActiveWorktreeId: string | null
sidebarWidth: number
rightSidebarWidth: number
groupBy: 'none' | 'repo' | 'pr-status'
groupBy: 'flat' | 'none' | 'repo' | 'pr-status'
showWorkspaceLineage?: boolean
sortBy: 'name' | 'smart' | 'recent' | 'repo'
showActiveOnly: boolean
@ -1698,6 +1698,13 @@ export type PersistedUIState = {
workspaceStatuses?: WorkspaceStatusDefinition[]
workspaceBoardOpacity?: number
workspaceBoardCompact?: 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. */
_workspaceStatusesDefaultOrderMigrated?: boolean
/** One-shot migration flag for the old default blue/violet/emerald status
* visuals. Once stamped, valid user-authored colors/icons are preserved. */
_workspaceStatusesDefaultVisualsMigrated?: boolean
statusBarItems: StatusBarItem[]
statusBarVisible: boolean
dismissedUpdateVersion: string | null

View File

@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import {
cloneDefaultWorkspaceStatuses,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from './workspace-statuses'
describe('workspace status visuals', () => {
it('keeps todo first by default', () => {
expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([
'todo',
'in-progress',
'in-review',
'completed'
])
})
it('migrates the old default status visuals without reordering the board', () => {
const statuses = normalizePersistedWorkspaceStatuses(
[
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
],
{ migrateLegacyDefaultStatusVisuals: true }
)
expect(statuses.map((status) => status.id)).toEqual([
'todo',
'in-progress',
'in-review',
'completed'
])
expect(statuses).toEqual(cloneDefaultWorkspaceStatuses())
})
it('preserves explicit status order while migrating default visuals', () => {
const statuses = normalizePersistedWorkspaceStatuses(
[
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' },
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
],
{ migrateLegacyDefaultStatusVisuals: true }
)
expect(statuses.map((status) => status.id)).toEqual([
'completed',
'in-review',
'in-progress',
'todo'
])
expect(statuses[0]).toMatchObject({
color: 'conductor-done',
icon: 'conductor-done'
})
})
it('preserves default-label reordered statuses unless the one-shot repair is requested', () => {
const statuses = normalizePersistedWorkspaceStatuses([
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{
id: 'in-progress',
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
])
expect(statuses.map((status) => status.id)).toEqual([
'completed',
'in-review',
'in-progress',
'todo'
])
})
it('repairs the exact PR-introduced default status reorder when migration-gated', () => {
const statuses = normalizePersistedWorkspaceStatuses(
[
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' },
{
id: 'in-review',
label: 'In review',
color: 'conductor-review',
icon: 'conductor-review'
},
{
id: 'in-progress',
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
],
{ repairReorderedDefaultStatuses: true }
)
expect(statuses).toEqual(cloneDefaultWorkspaceStatuses())
})
it('does not repair reordered default-label statuses with a different raw shape', () => {
const statuses = normalizePersistedWorkspaceStatuses(
[
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' },
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
],
{ repairReorderedDefaultStatuses: true }
)
expect(statuses.map((status) => status.id)).toEqual([
'completed',
'in-review',
'in-progress',
'todo'
])
})
it('leaves custom persisted status layouts in their saved order', () => {
const statuses = normalizePersistedWorkspaceStatuses([
{ id: 'completed', label: 'Shipped', color: 'conductor-done', icon: 'conductor-done' },
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
])
expect(statuses.map((status) => status.id)).toEqual(['completed', 'todo'])
})
it('uses Conductor-style visuals for the default status icons', () => {
const statuses = cloneDefaultWorkspaceStatuses()
const inProgress = statuses.find((status) => status.id === 'in-progress')
const inReview = statuses.find((status) => status.id === 'in-review')
const completed = statuses.find((status) => status.id === 'completed')
expect(inProgress).toMatchObject({
color: 'conductor-progress',
icon: 'conductor-progress'
})
expect(inReview).toMatchObject({
color: 'conductor-review',
icon: 'conductor-review'
})
expect(completed).toMatchObject({
color: 'conductor-done',
icon: 'conductor-done'
})
})
it('migrates the old in-progress blue dot default only when requested', () => {
const statuses = normalizePersistedWorkspaceStatuses(
[{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }],
{ migrateLegacyDefaultStatusVisuals: true }
)
expect(statuses[0]).toMatchObject({
color: 'conductor-progress',
icon: 'conductor-progress'
})
})
it('preserves valid legacy visuals for default-label statuses at runtime', () => {
const statuses = normalizeWorkspaceStatuses([
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' }
])
expect(statuses[0]).toMatchObject({
color: 'blue',
icon: 'circle-dot'
})
})
it('keeps intentional custom in-progress visuals', () => {
const statuses = normalizeWorkspaceStatuses([
{ id: 'in-progress', label: 'Doing', color: 'blue', icon: 'circle-dot' }
])
expect(statuses[0]).toMatchObject({
color: 'blue',
icon: 'circle-dot'
})
})
})

View File

@ -3,6 +3,9 @@ import type { Worktree, WorkspaceStatus, WorkspaceStatusDefinition } from './typ
const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:'
const MAX_STATUS_LABEL_LENGTH = 32
const MAX_WORKSPACE_STATUSES = 12
type WorkspaceStatusNormalizationOptions = {
migrateLegacyDefaultStatusVisuals?: boolean
}
export const DEFAULT_WORKSPACE_STATUS_ID: WorkspaceStatus = 'in-progress'
export const DEFAULT_WORKSPACE_STATUS_COLOR_ID = 'neutral'
@ -16,12 +19,16 @@ export const WORKSPACE_STATUS_COLOR_IDS = [
'amber',
'emerald',
'rose',
'zinc'
'zinc',
'conductor-done',
'conductor-review',
'conductor-progress'
] as const
export const WORKSPACE_STATUS_ICON_IDS = [
'circle',
'circle-dot',
'circle-progress',
'circle-dashed',
'circle-ellipsis',
'git-pull-request',
@ -31,21 +38,29 @@ export const WORKSPACE_STATUS_ICON_IDS = [
'circle-pause',
'circle-play',
'circle-check',
'ban'
'ban',
'conductor-done',
'conductor-review',
'conductor-progress'
] as const
const DEFAULT_STATUS_VISUALS: Record<string, { color: string; icon: string }> = {
todo: { color: 'neutral', icon: 'circle' },
'in-progress': { color: 'blue', icon: 'circle-dot' },
'in-review': { color: 'violet', icon: 'git-pull-request' },
completed: { color: 'emerald', icon: 'circle-check' }
'in-progress': { color: 'conductor-progress', icon: 'conductor-progress' },
'in-review': { color: 'conductor-review', icon: 'conductor-review' },
completed: { color: 'conductor-done', icon: 'conductor-done' }
}
export const DEFAULT_WORKSPACE_STATUSES = [
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' },
{ id: 'in-progress', label: 'In progress', color: 'blue', icon: 'circle-dot' },
{ id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' },
{ id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' }
{
id: 'in-progress',
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{ id: 'completed', label: 'Completed', color: 'conductor-done', icon: 'conductor-done' }
] as const satisfies readonly WorkspaceStatusDefinition[]
export function cloneDefaultWorkspaceStatuses(): WorkspaceStatusDefinition[] {
@ -80,7 +95,22 @@ function sanitizeWorkspaceStatusId(value: unknown, fallbackLabel: string): Works
return trimmed.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status'
}
function sanitizeWorkspaceStatusColor(value: unknown, statusId: string, index: number): string {
function sanitizeWorkspaceStatusColor(
value: unknown,
statusId: string,
label: string,
index: number,
options: WorkspaceStatusNormalizationOptions
): string {
if (
options.migrateLegacyDefaultStatusVisuals === true &&
((statusId === 'in-progress' && label === 'In progress' && value === 'blue') ||
(statusId === 'in-review' && label === 'In review' && value === 'violet') ||
(statusId === 'completed' && label === 'Completed' && value === 'emerald')) &&
DEFAULT_STATUS_VISUALS[statusId]
) {
return DEFAULT_STATUS_VISUALS[statusId]?.color ?? DEFAULT_WORKSPACE_STATUS_COLOR_ID
}
if (typeof value === 'string' && WORKSPACE_STATUS_COLOR_IDS.some((id) => id === value)) {
return value
}
@ -91,7 +121,23 @@ function sanitizeWorkspaceStatusColor(value: unknown, statusId: string, index: n
return WORKSPACE_STATUS_COLOR_IDS[index % WORKSPACE_STATUS_COLOR_IDS.length]
}
function sanitizeWorkspaceStatusIcon(value: unknown, statusId: string): string {
function sanitizeWorkspaceStatusIcon(
value: unknown,
statusId: string,
label: string,
options: WorkspaceStatusNormalizationOptions
): string {
if (
options.migrateLegacyDefaultStatusVisuals === true &&
((statusId === 'in-progress' &&
label === 'In progress' &&
(value === 'circle-dot' || value === 'circle-progress')) ||
(statusId === 'in-review' && label === 'In review' && value === 'git-pull-request') ||
(statusId === 'completed' && label === 'Completed' && value === 'circle-check')) &&
DEFAULT_STATUS_VISUALS[statusId]
) {
return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID
}
if (typeof value === 'string' && WORKSPACE_STATUS_ICON_IDS.some((id) => id === value)) {
return value
}
@ -116,7 +162,10 @@ export function makeWorkspaceStatusId(
return `status-${Date.now().toString(36)}`
}
export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] {
function normalizeWorkspaceStatusesInternal(
value: unknown,
options: WorkspaceStatusNormalizationOptions
): WorkspaceStatusDefinition[] {
if (!Array.isArray(value)) {
return cloneDefaultWorkspaceStatuses()
}
@ -138,12 +187,71 @@ export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefin
statuses.push({
id,
label,
color: sanitizeWorkspaceStatusColor(raw.color, id, statuses.length),
icon: sanitizeWorkspaceStatusIcon(raw.icon, id)
color: sanitizeWorkspaceStatusColor(raw.color, id, label, statuses.length, options),
icon: sanitizeWorkspaceStatusIcon(raw.icon, id, label, options)
})
}
return statuses.length > 0 ? statuses : cloneDefaultWorkspaceStatuses()
if (statuses.length === 0) {
return cloneDefaultWorkspaceStatuses()
}
return statuses
}
export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefinition[] {
return normalizeWorkspaceStatusesInternal(value, {})
}
const PR_REORDERED_DEFAULT_STATUS_IDS = ['completed', 'in-review', 'in-progress', 'todo'] as const
const PR_REORDERED_DEFAULT_STATUSES = PR_REORDERED_DEFAULT_STATUS_IDS.map((id) => {
const status = DEFAULT_WORKSPACE_STATUSES.find((entry) => entry.id === id)
if (!status) {
throw new Error(`Missing default workspace status: ${id}`)
}
return { ...status }
})
function isKnownBadPRReorderedDefaultStatusPayload(value: unknown): boolean {
if (!Array.isArray(value) || value.length !== PR_REORDERED_DEFAULT_STATUSES.length) {
return false
}
return value.every((rawStatus, index) => {
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
return false
}
const raw = rawStatus as Record<string, unknown>
const expected = PR_REORDERED_DEFAULT_STATUSES[index]
return (
Object.keys(raw).length === 4 &&
raw.id === expected.id &&
raw.label === expected.label &&
raw.color === expected.color &&
raw.icon === expected.icon
)
})
}
export function normalizePersistedWorkspaceStatuses(
value: unknown,
options: {
repairReorderedDefaultStatuses?: boolean
migrateLegacyDefaultStatusVisuals?: boolean
} = {}
): WorkspaceStatusDefinition[] {
// Why: this PR briefly wrote the default columns in reverse workflow order.
// The repair is one-shot and checks the raw payload, because normalized
// IDs/labels are indistinguishable from a user-authored column reorder.
if (
options.repairReorderedDefaultStatuses === true &&
isKnownBadPRReorderedDefaultStatusPayload(value)
) {
return cloneDefaultWorkspaceStatuses()
}
return normalizeWorkspaceStatusesInternal(value, {
migrateLegacyDefaultStatusVisuals: options.migrateLegacyDefaultStatusVisuals
})
}
export function clampWorkspaceBoardOpacity(value: unknown): number {