diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 90ebf6fe5..a8c6f2b25 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -453,7 +453,8 @@ export async function createRemoteWorktree( ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}) + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) @@ -749,7 +750,8 @@ export async function createLocalWorktree( ...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), - ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}) + ...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) const worktree = mergeWorktree(repo.id, created, meta) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index ac16f3945..2f575a499 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -32,6 +32,17 @@ const LEGACY_DEFAULT_WORKSPACE_STATUSES = [ { id: 'in-review', label: 'In review', color: 'violet', icon: 'git-pull-request' }, { id: 'completed', label: 'Completed', color: 'emerald', icon: 'circle-check' } ] +const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ + { id: 'completed', label: 'Done', 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' } +] vi.mock('electron', () => ({ app: { @@ -971,32 +982,37 @@ describe('Store', () => { const store = await createStore() const ui = store.getUI() expect(ui.workspaceStatuses?.map((status) => status.id)).toEqual([ - 'todo', - 'in-progress', + 'completed', 'in-review', - 'completed' + 'in-progress', + 'todo' ]) + expect(ui.workspaceStatuses?.[0]?.label).toBe('Done') expect(ui._workspaceStatusesDefaultOrderMigrated).toBe(true) + expect(ui._workspaceStatusesDefaultWorkflowMigrated).toBe(true) store.flush() const persisted = readDataFile() as { ui?: { workspaceStatuses?: typeof REORDERED_DEFAULT_WORKSPACE_STATUSES _workspaceStatusesDefaultOrderMigrated?: boolean + _workspaceStatusesDefaultWorkflowMigrated?: boolean _workspaceStatusesDefaultVisualsMigrated?: boolean } } expect(persisted.ui?._workspaceStatusesDefaultOrderMigrated).toBe(true) + expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) expect(persisted.ui?.workspaceStatuses?.map((status) => status.id)).toEqual([ - 'todo', - 'in-progress', + 'completed', 'in-review', - 'completed' + 'in-progress', + 'todo' ]) + expect(persisted.ui?.workspaceStatuses?.[0]?.label).toBe('Done') }) - it('migrates legacy default workspace status visuals once on load', async () => { + it('migrates legacy default workspace status visuals and workflow once on load', async () => { writeDataFile({ schemaVersion: 1, repos: [], @@ -1011,25 +1027,18 @@ describe('Store', () => { }) 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().workspaceStatuses).toEqual(WORKFLOW_DEFAULT_WORKSPACE_STATUSES) + expect(store.getUI()._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(store.getUI()._workspaceStatusesDefaultVisualsMigrated).toBe(true) store.flush() const persisted = readDataFile() as { ui?: { + _workspaceStatusesDefaultWorkflowMigrated?: boolean _workspaceStatusesDefaultVisualsMigrated?: boolean } } + expect(persisted.ui?._workspaceStatusesDefaultWorkflowMigrated).toBe(true) expect(persisted.ui?._workspaceStatusesDefaultVisualsMigrated).toBe(true) }) @@ -1042,6 +1051,7 @@ describe('Store', () => { ui: { workspaceStatuses: LEGACY_DEFAULT_WORKSPACE_STATUSES, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true }, githubCache: { pr: {}, issue: {} }, @@ -1063,7 +1073,8 @@ describe('Store', () => { settings: {}, ui: { workspaceStatuses: REORDERED_DEFAULT_WORKSPACE_STATUSES, - _workspaceStatusesDefaultOrderMigrated: true + _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true }, githubCache: { pr: {}, issue: {} }, workspaceSession: {} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index d177744e7..b2abc903f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -77,6 +77,7 @@ import { normalizeTerminalQuickCommands } from '../shared/terminal-quick-command import { normalizeVisibleTaskProviders } from '../shared/task-providers' import { DEFAULT_WORKSPACE_STATUS_ID, + clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, normalizeWorkspaceBoardCompact, normalizePersistedWorkspaceStatuses, @@ -1222,6 +1223,11 @@ export class Store { const migrate = !parsed.ui?._sortBySmartMigrated && rawSort === 'recent' const workspaceStatusesDefaultOrderMigrated = parsed.ui?._workspaceStatusesDefaultOrderMigrated === true + // Why: the default workflow changed to Done -> Review -> Progress -> Todo. + // Only exact legacy default payloads are migrated; users who + // customized status labels, colors, icons, or order keep theirs. + const workspaceStatusesDefaultWorkflowMigrated = + parsed.ui?._workspaceStatusesDefaultWorkflowMigrated === 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 = @@ -1229,12 +1235,14 @@ export class Store { const workspaceStatuses = normalizePersistedWorkspaceStatuses( parsed.ui?.workspaceStatuses, { + migrateDefaultWorkflowStatuses: !workspaceStatusesDefaultWorkflowMigrated, repairReorderedDefaultStatuses: !workspaceStatusesDefaultOrderMigrated, migrateLegacyDefaultStatusVisuals: !workspaceStatusesDefaultVisualsMigrated } ) if ( !workspaceStatusesDefaultOrderMigrated || + !workspaceStatusesDefaultWorkflowMigrated || !workspaceStatusesDefaultVisualsMigrated ) { this.loadNeedsSave = true @@ -1288,6 +1296,7 @@ export class Store { sortBy: migrate ? ('smart' as const) : sort, workspaceStatuses, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, _sortBySmartMigrated: true, ...(migratedCardProps !== undefined @@ -2046,7 +2055,10 @@ export class Store { sortBy: normalizeSortBy(this.state.ui?.sortBy), workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity), - workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact) + workspaceBoardCompact: normalizeWorkspaceBoardCompact(this.state.ui?.workspaceBoardCompact), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( + this.state.ui?.workspaceBoardColumnWidth + ) } } @@ -2069,6 +2081,9 @@ export class Store { ), workspaceBoardCompact: normalizeWorkspaceBoardCompact( updates.workspaceBoardCompact ?? this.state.ui?.workspaceBoardCompact + ), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( + updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ) } this.scheduleSave() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 47dfdeae4..660cd4832 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -5129,6 +5129,7 @@ export class OrcaRuntimeService { linkedLinearIssue?: string comment?: string displayName?: string + workspaceStatus?: string sparseCheckout?: { directories: string[]; presetId?: string } pushTarget?: GitPushTarget runHooks?: boolean @@ -5296,7 +5297,8 @@ export class OrcaRuntimeService { ? { linkedLinearIssue: args.linkedLinearIssue } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), - ...(args.comment !== undefined ? { comment: args.comment } : {}) + ...(args.comment !== undefined ? { comment: args.comment } : {}), + ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) }) const worktree = mergeWorktree(repo.id, created, meta) let lineage: WorktreeLineage | null = null diff --git a/src/main/runtime/rpc/methods/worktree.test.ts b/src/main/runtime/rpc/methods/worktree.test.ts index d9a9d4ddd..8a3c62f17 100644 --- a/src/main/runtime/rpc/methods/worktree.test.ts +++ b/src/main/runtime/rpc/methods/worktree.test.ts @@ -23,6 +23,7 @@ describe('worktree RPC methods', () => { baseBranch: 'origin/main', setupDecision: 'skip', displayName: 'Feature title', + workspaceStatus: 'in-review', linkedIssue: 123, linkedPR: 456, sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, @@ -40,6 +41,7 @@ describe('worktree RPC methods', () => { linkedLinearIssue: undefined, comment: undefined, displayName: 'Feature title', + workspaceStatus: 'in-review', sparseCheckout: { directories: ['src'], presetId: 'preset-1' }, pushTarget: { remoteName: 'fork', branchName: 'feature' }, runHooks: false, diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 17922acb7..3ac7e384a 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -42,6 +42,7 @@ const WorktreeCreate = z linkedLinearIssue: z.string().optional(), comment: OptionalString, displayName: OptionalString, + workspaceStatus: OptionalString, sparseCheckout: z .object({ directories: z.array(z.string()), @@ -111,6 +112,7 @@ const WorktreeSet = WorktreeSelector.extend({ sparseBaseRef: OptionalString, sparsePresetId: OptionalString, baseRef: OptionalString, + workspaceStatus: OptionalString, pushTarget: z .object({ remoteName: z.string(), @@ -194,6 +196,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ linkedLinearIssue: params.linkedLinearIssue, comment: params.comment, displayName: params.displayName, + workspaceStatus: params.workspaceStatus, sparseCheckout: params.sparseCheckout, pushTarget: params.pushTarget, runHooks: params.runHooks === true, @@ -230,6 +233,7 @@ export const WORKTREE_METHODS: RpcMethod[] = [ sparseBaseRef: params.sparseBaseRef, sparsePresetId: params.sparsePresetId, baseRef: params.baseRef, + workspaceStatus: params.workspaceStatus, pushTarget: params.pushTarget, diffComments: params.diffComments, lineage: diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index b7e5958a1..5c78a7c6e 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -10,7 +10,11 @@ import { shouldAllowComposerEnterSubmitTarget, shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' -import type { TuiAgent, WorkspaceCreateTelemetrySource } from '../../../shared/types' +import type { + TuiAgent, + WorkspaceCreateTelemetrySource, + WorkspaceStatus +} from '../../../shared/types' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -19,6 +23,7 @@ type ComposerModalData = { initialRepoId?: string linkedWorkItem?: LinkedWorkItemSummary | null initialBaseBranch?: string + initialWorkspaceStatus?: WorkspaceStatus /** Telemetry surface that opened the composer. Set by each * `openModal('new-workspace-composer', ...)` site so * `workspace_created.source` carries the right value. Falls back to @@ -109,6 +114,7 @@ function QuickTabBody({ initialPrompt: '', initialLinkedWorkItem: modalData.linkedWorkItem ?? null, initialRepoId: modalData.initialRepoId, + initialWorkspaceStatus: modalData.initialWorkspaceStatus, ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, onCreated: onClose, diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 4626a231a..46e3d2d18 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -13,6 +13,8 @@ import { } from './workspace-status' import { useWorkspaceStatusDocumentDrop } from './use-workspace-status-drop' import { useWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' +import { useWorkspaceKanbanColumnResize } from './use-workspace-kanban-column-resize' +import { useWorkspaceKanbanCreateWorktree } from './use-workspace-kanban-create-worktree' import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection' import { isWorkspaceBoardKeepOpenTarget, @@ -47,12 +49,15 @@ export default function WorkspaceKanbanDrawer({ const setWorkspaceBoardOpacity = useAppStore((s) => s.setWorkspaceBoardOpacity) const workspaceBoardCompact = useAppStore((s) => s.workspaceBoardCompact) const setWorkspaceBoardCompact = useAppStore((s) => s.setWorkspaceBoardCompact) + const workspaceBoardColumnWidth = useAppStore((s) => s.workspaceBoardColumnWidth) + const setWorkspaceBoardColumnWidth = useAppStore((s) => s.setWorkspaceBoardColumnWidth) const sidebarOpen = useAppStore((s) => s.sidebarOpen) const sidebarWidth = useAppStore((s) => s.sidebarWidth) const boardRef = useRef(null) const areaSelectionOverlayRef = useRef(null) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree() const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({ allWorktrees, @@ -92,6 +97,8 @@ export default function WorkspaceKanbanDrawer({ selectionAnchorId, updateSelectionForArea }) + const { columnWidth, isResizingColumn, onColumnResizeStart, onColumnResizeKeyDown } = + useWorkspaceKanbanColumnResize(workspaceBoardColumnWidth, setWorkspaceBoardColumnWidth) const moveWorktreeToStatus = useCallback( (worktreeId: string, status: WorkspaceStatus) => { @@ -305,6 +312,9 @@ export default function WorkspaceKanbanDrawer({ const opacityPercent = Math.round(workspaceBoardOpacity * 100) const drawerLeft = sidebarOpen ? sidebarWidth : 0 + const drawerLeftCss = sidebarOpen + ? `var(--workspace-sidebar-live-width, ${sidebarWidth}px)` + : '0px' return ( @@ -312,15 +322,15 @@ export default function WorkspaceKanbanDrawer({ side="left" showCloseButton={false} className="workspace-kanban-sheet-content bg-sidebar p-0 sm:max-w-none" - overlayStyle={{ top: 36, left: drawerLeft, pointerEvents: 'none' }} + overlayStyle={{ top: 36, left: drawerLeftCss, pointerEvents: 'none' }} style={ { // Why: the board is a companion to the workspace sidebar, so it // expands from the sidebar edge instead of covering the sidebar. - left: drawerLeft, + left: drawerLeftCss, top: 36, height: 'calc(100% - 36px)', - width: `min(calc(100vw - ${drawerLeft}px), 1180px)`, + width: `min(calc(100vw - ${drawerLeftCss}), 1180px)`, opacity: workspaceBoardOpacity } as React.CSSProperties } @@ -343,7 +353,11 @@ export default function WorkspaceKanbanDrawer({ event.preventDefault() return } - if (originalEvent instanceof PointerEvent && originalEvent.clientX < drawerLeft) { + const liveDrawerLeft = + boardRef.current + ?.closest('[data-slot="sheet-content"]') + ?.getBoundingClientRect().left ?? drawerLeft + if (originalEvent instanceof PointerEvent && originalEvent.clientX < liveDrawerLeft) { // Why: keep the workspace sidebar interactive while the companion board stays open. event.preventDefault() } @@ -380,7 +394,7 @@ export default function WorkspaceKanbanDrawer({
{workspaceStatuses.map((status) => { @@ -394,7 +408,10 @@ export default function WorkspaceKanbanDrawer({ repoMap={repoMap} activeWorktreeId={activeWorktreeId} compact={workspaceBoardCompact} + columnWidth={columnWidth} + isResizingColumn={isResizingColumn} isDragTarget={dragOverStatus === status.id} + canCreateWorktree={canCreateWorktree} selectedWorktreeIds={selectedWorktreeIds} selectedWorktrees={selectedWorktrees} onDragOver={handleDragOver} @@ -403,6 +420,9 @@ export default function WorkspaceKanbanDrawer({ onActivate={handleWorktreeActivate} onSelectionGesture={updateSelectionForGesture} onContextMenuSelect={selectForContextMenu} + onCreateWorktree={createWorktreeForStatus} + onColumnResizeStart={onColumnResizeStart} + onColumnResizeKeyDown={onColumnResizeKeyDown} /> ) })} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx index e439fb02a..d2105d279 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanStatusLane.tsx @@ -1,6 +1,13 @@ import React from 'react' +import { Plus } from 'lucide-react' import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types' +import { + WORKSPACE_BOARD_COLUMN_WIDTH_MAX, + WORKSPACE_BOARD_COLUMN_WIDTH_MIN +} from '../../../../shared/workspace-statuses' import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import WorkspaceKanbanCard from './WorkspaceKanbanCard' import { getWorkspaceStatusVisualMeta } from './workspace-status' @@ -10,7 +17,10 @@ type WorkspaceKanbanStatusLaneProps = { repoMap: Map activeWorktreeId: string | null compact: boolean + columnWidth: number + isResizingColumn: boolean isDragTarget: boolean + canCreateWorktree: boolean selectedWorktreeIds: ReadonlySet selectedWorktrees: readonly Worktree[] onDragOver: (event: React.DragEvent, statusId: string) => void @@ -22,6 +32,9 @@ type WorkspaceKanbanStatusLaneProps = { event: React.MouseEvent, worktree: Worktree ) => readonly Worktree[] + onCreateWorktree: (statusId: string) => void + onColumnResizeStart: (event: React.PointerEvent) => void + onColumnResizeKeyDown: (event: React.KeyboardEvent) => void } export default function WorkspaceKanbanStatusLane({ @@ -30,7 +43,10 @@ export default function WorkspaceKanbanStatusLane({ repoMap, activeWorktreeId, compact, + columnWidth, + isResizingColumn, isDragTarget, + canCreateWorktree, selectedWorktreeIds, selectedWorktrees, onDragOver, @@ -38,16 +54,36 @@ export default function WorkspaceKanbanStatusLane({ onDrop, onActivate, onSelectionGesture, - onContextMenuSelect + onContextMenuSelect, + onCreateWorktree, + onColumnResizeStart, + onColumnResizeKeyDown }: WorkspaceKanbanStatusLaneProps): React.JSX.Element { const meta = getWorkspaceStatusVisualMeta(status) + const createTooltip = canCreateWorktree + ? `New workspace in ${status.label}` + : 'Add a Git project to create worktrees' + const createButton = ( + + ) return (
onDrop(event, status.id)} > -
- -
- {status.label} -
-
- {items.length} +
event.stopPropagation()} + > + +
+
+
+ +
+ {status.label} +
+
+ {items.length} +
+ + {createButton} + + {createTooltip} + +
@@ -94,6 +164,27 @@ export default function WorkspaceKanbanStatusLane({ Empty
)} + + + + + + {createTooltip} + +
) diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index 6f0466110..0f7e13935 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -31,6 +31,10 @@ function Sidebar({ const repos = useAppStore((s) => s.repos) const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees) + const setLiveSidebarWidth = React.useCallback((width: number) => { + document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`) + }, []) + // Fetch worktrees when repos are added/removed const repoCount = repos.length useEffect(() => { @@ -45,9 +49,14 @@ function Sidebar({ minWidth: MIN_WIDTH, maxWidth: MAX_WIDTH, deltaSign: 1, - setWidth: setSidebarWidth + setWidth: setSidebarWidth, + onDraftWidthChange: setLiveSidebarWidth }) + useEffect(() => { + setLiveSidebarWidth(sidebarWidth) + }, [setLiveSidebarWidth, sidebarWidth]) + return (
diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts new file mode 100644 index 000000000..23c16e482 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type React from 'react' +import { + WORKSPACE_BOARD_COLUMN_WIDTH_STEP, + clampWorkspaceBoardColumnWidth +} from '../../../../shared/workspace-statuses' + +type UseWorkspaceKanbanColumnResizeResult = { + columnWidth: number + isResizingColumn: boolean + onColumnResizeStart: (event: React.PointerEvent) => void + onColumnResizeKeyDown: (event: React.KeyboardEvent) => void +} + +export function useWorkspaceKanbanColumnResize( + committedWidth: number, + onCommitWidth: (width: number) => void +): UseWorkspaceKanbanColumnResizeResult { + const [columnWidth, setColumnWidth] = useState(() => + clampWorkspaceBoardColumnWidth(committedWidth) + ) + const [isResizingColumn, setIsResizingColumn] = useState(false) + const committedWidthRef = useRef(clampWorkspaceBoardColumnWidth(committedWidth)) + const commitWidthRef = useRef(onCommitWidth) + const resizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(columnWidth) + const draftWidthRef = useRef(columnWidth) + const frameRef = useRef(null) + + useEffect(() => { + commitWidthRef.current = onCommitWidth + }, [onCommitWidth]) + + useEffect(() => { + const nextWidth = clampWorkspaceBoardColumnWidth(committedWidth) + committedWidthRef.current = nextWidth + if (resizingRef.current) { + return + } + draftWidthRef.current = nextWidth + setColumnWidth(nextWidth) + }, [committedWidth]) + + const resetDocumentStyles = useCallback(() => { + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) + + const publishDraftWidth = useCallback((width: number) => { + const nextWidth = clampWorkspaceBoardColumnWidth(width) + if (nextWidth === draftWidthRef.current) { + return + } + draftWidthRef.current = nextWidth + if (frameRef.current !== null) { + return + } + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null + setColumnWidth(draftWidthRef.current) + }) + }, []) + + const commitDraftWidth = useCallback(() => { + const nextWidth = clampWorkspaceBoardColumnWidth(draftWidthRef.current) + setColumnWidth(nextWidth) + if (nextWidth !== committedWidthRef.current) { + committedWidthRef.current = nextWidth + commitWidthRef.current(nextWidth) + } + }, []) + + const stopResize = useCallback(() => { + if (!resizingRef.current) { + return + } + resizingRef.current = false + setIsResizingColumn(false) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + resetDocumentStyles() + commitDraftWidth() + }, [commitDraftWidth, resetDocumentStyles]) + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + if (!resizingRef.current) { + return + } + publishDraftWidth(startWidthRef.current + event.clientX - startXRef.current) + }, + [publishDraftWidth] + ) + + useEffect(() => { + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', stopResize) + window.addEventListener('pointercancel', stopResize) + window.addEventListener('blur', stopResize) + + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', stopResize) + window.removeEventListener('pointercancel', stopResize) + window.removeEventListener('blur', stopResize) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + resizingRef.current = false + resetDocumentStyles() + } + }, [handlePointerMove, resetDocumentStyles, stopResize]) + + const onColumnResizeStart = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) { + return + } + event.preventDefault() + event.stopPropagation() + resizingRef.current = true + setIsResizingColumn(true) + startXRef.current = event.clientX + startWidthRef.current = draftWidthRef.current + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, []) + + const onColumnResizeKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + event.preventDefault() + event.stopPropagation() + const direction = event.key === 'ArrowRight' ? 1 : -1 + const step = WORKSPACE_BOARD_COLUMN_WIDTH_STEP * (event.shiftKey ? 2 : 1) + publishDraftWidth(draftWidthRef.current + direction * step) + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + commitDraftWidth() + }, + [commitDraftWidth, publishDraftWidth] + ) + + return { + columnWidth, + isResizingColumn, + onColumnResizeStart, + onColumnResizeKeyDown + } +} diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts new file mode 100644 index 000000000..eca6f9785 --- /dev/null +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-create-worktree.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react' +import { useAppStore } from '@/store' +import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { WorkspaceStatus } from '../../../../shared/types' + +export function useWorkspaceKanbanCreateWorktree(): { + canCreateWorktree: boolean + createWorktreeForStatus: (workspaceStatus: WorkspaceStatus) => void +} { + const openModal = useAppStore((s) => s.openModal) + const canCreateWorktree = useAppStore((s) => s.repos.some((repo) => isGitRepoKind(repo))) + + const createWorktreeForStatus = useCallback( + (workspaceStatus: WorkspaceStatus) => { + openModal('new-workspace-composer', { + telemetrySource: 'sidebar', + initialWorkspaceStatus: workspaceStatus + }) + }, + [openModal] + ) + + return { canCreateWorktree, createWorktreeForStatus } +} 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 c5974e473..683fc3ac0 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -108,22 +108,16 @@ describe('buildRows with pinned worktrees', () => { } }) - it('keeps an empty pinned drop section above statuses in groupBy none', () => { + it('omits empty pinned sections in groupBy none', () => { const rows = buildRows('none', [unpinned1, unpinned2], repoMap, null, new Set()) expect(rows[0]).toMatchObject({ - type: 'header', - key: 'pinned', - label: 'Pinned', - count: 0 - }) - expect(rows[1]).toMatchObject({ type: 'header', key: 'workspace-status:in-progress', label: 'In progress', count: 2 }) - expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) - expect(rows[3]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) + expect(rows[1]).toMatchObject({ type: 'item', worktree: { id: 'wt-1' } }) + expect(rows[2]).toMatchObject({ type: 'item', worktree: { id: 'wt-2' } }) }) it('collapses pinned group when in collapsedGroups', () => { @@ -190,10 +184,7 @@ describe('buildRows with pinned worktrees', () => { rows .filter((r) => r.type === 'header') .map((r) => ({ key: r.key, label: r.label, count: r.count })) - ).toEqual([ - { key: 'pinned', label: 'Pinned', count: 0 }, - { key: 'workspace-status:in-review', label: 'In review', count: 1 } - ]) + ).toEqual([{ key: 'workspace-status:in-review', label: 'In review', count: 1 }]) }) it('uses customized workspace status labels and order', () => { @@ -219,7 +210,6 @@ describe('buildRows with pinned worktrees', () => { .filter((r) => r.type === 'header') .map((r) => ({ key: r.key, label: r.label, count: r.count })) ).toEqual([ - { key: 'pinned', label: 'Pinned', count: 0 }, { key: 'workspace-status:blocked', label: 'Blocked', count: 1 }, { key: 'workspace-status:in-progress', label: 'Doing', count: 1 } ]) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index cb717cf8f..b3d886a23 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -181,11 +181,10 @@ function emitPinnedGroup( worktreeMap: Map, collapsedGroups: Set, result: Row[], - showLineageContext: boolean, - force = false + showLineageContext: boolean ): Set { const pinned = worktrees.filter((w) => w.isPinned) - if (pinned.length === 0 && !force) { + if (pinned.length === 0) { return new Set() } @@ -371,8 +370,7 @@ export function buildRows( worktreeMap, collapsedGroups, result, - nestLineage, - groupBy === 'none' + nestLineage ) const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index a401775b6..e4e659940 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -29,8 +29,10 @@ import type { SparsePreset, TuiAgent, WorktreeMeta, + WorkspaceStatus, WorkspaceCreateTelemetrySource } from '../../../shared/types' +import { isWorkspaceStatusId } from '../../../shared/workspace-statuses' import { ADD_ATTACHMENT_SHORTCUT, CLIENT_PLATFORM, @@ -72,6 +74,7 @@ export type UseComposerStateOptions = { initialName?: string initialPrompt?: string initialLinkedWorkItem?: LinkedWorkItemSummary | null + initialWorkspaceStatus?: WorkspaceStatus /** Seed the Start-from selection when the composer opens. Used by the * Create-from → Quick fallback path so a PR pick that needs a setup * decision still lands with the resolved PR head as the base branch. */ @@ -211,6 +214,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS initialName = '', initialPrompt = '', initialLinkedWorkItem = null, + initialWorkspaceStatus, initialBaseBranch, persistDraft, onCreated, @@ -262,8 +266,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo) + const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos]) const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null + const resolvedInitialWorkspaceStatus = useMemo( + () => + initialWorkspaceStatus && isWorkspaceStatusId(initialWorkspaceStatus, workspaceStatuses) + ? initialWorkspaceStatus + : undefined, + [initialWorkspaceStatus, workspaceStatuses] + ) const resolvedInitialRepoId = draftRepoId && eligibleRepos.some((repo) => repo.id === draftRepoId) @@ -1599,7 +1611,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS effectiveLinkedPR ?? undefined, pushTarget, tuiAgent, - linkedLinearIssue + linkedLinearIssue, + resolvedInitialWorkspaceStatus ) const worktree = result.worktree @@ -1696,6 +1709,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId, requiresExplicitSetupChoice, resolvedSetupDecision, + resolvedInitialWorkspaceStatus, selectedRepo, settings?.agentCmdOverrides, settings?.rightSidebarOpenByDefault, @@ -1787,7 +1801,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS effectiveLinkedPR ?? undefined, pushTarget, agent ?? undefined, - linkedLinearIssue + linkedLinearIssue, + resolvedInitialWorkspaceStatus ) const worktree = result.worktree @@ -1927,6 +1942,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS repoId, requiresExplicitSetupChoice, resolvedSetupDecision, + resolvedInitialWorkspaceStatus, selectedRepo, settings?.agentCmdOverrides, settings?.rightSidebarOpenByDefault, diff --git a/src/renderer/src/hooks/useSidebarResize.ts b/src/renderer/src/hooks/useSidebarResize.ts index 5104b8867..b2c3453fa 100644 --- a/src/renderer/src/hooks/useSidebarResize.ts +++ b/src/renderer/src/hooks/useSidebarResize.ts @@ -8,6 +8,7 @@ type UseSidebarResizeOptions = { deltaSign: 1 | -1 renderedExtraWidth?: number setWidth: (width: number) => void + onDraftWidthChange?: (width: number) => void } type UseSidebarResizeResult = { @@ -54,7 +55,8 @@ export function useSidebarResize({ maxWidth, deltaSign, renderedExtraWidth = 0, - setWidth + setWidth, + onDraftWidthChange }: UseSidebarResizeOptions): UseSidebarResizeResult { const containerRef = useRef(null) const isResizingRef = useRef(false) @@ -102,7 +104,8 @@ export function useSidebarResize({ draftWidthRef.current = width applyRenderedWidth(width) - }, [applyRenderedWidth, width]) + onDraftWidthChange?.(width) + }, [applyRenderedWidth, onDraftWidthChange, width]) const stopResize = useCallback(() => { if (!isResizingRef.current) { @@ -121,10 +124,11 @@ export function useSidebarResize({ const finalWidth = draftWidthRef.current applyRenderedWidth(finalWidth) + onDraftWidthChange?.(finalWidth) if (finalWidth !== width) { setWidth(finalWidth) } - }, [applyRenderedWidth, resetDocumentStyles, setWidth, width]) + }, [applyRenderedWidth, onDraftWidthChange, resetDocumentStyles, setWidth, width]) const handleMouseMove = useCallback( (event: MouseEvent) => { @@ -152,9 +156,10 @@ export function useSidebarResize({ frameRef.current = window.requestAnimationFrame(() => { frameRef.current = null applyRenderedWidth(draftWidthRef.current) + onDraftWidthChange?.(draftWidthRef.current) }) }, - [applyRenderedWidth, deltaSign, maxWidth, minWidth] + [applyRenderedWidth, deltaSign, maxWidth, minWidth, onDraftWidthChange] ) useEffect(() => { @@ -185,6 +190,7 @@ export function useSidebarResize({ startXRef.current = event.clientX startWidthRef.current = width draftWidthRef.current = width + onDraftWidthChange?.(width) document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' @@ -205,7 +211,7 @@ export function useSidebarResize({ overlayRef.current = overlay } }, - [width] + [onDraftWidthChange, width] ) return { containerRef, isResizing, onResizeStart } diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index ac0bbef21..ce60490cf 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -134,6 +134,18 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().workspaceBoardCompact).toBe(false) }) + it('clamps persisted workspace board column width', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + workspaceBoardColumnWidth: 900 + }) + ) + + expect(store.getState().workspaceBoardColumnWidth).toBe(520) + }) + it('hydrates a valid Kagi session link', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index d61a9cebf..7f8990221 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -31,6 +31,8 @@ import { DEFAULT_WORKTREE_CARD_PROPERTIES } from '../../../../shared/constants' import { + WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, + clampWorkspaceBoardColumnWidth, clampWorkspaceBoardOpacity, cloneDefaultWorkspaceStatuses, normalizeWorkspaceBoardCompact, @@ -372,6 +374,8 @@ export type UISlice = { setWorkspaceBoardOpacity: (opacity: number) => void workspaceBoardCompact: boolean setWorkspaceBoardCompact: (compact: boolean) => void + workspaceBoardColumnWidth: number + setWorkspaceBoardColumnWidth: (width: number) => void statusBarItems: StatusBarItem[] toggleStatusBarItem: (item: StatusBarItem) => void statusBarVisible: boolean @@ -779,6 +783,13 @@ export const createUISlice: StateCreator = (set, get) set({ workspaceBoardCompact: normalized }) }, + workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, + setWorkspaceBoardColumnWidth: (width) => { + const clamped = clampWorkspaceBoardColumnWidth(width) + window.api.ui.set({ workspaceBoardColumnWidth: clamped }).catch(console.error) + set({ workspaceBoardColumnWidth: clamped }) + }, + statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], toggleStatusBarItem: (item) => set((s) => { @@ -922,6 +933,7 @@ export const createUISlice: StateCreator = (set, get) workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity), workspaceBoardCompact: normalizeWorkspaceBoardCompact(ui.workspaceBoardCompact), + workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth), statusBarItems: migrateStatusBarItems(ui.statusBarItems), statusBarVisible: ui.statusBarVisible ?? true, // Why: absent → true so existing users see the pet the first time diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 5dcc863c4..da668954a 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -5,6 +5,7 @@ import type { SetupDecision, TuiAgent, WorkspaceCreateTelemetrySource, + WorkspaceStatus, Worktree, WorktreeBaseStatusEvent, WorktreeLineage, @@ -84,7 +85,8 @@ export type WorktreeSlice = { linkedPR?: number, pushTarget?: GitPushTarget, createdWithAgent?: TuiAgent, - linkedLinearIssue?: string + linkedLinearIssue?: string, + workspaceStatus?: WorkspaceStatus ) => Promise removeWorktree: ( worktreeId: string, diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 14cfbbbe2..1294c21ee 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -590,7 +590,8 @@ describe('createWorktree base status merge', () => { linkedIssue: 123, linkedPR: 456, createdWithAgent: 'codex', - linkedLinearIssue: 'ENG-123' + linkedLinearIssue: 'ENG-123', + workspaceStatus: 'in-review' }) mockApi.worktrees.create.mockResolvedValue({ worktree: wt }) @@ -608,7 +609,8 @@ describe('createWorktree base status merge', () => { 456, undefined, 'codex', - 'ENG-123' + 'ENG-123', + 'in-review' ) expect(mockApi.worktrees.create).toHaveBeenCalledWith( @@ -618,14 +620,16 @@ describe('createWorktree base status merge', () => { linkedIssue: 123, linkedPR: 456, createdWithAgent: 'codex', - linkedLinearIssue: 'ENG-123' + linkedLinearIssue: 'ENG-123', + workspaceStatus: 'in-review' }) ) expect(store.getState().worktreesByRepo.repo1[0]).toMatchObject({ linkedIssue: 123, linkedPR: 456, createdWithAgent: 'codex', - linkedLinearIssue: 'ENG-123' + linkedLinearIssue: 'ENG-123', + workspaceStatus: 'in-review' }) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c55e3ee26..4f84e145e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -422,7 +422,8 @@ export const createWorktreeSlice: StateCreator linkedPR, pushTarget, createdWithAgent, - linkedLinearIssue + linkedLinearIssue, + workspaceStatus ) => { const retryableConflictPatterns = [ /already exists locally/i, @@ -448,7 +449,8 @@ export const createWorktreeSlice: StateCreator ...(linkedPR !== undefined ? { linkedPR } : {}), ...(pushTarget ? { pushTarget } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), - ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}) + ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), + ...(workspaceStatus !== undefined ? { workspaceStatus } : {}) } const target = getActiveRuntimeTarget(get().settings) const result = @@ -468,7 +470,8 @@ export const createWorktreeSlice: StateCreator ...(linkedPR !== undefined ? { linkedPR } : {}), ...(pushTarget ? { pushTarget } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), - ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}) + ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), + ...(workspaceStatus !== undefined ? { workspaceStatus } : {}) }, { timeoutMs: 10 * 60_000 } ) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 8d7dd9fe9..92e5643bd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -6,16 +6,18 @@ import type { PersistedState, PersistedUIState, RepoHookSettings, - StatusBarItem, WorkspaceSessionState, WorktreeCardProperty } from './types' +import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' import { TASK_PROVIDERS } from './task-providers' +export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' + export const SCHEMA_VERSION = 1 export const DEFAULT_APP_FONT_FAMILY = 'Geist' @@ -95,15 +97,6 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ 'inline-agents' ] -export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ - 'claude', - 'codex', - 'gemini', - 'opencode-go', - 'ssh', - 'resource-usage' -] - /** Synthetic worktree id used by the memory collector to bucket PTYs that * are not associated with any worktree. Shared across main and renderer so * the collector and the status-bar popover agree on the sentinel. */ @@ -358,7 +351,9 @@ export function getDefaultUIState(): PersistedUIState { workspaceStatuses: cloneDefaultWorkspaceStatuses(), workspaceBoardOpacity: 1, workspaceBoardCompact: false, + workspaceBoardColumnWidth: 308, _workspaceStatusesDefaultOrderMigrated: true, + _workspaceStatusesDefaultWorkflowMigrated: true, _workspaceStatusesDefaultVisualsMigrated: true, statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS], statusBarVisible: true, diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts new file mode 100644 index 000000000..9d4b6f635 --- /dev/null +++ b/src/shared/status-bar-defaults.ts @@ -0,0 +1,10 @@ +import type { StatusBarItem } from './types' + +export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ + 'claude', + 'codex', + 'gemini', + 'opencode-go', + 'ssh', + 'resource-usage' +] diff --git a/src/shared/types.ts b/src/shared/types.ts index 50c1ae197..83dea66ff 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1023,6 +1023,7 @@ export type CreateWorktreeArgs = { linkedPR?: number linkedLinearIssue?: string pushTarget?: GitPushTarget + workspaceStatus?: WorkspaceStatus /** Agent selected in the create surface. Omitted for blank-shell creates. */ createdWithAgent?: TuiAgent /** Telemetry-only: which UI surface initiated this create. Threaded from @@ -1702,10 +1703,15 @@ export type PersistedUIState = { workspaceStatuses?: WorkspaceStatusDefinition[] workspaceBoardOpacity?: number workspaceBoardCompact?: boolean + workspaceBoardColumnWidth?: number /** 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 default status workflow order/label: + * Done -> In review -> In progress -> Todo. Exact legacy default payloads + * migrate; customized statuses are preserved. */ + _workspaceStatusesDefaultWorkflowMigrated?: 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 diff --git a/src/shared/workspace-status-default-migration.ts b/src/shared/workspace-status-default-migration.ts new file mode 100644 index 000000000..6e56f8b5d --- /dev/null +++ b/src/shared/workspace-status-default-migration.ts @@ -0,0 +1,82 @@ +const LEGACY_DEFAULT_STATUS_LABELS: Record = { + todo: 'Todo', + 'in-progress': 'In progress', + 'in-review': 'In review', + completed: 'Completed' +} + +const CONDUCTOR_DEFAULT_STATUS_VISUALS: Record = { + todo: { color: 'neutral', icon: 'circle' }, + 'in-progress': { color: 'conductor-progress', icon: 'conductor-progress' }, + 'in-review': { color: 'conductor-review', icon: 'conductor-review' }, + completed: { color: 'conductor-done', icon: 'conductor-done' } +} + +const LEGACY_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' } +} + +const LEGACY_TODO_FIRST_DEFAULT_STATUS_IDS = [ + 'todo', + 'in-progress', + 'in-review', + 'completed' +] as const +const WORKFLOW_DEFAULT_STATUS_IDS = ['completed', 'in-review', 'in-progress', 'todo'] as const + +function isLegacyDefaultStatusPayload( + value: unknown, + orderedIds: readonly string[], + visuals: Record +): boolean { + if (!Array.isArray(value) || value.length !== orderedIds.length) { + return false + } + return value.every((rawStatus, index) => { + if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) { + return false + } + const raw = rawStatus as Record + const expectedId = orderedIds[index]! + const expectedVisual = visuals[expectedId] + return ( + Object.keys(raw).length === 4 && + raw.id === expectedId && + raw.label === LEGACY_DEFAULT_STATUS_LABELS[expectedId] && + raw.color === expectedVisual?.color && + raw.icon === expectedVisual?.icon + ) + }) +} + +export function isLegacyDefaultWorkflowStatusPayload(value: unknown): boolean { + return ( + isLegacyDefaultStatusPayload( + value, + LEGACY_TODO_FIRST_DEFAULT_STATUS_IDS, + CONDUCTOR_DEFAULT_STATUS_VISUALS + ) || + isLegacyDefaultStatusPayload( + value, + LEGACY_TODO_FIRST_DEFAULT_STATUS_IDS, + LEGACY_DEFAULT_STATUS_VISUALS + ) || + isLegacyDefaultStatusPayload( + value, + WORKFLOW_DEFAULT_STATUS_IDS, + CONDUCTOR_DEFAULT_STATUS_VISUALS + ) || + isLegacyDefaultStatusPayload(value, WORKFLOW_DEFAULT_STATUS_IDS, LEGACY_DEFAULT_STATUS_VISUALS) + ) +} + +export function isKnownBadPRReorderedDefaultStatusPayload(value: unknown): boolean { + return isLegacyDefaultStatusPayload( + value, + WORKFLOW_DEFAULT_STATUS_IDS, + CONDUCTOR_DEFAULT_STATUS_VISUALS + ) +} diff --git a/src/shared/workspace-status-defaults.ts b/src/shared/workspace-status-defaults.ts new file mode 100644 index 000000000..a698c9a37 --- /dev/null +++ b/src/shared/workspace-status-defaults.ts @@ -0,0 +1,20 @@ +import type { WorkspaceStatusDefinition } from './types' + +export const DEFAULT_STATUS_VISUALS: Record = { + todo: { color: 'neutral', icon: 'circle' }, + '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: 'completed', label: 'Done', 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' } +] as const satisfies readonly WorkspaceStatusDefinition[] diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index 57eb42556..ed8e8b7e5 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -1,18 +1,47 @@ import { describe, expect, it } from 'vitest' import { + WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, + WORKSPACE_BOARD_COLUMN_WIDTH_MAX, + WORKSPACE_BOARD_COLUMN_WIDTH_MIN, + clampWorkspaceBoardColumnWidth, cloneDefaultWorkspaceStatuses, normalizePersistedWorkspaceStatuses, normalizeWorkspaceStatuses } from './workspace-statuses' describe('workspace status visuals', () => { - it('keeps todo first by default', () => { + it('keeps the default workflow order', () => { expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([ - 'todo', - 'in-progress', + 'completed', 'in-review', - 'completed' + 'in-progress', + 'todo' ]) + expect(cloneDefaultWorkspaceStatuses()[0]).toMatchObject({ id: 'completed', label: 'Done' }) + }) + + it('migrates legacy default statuses to the default workflow order', () => { + const statuses = normalizePersistedWorkspaceStatuses( + [ + { 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' } + ], + { migrateDefaultWorkflowStatuses: true } + ) + + expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) }) it('migrates the old default status visuals without reordering the board', () => { @@ -32,7 +61,12 @@ describe('workspace status visuals', () => { 'in-review', 'completed' ]) - expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) + expect(statuses.map((status) => status.color)).toEqual([ + 'neutral', + 'conductor-progress', + 'conductor-review', + 'conductor-done' + ]) }) it('preserves explicit status order while migrating default visuals', () => { @@ -58,7 +92,7 @@ describe('workspace status visuals', () => { }) }) - it('preserves default-label reordered statuses unless the one-shot repair is requested', () => { + it('preserves default-label reordered statuses unless a default migration 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' }, @@ -79,6 +113,30 @@ describe('workspace status visuals', () => { ]) }) + it('migrates exact reordered default statuses to the new Done label when 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' } + ], + { migrateDefaultWorkflowStatuses: true } + ) + + expect(statuses).toEqual(cloneDefaultWorkspaceStatuses()) + }) + it('repairs the exact PR-introduced default status reorder when migration-gated', () => { const statuses = normalizePersistedWorkspaceStatuses( [ @@ -184,4 +242,11 @@ describe('workspace status visuals', () => { icon: 'circle-dot' }) }) + + it('clamps workspace board column widths to resizable bounds', () => { + expect(clampWorkspaceBoardColumnWidth(undefined)).toBe(WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT) + expect(clampWorkspaceBoardColumnWidth(100)).toBe(WORKSPACE_BOARD_COLUMN_WIDTH_MIN) + expect(clampWorkspaceBoardColumnWidth(321.6)).toBe(322) + expect(clampWorkspaceBoardColumnWidth(900)).toBe(WORKSPACE_BOARD_COLUMN_WIDTH_MAX) + }) }) diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index f79ad0bc6..2d053dddb 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -1,15 +1,27 @@ import type { Worktree, WorkspaceStatus, WorkspaceStatusDefinition } from './types' +import { DEFAULT_STATUS_VISUALS, DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' +import { + isKnownBadPRReorderedDefaultStatusPayload, + isLegacyDefaultWorkflowStatusPayload +} from './workspace-status-default-migration' + +export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' const MAX_STATUS_LABEL_LENGTH = 32 const MAX_WORKSPACE_STATUSES = 12 type WorkspaceStatusNormalizationOptions = { + migrateDefaultWorkflowStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean } export const DEFAULT_WORKSPACE_STATUS_ID: WorkspaceStatus = 'in-progress' export const DEFAULT_WORKSPACE_STATUS_COLOR_ID = 'neutral' export const DEFAULT_WORKSPACE_STATUS_ICON_ID = 'circle-dot' +export const WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT = 308 +export const WORKSPACE_BOARD_COLUMN_WIDTH_MIN = 220 +export const WORKSPACE_BOARD_COLUMN_WIDTH_MAX = 520 +export const WORKSPACE_BOARD_COLUMN_WIDTH_STEP = 20 export const WORKSPACE_STATUS_COLOR_IDS = [ 'neutral', @@ -44,25 +56,6 @@ export const WORKSPACE_STATUS_ICON_IDS = [ 'conductor-progress' ] as const -const DEFAULT_STATUS_VISUALS: Record = { - todo: { color: 'neutral', icon: 'circle' }, - '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: '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[] { return DEFAULT_WORKSPACE_STATUSES.map((status) => ({ ...status })) } @@ -106,7 +99,9 @@ function sanitizeWorkspaceStatusColor( 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')) && + (statusId === 'completed' && + (label === 'Completed' || label === 'Done') && + value === 'emerald')) && DEFAULT_STATUS_VISUALS[statusId] ) { return DEFAULT_STATUS_VISUALS[statusId]?.color ?? DEFAULT_WORKSPACE_STATUS_COLOR_ID @@ -133,7 +128,9 @@ function sanitizeWorkspaceStatusIcon( 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')) && + (statusId === 'completed' && + (label === 'Completed' || label === 'Done') && + value === 'circle-check')) && DEFAULT_STATUS_VISUALS[statusId] ) { return DEFAULT_STATUS_VISUALS[statusId]?.icon ?? DEFAULT_WORKSPACE_STATUS_ICON_ID @@ -203,43 +200,20 @@ export function normalizeWorkspaceStatuses(value: unknown): WorkspaceStatusDefin 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: { + migrateDefaultWorkflowStatuses?: boolean repairReorderedDefaultStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean } = {} ): WorkspaceStatusDefinition[] { + if ( + options.migrateDefaultWorkflowStatuses === true && + isLegacyDefaultWorkflowStatusPayload(value) + ) { + return cloneDefaultWorkspaceStatuses() + } // 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. @@ -265,6 +239,16 @@ export function normalizeWorkspaceBoardCompact(value: unknown): boolean { return value === true } +export function clampWorkspaceBoardColumnWidth(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT + } + return Math.min( + WORKSPACE_BOARD_COLUMN_WIDTH_MAX, + Math.max(WORKSPACE_BOARD_COLUMN_WIDTH_MIN, Math.round(value)) + ) +} + export function isWorkspaceStatusId( value: string, statuses: readonly WorkspaceStatusDefinition[]