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:
Neil 2026-05-16 15:02:56 -07:00 committed by GitHub
parent 9943bba8ab
commit ffcb97b96a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 689 additions and 140 deletions

View File

@ -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)

View File

@ -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: {}

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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:

View File

@ -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,

View File

@ -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}
/>
)
})}

View File

@ -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>
)

View File

@ -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}
/>

View File

@ -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
}
}

View File

@ -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 }
}

View File

@ -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 }
])

View File

@ -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

View File

@ -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,

View File

@ -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 }

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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'
})
})

View File

@ -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 }
)

View File

@ -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,

View File

@ -0,0 +1,10 @@
import type { StatusBarItem } from './types'
export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
'claude',
'codex',
'gemini',
'opencode-go',
'ssh',
'resource-usage'
]

View File

@ -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

View File

@ -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
)
}

View File

@ -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[]

View File

@ -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)
})
})

View File

@ -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[]