diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index f01d37e34..8aeeb4412 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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 () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 3f94e7c65..d177744e7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 | null = null private writeGeneration = 0 private gitUsernameCache = new Map() + 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 ), diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index 83622aa6a..ba08709a4 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -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 ( <>
diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 1b87d8c9b..1f6287c1f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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 )} > - +
) : null} diff --git a/src/renderer/src/components/sidebar/workspace-status-icons.tsx b/src/renderer/src/components/sidebar/workspace-status-icons.tsx new file mode 100644 index 000000000..5005c5a34 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-status-icons.tsx @@ -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' + }) + ) +} diff --git a/src/renderer/src/components/sidebar/workspace-status.ts b/src/renderer/src/components/sidebar/workspace-status.ts index f1959c384..0aaef05a7 100644 --- a/src/renderer/src/components/sidebar/workspace-status.ts +++ b/src/renderer/src/components/sidebar/workspace-status.ts @@ -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' } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index f7cea6194..c5974e473 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -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) => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 0bbb191ba..cb717cf8f 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -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() for (const w of unpinned) { let key: string @@ -501,6 +517,9 @@ export function getGroupKeyForWorktree( prCache: Record | null, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses() ): string | null { + if (groupBy === 'flat') { + return null + } if (groupBy === 'none') { return getWorkspaceStatusGroupKey(getWorkspaceStatus(worktree, workspaceStatuses)) } diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index a134ef1b3..6c8b3af79 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9d22f7c1f..bd201a785 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 802ddd141..2fae8cfdb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts new file mode 100644 index 000000000..57eb42556 --- /dev/null +++ b/src/shared/workspace-statuses.test.ts @@ -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' + }) + }) +}) diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 7163d661a..f79ad0bc6 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -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 = { 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 + 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 {