Add workspace board column layout setting (#3822)

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Borja 2026-05-31 09:24:08 +01:00 committed by GitHub
parent a931fde9e3
commit aed7703d30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 225 additions and 8 deletions

View File

@ -96,6 +96,7 @@ import {
DEFAULT_WORKSPACE_STATUS_ID,
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity,
normalizeWorkspaceBoardColumnLayout,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
@ -2825,6 +2826,9 @@ export class Store {
),
workspaceStatuses: normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(this.state.ui?.workspaceBoardOpacity),
workspaceBoardColumnLayout: normalizeWorkspaceBoardColumnLayout(
this.state.ui?.workspaceBoardColumnLayout
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
this.state.ui?.workspaceBoardColumnWidth
),
@ -2862,6 +2866,9 @@ export class Store {
workspaceBoardOpacity: clampWorkspaceBoardOpacity(
updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity
),
workspaceBoardColumnLayout: normalizeWorkspaceBoardColumnLayout(
updates.workspaceBoardColumnLayout ?? this.state.ui?.workspaceBoardColumnLayout
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(
updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth
),

View File

@ -146,6 +146,7 @@ const UiUpdate = z
agentActivityDisplayMode: AgentActivityDisplayMode.optional(),
workspaceStatuses: z.array(WorkspaceStatusDefinition).optional(),
workspaceBoardOpacity: z.number().finite().optional(),
workspaceBoardColumnLayout: z.enum(['full', 'fit']).optional(),
workspaceBoardColumnWidth: z.number().finite().optional(),
_workspaceStatusesDefaultOrderMigrated: z.boolean().optional(),
_workspaceStatusesDefaultWorkflowMigrated: z.boolean().optional(),

View File

@ -31,7 +31,11 @@ import {
type WorktreeDragGroup
} from './worktree-manual-order'
import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types'
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
import {
WORKSPACE_BOARD_COLUMN_GAP,
fitWorkspaceBoardColumnWidth,
makeWorkspaceStatusId
} from '../../../../shared/workspace-statuses'
type WorkspaceKanbanDrawerProps = {
open: boolean
@ -53,6 +57,8 @@ export default function WorkspaceKanbanDrawer({
const updateWorktreesMeta = useAppStore((s) => s.updateWorktreesMeta)
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
const setWorkspaceStatuses = useAppStore((s) => s.setWorkspaceStatuses)
const workspaceBoardColumnLayout = useAppStore((s) => s.workspaceBoardColumnLayout)
const setWorkspaceBoardColumnLayout = useAppStore((s) => s.setWorkspaceBoardColumnLayout)
const workspaceBoardColumnWidth = useAppStore((s) => s.workspaceBoardColumnWidth)
const setWorkspaceBoardColumnWidth = useAppStore((s) => s.setWorkspaceBoardColumnWidth)
const sortBy = useAppStore((s) => s.sortBy)
@ -64,6 +70,7 @@ export default function WorkspaceKanbanDrawer({
const areaSelectionOverlayRef = useRef<HTMLDivElement>(null)
const [dragOverStatus, setDragOverStatus] = useState<WorkspaceStatus | null>(null)
const [pinDragOver, setPinDragOver] = useState(false)
const [laneScrollerWidth, setLaneScrollerWidth] = useState(0)
const { canCreateWorktree, createWorktreeForStatus } = useWorkspaceKanbanCreateWorktree()
const visibleWorktreeIdSet = useVisibleWorkspaceKanbanWorktreeIds({
allWorktrees,
@ -112,6 +119,33 @@ export default function WorkspaceKanbanDrawer({
})
const { columnWidth, isResizingColumn, onColumnResizeStart, onColumnResizeKeyDown } =
useWorkspaceKanbanColumnResize(workspaceBoardColumnWidth, setWorkspaceBoardColumnWidth)
const laneScrollerResizeRef = useRef<ResizeObserver | null>(null)
const attachLaneScroller = useCallback((node: HTMLDivElement | null) => {
laneScrollerRef.current = node
laneScrollerResizeRef.current?.disconnect()
laneScrollerResizeRef.current = null
if (!node) {
return
}
setLaneScrollerWidth(node.clientWidth)
if (typeof ResizeObserver === 'undefined') {
return
}
const observer = new ResizeObserver(() => setLaneScrollerWidth(node.clientWidth))
observer.observe(node)
laneScrollerResizeRef.current = observer
}, [])
const renderColumnWidth = useMemo(
() =>
workspaceBoardColumnLayout === 'fit'
? fitWorkspaceBoardColumnWidth({
containerWidth: laneScrollerWidth,
columnCount: workspaceStatuses.length,
capWidth: columnWidth
})
: columnWidth,
[columnWidth, laneScrollerWidth, workspaceBoardColumnLayout, workspaceStatuses.length]
)
const moveWorktreeToStatus = useCallback(
(worktreeId: string, status: WorkspaceStatus) => {
const current = worktreeById.get(worktreeId)
@ -464,6 +498,19 @@ export default function WorkspaceKanbanDrawer({
const drawerLeftCss = sidebarOpen
? `var(--workspace-sidebar-live-width, ${sidebarWidth}px)`
: '0px'
const fitPanelBoardWidthCss = `min(calc(100vw - ${drawerLeftCss}), 1294px)`
// Why: full-width mode keeps user-sized columns and expands the companion
// board over adjacent panes; fit mode preserves the older fixed panel.
const boardContentWidth =
workspaceStatuses.length * columnWidth +
Math.max(0, workspaceStatuses.length - 1) * WORKSPACE_BOARD_COLUMN_GAP +
24
const fullBoardWidthCss = `min(calc(100vw - ${drawerLeftCss}), ${Math.max(
boardContentWidth,
1294
)}px)`
const boardWidthCss =
workspaceBoardColumnLayout === 'fit' ? fitPanelBoardWidthCss : fullBoardWidthCss
return (
<Sheet open={open} onOpenChange={handleSheetOpenChange} modal={false}>
@ -479,7 +526,7 @@ export default function WorkspaceKanbanDrawer({
left: drawerLeftCss,
top: 36,
height: 'calc(100% - 36px)',
width: `min(calc(100vw - ${drawerLeftCss}), 1294px)`
width: boardWidthCss
} as React.CSSProperties
}
onOpenAutoFocus={(event) => {
@ -539,7 +586,12 @@ export default function WorkspaceKanbanDrawer({
>
<WorkspaceKanbanDrawerHeader
selectedCount={selectedWorktrees.length}
columnLayout={workspaceBoardColumnLayout}
workspaceStatuses={workspaceStatuses}
onColumnLayoutChange={(layout) => {
useAppStore.getState().recordFeatureInteraction('workspace-board-actions')
setWorkspaceBoardColumnLayout(layout)
}}
onRenameStatus={handleRenameStatus}
onChangeStatusColor={handleChangeStatusColor}
onChangeStatusIcon={handleChangeStatusIcon}
@ -562,7 +614,7 @@ export default function WorkspaceKanbanDrawer({
onDragLeave={handlePinDragLeave}
/>
<div
ref={laneScrollerRef}
ref={attachLaneScroller}
className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek"
>
<WorkspaceKanbanLaneGrid
@ -571,6 +623,7 @@ export default function WorkspaceKanbanDrawer({
repoMap={repoMap}
activeWorktreeId={activeWorktreeId}
columnWidth={columnWidth}
renderColumnWidth={renderColumnWidth}
isResizingColumn={isResizingColumn}
dragOverStatus={dragOverStatus}
canCreateWorktree={canCreateWorktree}

View File

@ -2,13 +2,18 @@ import React from 'react'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { SheetClose, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
import type {
WorkspaceBoardColumnLayout,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import SidebarFilter from './SidebarFilter'
import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu'
type WorkspaceKanbanDrawerHeaderProps = {
selectedCount: number
columnLayout: WorkspaceBoardColumnLayout
workspaceStatuses: readonly WorkspaceStatusDefinition[]
onColumnLayoutChange: (layout: WorkspaceBoardColumnLayout) => void
onRenameStatus: (statusId: string, label: string) => void
onChangeStatusColor: (statusId: string, color: string) => void
onChangeStatusIcon: (statusId: string, icon: string) => void
@ -20,7 +25,9 @@ type WorkspaceKanbanDrawerHeaderProps = {
export default function WorkspaceKanbanDrawerHeader({
selectedCount,
columnLayout,
workspaceStatuses,
onColumnLayoutChange,
onRenameStatus,
onChangeStatusColor,
onChangeStatusIcon,
@ -53,7 +60,9 @@ export default function WorkspaceKanbanDrawerHeader({
onMenuOpenChange={onFilterMenuOpenChange}
/>
<WorkspaceKanbanSettingsMenu
columnLayout={columnLayout}
workspaceStatuses={workspaceStatuses}
onColumnLayoutChange={onColumnLayoutChange}
onRenameStatus={onRenameStatus}
onChangeStatusColor={onChangeStatusColor}
onChangeStatusIcon={onChangeStatusIcon}

View File

@ -13,6 +13,7 @@ type WorkspaceKanbanLaneGridProps = {
repoMap: Map<string, Repo>
activeWorktreeId: string | null
columnWidth: number
renderColumnWidth: number
isResizingColumn: boolean
dragOverStatus: WorkspaceStatus | null
canCreateWorktree: boolean
@ -38,6 +39,7 @@ export default function WorkspaceKanbanLaneGrid({
repoMap,
activeWorktreeId,
columnWidth,
renderColumnWidth,
isResizingColumn,
dragOverStatus,
canCreateWorktree,
@ -57,7 +59,7 @@ export default function WorkspaceKanbanLaneGrid({
<div
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"
style={{
gridTemplateColumns: `repeat(${statuses.length}, minmax(${columnWidth}px, ${columnWidth}px))`
gridTemplateColumns: `repeat(${statuses.length}, minmax(${renderColumnWidth}px, ${renderColumnWidth}px))`
}}
>
{statuses.map((status) => (

View File

@ -5,16 +5,23 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { WorkspaceStatusDefinition } from '../../../../shared/types'
import type {
WorkspaceBoardColumnLayout,
WorkspaceStatusDefinition
} from '../../../../shared/types'
import { getWorkspaceStatusVisualMeta } from './workspace-status'
import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover'
type WorkspaceKanbanSettingsMenuProps = {
columnLayout: WorkspaceBoardColumnLayout
workspaceStatuses: readonly WorkspaceStatusDefinition[]
onColumnLayoutChange: (layout: WorkspaceBoardColumnLayout) => void
onRenameStatus: (statusId: string, label: string) => void
onChangeStatusColor: (statusId: string, color: string) => void
onChangeStatusIcon: (statusId: string, icon: string) => void
@ -24,7 +31,9 @@ type WorkspaceKanbanSettingsMenuProps = {
}
export default function WorkspaceKanbanSettingsMenu({
columnLayout,
workspaceStatuses,
onColumnLayoutChange,
onRenameStatus,
onChangeStatusColor,
onChangeStatusIcon,
@ -66,6 +75,36 @@ export default function WorkspaceKanbanSettingsMenu({
}
}}
>
<DropdownMenuLabel>Column layout</DropdownMenuLabel>
<div className="px-2 pt-0.5 pb-2">
<ToggleGroup
type="single"
value={columnLayout}
onValueChange={(value) => {
if (value === 'full' || value === 'fit') {
onColumnLayoutChange(value)
}
}}
variant="outline"
size="sm"
className="h-7 w-full justify-stretch"
aria-label="Workspace board column layout"
>
<ToggleGroupItem
value="full"
className="h-7 grow basis-0 px-1.5 text-[11px] data-[state=on]:bg-foreground/10 data-[state=on]:font-semibold data-[state=on]:text-foreground"
>
Full width
</ToggleGroupItem>
<ToggleGroupItem
value="fit"
className="h-7 grow basis-0 px-1.5 text-[11px] data-[state=on]:bg-foreground/10 data-[state=on]:font-semibold data-[state=on]:text-foreground"
>
Fit panel
</ToggleGroupItem>
</ToggleGroup>
</div>
<DropdownMenuSeparator />
<DropdownMenuLabel>Statuses</DropdownMenuLabel>
<div className="space-y-2 px-1 pb-1">
{workspaceStatuses.map((status, index) => {

View File

@ -620,6 +620,30 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().workspaceBoardColumnWidth).toBe(520)
})
it('hydrates workspace board column layout', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
workspaceBoardColumnLayout: 'fit'
})
)
expect(store.getState().workspaceBoardColumnLayout).toBe('fit')
})
it('defaults invalid workspace board column layout to full width', () => {
const store = createUIStore()
store.getState().hydratePersistedUI(
makePersistedUI({
workspaceBoardColumnLayout: 'compact' as never
})
)
expect(store.getState().workspaceBoardColumnLayout).toBe('full')
})
it('hydrates a valid Kagi session link', () => {
const store = createUIStore()

View File

@ -18,6 +18,7 @@ import type {
TaskViewPresetId,
TuiAgent,
UpdateStatus,
WorkspaceBoardColumnLayout,
WorkspaceStatusDefinition,
AgentActivityDisplayMode,
WorktreeCardProperty
@ -55,6 +56,7 @@ import {
clampWorkspaceBoardColumnWidth,
clampWorkspaceBoardOpacity,
cloneDefaultWorkspaceStatuses,
normalizeWorkspaceBoardColumnLayout,
normalizeWorkspaceStatuses
} from '../../../../shared/workspace-statuses'
import { normalizeKagiSessionLink } from '../../../../shared/browser-url'
@ -610,6 +612,8 @@ export type UISlice = {
setWorkspaceStatuses: (statuses: WorkspaceStatusDefinition[]) => void
workspaceBoardOpacity: number
setWorkspaceBoardOpacity: (opacity: number) => void
workspaceBoardColumnLayout: WorkspaceBoardColumnLayout
setWorkspaceBoardColumnLayout: (layout: WorkspaceBoardColumnLayout) => void
workspaceBoardColumnWidth: number
setWorkspaceBoardColumnWidth: (width: number) => void
statusBarItems: StatusBarItem[]
@ -1291,6 +1295,13 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
set({ workspaceBoardOpacity: clamped })
},
workspaceBoardColumnLayout: 'full',
setWorkspaceBoardColumnLayout: (layout) => {
const normalized = normalizeWorkspaceBoardColumnLayout(layout)
window.api.ui.set({ workspaceBoardColumnLayout: normalized }).catch(console.error)
set({ workspaceBoardColumnLayout: normalized })
},
workspaceBoardColumnWidth: WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT,
setWorkspaceBoardColumnWidth: (width) => {
const clamped = clampWorkspaceBoardColumnWidth(width)
@ -1466,6 +1477,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
agentActivityDisplayMode: normalizeAgentActivityDisplayMode(ui.agentActivityDisplayMode),
workspaceStatuses: normalizeWorkspaceStatuses(ui.workspaceStatuses),
workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity),
workspaceBoardColumnLayout: normalizeWorkspaceBoardColumnLayout(
ui.workspaceBoardColumnLayout
),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth),
statusBarItems,
statusBarVisible: ui.statusBarVisible ?? true,

View File

@ -14,7 +14,10 @@ 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 {
WORKSPACE_BOARD_COLUMN_LAYOUT_DEFAULT,
cloneDefaultWorkspaceStatuses
} from './workspace-statuses'
import { TASK_PROVIDERS } from './task-providers'
import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree-card-properties'
import { getDefaultSourceControlAiSettings } from './source-control-ai'
@ -395,6 +398,7 @@ export function getDefaultUIState(): PersistedUIState {
agentActivityDisplayMode: DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE,
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
workspaceBoardOpacity: 1,
workspaceBoardColumnLayout: WORKSPACE_BOARD_COLUMN_LAYOUT_DEFAULT,
workspaceBoardColumnWidth: 308,
_workspaceStatusesDefaultOrderMigrated: true,
_workspaceStatusesDefaultWorkflowMigrated: true,

View File

@ -2378,6 +2378,8 @@ export type WorktreeCardProperty =
export type AgentActivityDisplayMode = 'compact' | 'full'
export type WorkspaceBoardColumnLayout = 'full' | 'fit'
export type StatusBarItem =
| 'claude'
| 'codex'
@ -2436,6 +2438,7 @@ export type PersistedUIState = {
agentActivityDisplayMode?: AgentActivityDisplayMode
workspaceStatuses?: WorkspaceStatusDefinition[]
workspaceBoardOpacity?: number
workspaceBoardColumnLayout?: WorkspaceBoardColumnLayout
workspaceBoardColumnWidth?: number
/** One-shot migration flag for a short-lived build that persisted the
* default workspace statuses in reverse workflow order. Once stamped,

View File

@ -5,7 +5,9 @@ import {
WORKSPACE_BOARD_COLUMN_WIDTH_MIN,
clampWorkspaceBoardColumnWidth,
cloneDefaultWorkspaceStatuses,
fitWorkspaceBoardColumnWidth,
normalizePersistedWorkspaceStatuses,
normalizeWorkspaceBoardColumnLayout,
normalizeWorkspaceStatuses
} from './workspace-statuses'
@ -249,4 +251,34 @@ describe('workspace status visuals', () => {
expect(clampWorkspaceBoardColumnWidth(321.6)).toBe(322)
expect(clampWorkspaceBoardColumnWidth(900)).toBe(WORKSPACE_BOARD_COLUMN_WIDTH_MAX)
})
it('defaults workspace board column layout to full width', () => {
expect(normalizeWorkspaceBoardColumnLayout(undefined)).toBe('full')
expect(normalizeWorkspaceBoardColumnLayout('fit')).toBe('fit')
expect(normalizeWorkspaceBoardColumnLayout('compact')).toBe('full')
})
it('fits workspace board columns inside the visible panel without exceeding saved width', () => {
expect(
fitWorkspaceBoardColumnWidth({
containerWidth: 960,
columnCount: 4,
capWidth: 308
})
).toBe(231)
expect(
fitWorkspaceBoardColumnWidth({
containerWidth: 1600,
columnCount: 4,
capWidth: 308
})
).toBe(308)
expect(
fitWorkspaceBoardColumnWidth({
containerWidth: 100,
columnCount: 4,
capWidth: 308
})
).toBe(WORKSPACE_BOARD_COLUMN_WIDTH_MIN)
})
})

View File

@ -1,4 +1,9 @@
import type { Worktree, WorkspaceStatus, WorkspaceStatusDefinition } from './types'
import type {
Worktree,
WorkspaceBoardColumnLayout,
WorkspaceStatus,
WorkspaceStatusDefinition
} from './types'
import { DEFAULT_STATUS_VISUALS, DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults'
import {
isKnownBadPRReorderedDefaultStatusPayload,
@ -22,6 +27,10 @@ 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
// Why: keep this aligned with the `gap-3` lane gap in WorkspaceKanbanLaneGrid;
// layout calculations need the actual rendered gap between status columns.
export const WORKSPACE_BOARD_COLUMN_GAP = 12
export const WORKSPACE_BOARD_COLUMN_LAYOUT_DEFAULT: WorkspaceBoardColumnLayout = 'full'
export const WORKSPACE_STATUS_COLOR_IDS = [
'neutral',
@ -235,6 +244,10 @@ export function clampWorkspaceBoardOpacity(value: unknown): number {
return Math.min(1, Math.max(0.2, Math.round(value * 100) / 100))
}
export function normalizeWorkspaceBoardColumnLayout(value: unknown): WorkspaceBoardColumnLayout {
return value === 'fit' ? 'fit' : WORKSPACE_BOARD_COLUMN_LAYOUT_DEFAULT
}
export function clampWorkspaceBoardColumnWidth(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT
@ -245,6 +258,22 @@ export function clampWorkspaceBoardColumnWidth(value: unknown): number {
)
}
export function fitWorkspaceBoardColumnWidth(args: {
containerWidth: number
columnCount: number
capWidth: number
gap?: number
}): number {
const cap = clampWorkspaceBoardColumnWidth(args.capWidth)
if (args.columnCount <= 0 || !Number.isFinite(args.containerWidth) || args.containerWidth <= 0) {
return cap
}
const gap = args.gap ?? WORKSPACE_BOARD_COLUMN_GAP
const available = args.containerWidth - gap * Math.max(0, args.columnCount - 1)
const perColumn = Math.floor(available / args.columnCount)
return Math.min(cap, Math.max(WORKSPACE_BOARD_COLUMN_WIDTH_MIN, perColumn))
}
export function isWorkspaceStatusId(
value: string,
statuses: readonly WorkspaceStatusDefinition[]