Polish workspace board resizing and status defaults (#2113)
* Add workspace board live resize behavior * Add workspace board create controls * Hide empty status sidebar sections * Update default workspace status order
This commit is contained in:
parent
9943bba8ab
commit
ffcb97b96a
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null)
|
||||
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
|
||||
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
|
|
@ -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<HTMLElement>('[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({
|
|||
<div
|
||||
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${workspaceStatuses.length}, minmax(240px, 1fr))`
|
||||
gridTemplateColumns: `repeat(${workspaceStatuses.length}, minmax(${columnWidth}px, ${columnWidth}px))`
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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<string, Repo>
|
||||
activeWorktreeId: string | null
|
||||
compact: boolean
|
||||
columnWidth: number
|
||||
isResizingColumn: boolean
|
||||
isDragTarget: boolean
|
||||
canCreateWorktree: boolean
|
||||
selectedWorktreeIds: ReadonlySet<string>
|
||||
selectedWorktrees: readonly Worktree[]
|
||||
onDragOver: (event: React.DragEvent, statusId: string) => void
|
||||
|
|
@ -22,6 +32,9 @@ type WorkspaceKanbanStatusLaneProps = {
|
|||
event: React.MouseEvent<HTMLElement>,
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
onCreateWorktree: (statusId: string) => void
|
||||
onColumnResizeStart: (event: React.PointerEvent<HTMLElement>) => void
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => 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 = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-6 text-muted-foreground"
|
||||
aria-label={createTooltip}
|
||||
disabled={!canCreateWorktree}
|
||||
onClick={() => onCreateWorktree(status.id)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<section
|
||||
data-workspace-status-drop-target=""
|
||||
data-workspace-status={status.id}
|
||||
className={cn(
|
||||
'flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-t-2 border-sidebar-border transition-colors',
|
||||
'group/lane',
|
||||
'relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-md border border-t-2 border-sidebar-border transition-colors',
|
||||
meta.border,
|
||||
meta.laneTint,
|
||||
isDragTarget && 'border-sidebar-ring bg-sidebar-accent/70'
|
||||
|
|
@ -56,14 +92,48 @@ export default function WorkspaceKanbanStatusLane({
|
|||
onDragLeave={onDragLeave}
|
||||
onDrop={(event) => onDrop(event, status.id)}
|
||||
>
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/70 px-3">
|
||||
<meta.icon className={cn('size-3.5', meta.tone)} />
|
||||
<div className="min-w-0 flex-1 truncate text-[12px] font-semibold text-foreground">
|
||||
{status.label}
|
||||
</div>
|
||||
<div className="rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{items.length}
|
||||
<div
|
||||
data-workspace-board-column-resize-handle=""
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize workspace board columns"
|
||||
aria-valuemin={WORKSPACE_BOARD_COLUMN_WIDTH_MIN}
|
||||
aria-valuemax={WORKSPACE_BOARD_COLUMN_WIDTH_MAX}
|
||||
aria-valuenow={columnWidth}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group absolute right-0 top-0 z-20 h-9 w-2 cursor-col-resize outline-none',
|
||||
'focus-visible:ring-1 focus-visible:ring-sidebar-ring',
|
||||
isResizingColumn && 'cursor-col-resize'
|
||||
)}
|
||||
onPointerDown={onColumnResizeStart}
|
||||
onKeyDown={onColumnResizeKeyDown}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-y-2 left-1/2 w-px -translate-x-1/2 rounded-full bg-transparent transition-colors',
|
||||
'group-hover:bg-sidebar-ring/55 group-focus-visible:bg-sidebar-ring',
|
||||
isResizingColumn && 'bg-sidebar-ring'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/70 py-0 pl-3 pr-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<meta.icon className={cn('size-3.5 shrink-0', meta.tone)} />
|
||||
<div className="min-w-0 truncate text-[12px] font-semibold text-foreground">
|
||||
{status.label}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{items.length}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{createButton}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{createTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-1.5 py-2 scrollbar-sleek">
|
||||
|
|
@ -94,6 +164,27 @@ export default function WorkspaceKanbanStatusLane({
|
|||
Empty
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className={cn(
|
||||
'mt-2 h-7 w-full opacity-0 transition-opacity',
|
||||
'group-hover/lane:opacity-100 group-focus-within/lane:opacity-100'
|
||||
)}
|
||||
aria-label={createTooltip}
|
||||
disabled={!canCreateWorktree}
|
||||
onClick={() => onCreateWorktree(status.id)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
{createTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
|
|
@ -68,6 +77,7 @@ function Sidebar({
|
|||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
data-sidebar-resize-handle=""
|
||||
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-ring/20 active:bg-ring/30 transition-colors z-10"
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>) => void
|
||||
onColumnResizeKeyDown: (event: React.KeyboardEvent<HTMLElement>) => 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<number | null>(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<HTMLElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
])
|
||||
|
|
|
|||
|
|
@ -181,11 +181,10 @@ function emitPinnedGroup(
|
|||
worktreeMap: Map<string, Worktree>,
|
||||
collapsedGroups: Set<string>,
|
||||
result: Row[],
|
||||
showLineageContext: boolean,
|
||||
force = false
|
||||
showLineageContext: boolean
|
||||
): Set<string> {
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type UseSidebarResizeOptions = {
|
|||
deltaSign: 1 | -1
|
||||
renderedExtraWidth?: number
|
||||
setWidth: (width: number) => void
|
||||
onDraftWidthChange?: (width: number) => void
|
||||
}
|
||||
|
||||
type UseSidebarResizeResult<T extends HTMLElement> = {
|
||||
|
|
@ -54,7 +55,8 @@ export function useSidebarResize<T extends HTMLElement>({
|
|||
maxWidth,
|
||||
deltaSign,
|
||||
renderedExtraWidth = 0,
|
||||
setWidth
|
||||
setWidth,
|
||||
onDraftWidthChange
|
||||
}: UseSidebarResizeOptions): UseSidebarResizeResult<T> {
|
||||
const containerRef = useRef<T | null>(null)
|
||||
const isResizingRef = useRef(false)
|
||||
|
|
@ -102,7 +104,8 @@ export function useSidebarResize<T extends HTMLElement>({
|
|||
|
||||
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<T extends HTMLElement>({
|
|||
|
||||
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<T extends HTMLElement>({
|
|||
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<T extends HTMLElement>({
|
|||
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<T extends HTMLElement>({
|
|||
overlayRef.current = overlay
|
||||
}
|
||||
},
|
||||
[width]
|
||||
[onDraftWidthChange, width]
|
||||
)
|
||||
|
||||
return { containerRef, isResizing, onResizeStart }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AppState, [], [], UISlice> = (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<AppState, [], [], UISlice> = (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
|
||||
|
|
|
|||
|
|
@ -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<CreateWorktreeResult>
|
||||
removeWorktree: (
|
||||
worktreeId: string,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -422,7 +422,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
linkedPR,
|
||||
pushTarget,
|
||||
createdWithAgent,
|
||||
linkedLinearIssue
|
||||
linkedLinearIssue,
|
||||
workspaceStatus
|
||||
) => {
|
||||
const retryableConflictPatterns = [
|
||||
/already exists locally/i,
|
||||
|
|
@ -448,7 +449,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
...(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<AppState, [], [], WorktreeSlice>
|
|||
...(linkedPR !== undefined ? { linkedPR } : {}),
|
||||
...(pushTarget ? { pushTarget } : {}),
|
||||
...(createdWithAgent ? { createdWithAgent } : {}),
|
||||
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {})
|
||||
...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}),
|
||||
...(workspaceStatus !== undefined ? { workspaceStatus } : {})
|
||||
},
|
||||
{ timeoutMs: 10 * 60_000 }
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import type { StatusBarItem } from './types'
|
||||
|
||||
export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
|
||||
'claude',
|
||||
'codex',
|
||||
'gemini',
|
||||
'opencode-go',
|
||||
'ssh',
|
||||
'resource-usage'
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
const LEGACY_DEFAULT_STATUS_LABELS: Record<string, string> = {
|
||||
todo: 'Todo',
|
||||
'in-progress': 'In progress',
|
||||
'in-review': 'In review',
|
||||
completed: 'Completed'
|
||||
}
|
||||
|
||||
const CONDUCTOR_DEFAULT_STATUS_VISUALS: Record<string, { color: string; icon: string }> = {
|
||||
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<string, { color: string; icon: string }> = {
|
||||
todo: { color: 'neutral', icon: 'circle' },
|
||||
'in-progress': { color: 'blue', icon: 'circle-dot' },
|
||||
'in-review': { color: 'violet', icon: 'git-pull-request' },
|
||||
completed: { color: 'emerald', icon: 'circle-check' }
|
||||
}
|
||||
|
||||
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<string, { color: string; icon: string }>
|
||||
): 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<string, unknown>
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { WorkspaceStatusDefinition } from './types'
|
||||
|
||||
export const DEFAULT_STATUS_VISUALS: Record<string, { color: string; icon: string }> = {
|
||||
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[]
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, { color: string; icon: string }> = {
|
||||
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<string, unknown>
|
||||
const expected = PR_REORDERED_DEFAULT_STATUSES[index]
|
||||
return (
|
||||
Object.keys(raw).length === 4 &&
|
||||
raw.id === expected.id &&
|
||||
raw.label === expected.label &&
|
||||
raw.color === expected.color &&
|
||||
raw.icon === expected.icon
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizePersistedWorkspaceStatuses(
|
||||
value: unknown,
|
||||
options: {
|
||||
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[]
|
||||
|
|
|
|||
Loading…
Reference in New Issue