From aed7703d3072178b68cfdb964951a6b8f742c86e Mon Sep 17 00:00:00 2001 From: Borja <3930245+BorjaLL@users.noreply.github.com> Date: Sun, 31 May 2026 09:24:08 +0100 Subject: [PATCH] Add workspace board column layout setting (#3822) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/main/persistence.ts | 7 +++ src/main/runtime/rpc/methods/client-ui.ts | 1 + .../sidebar/WorkspaceKanbanDrawer.tsx | 59 ++++++++++++++++++- .../sidebar/WorkspaceKanbanDrawerHeader.tsx | 11 +++- .../sidebar/WorkspaceKanbanLaneGrid.tsx | 4 +- .../sidebar/WorkspaceKanbanSettingsMenu.tsx | 41 ++++++++++++- src/renderer/src/store/slices/ui.test.ts | 24 ++++++++ src/renderer/src/store/slices/ui.ts | 14 +++++ src/shared/constants.ts | 6 +- src/shared/types.ts | 3 + src/shared/workspace-statuses.test.ts | 32 ++++++++++ src/shared/workspace-statuses.ts | 31 +++++++++- 12 files changed, 225 insertions(+), 8 deletions(-) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 95ced2e3a..b2d0f314f 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 ), diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 900ae14a3..d97776205 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -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(), diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index 41d81da50..6c195534c 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -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(null) const [dragOverStatus, setDragOverStatus] = useState(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(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 ( @@ -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({ > { + useAppStore.getState().recordFeatureInteraction('workspace-board-actions') + setWorkspaceBoardColumnLayout(layout) + }} onRenameStatus={handleRenameStatus} onChangeStatusColor={handleChangeStatusColor} onChangeStatusIcon={handleChangeStatusIcon} @@ -562,7 +614,7 @@ export default function WorkspaceKanbanDrawer({ onDragLeave={handlePinDragLeave} />
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} /> 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({
{statuses.map((status) => ( diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx index 3afe7d549..743f36c60 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx @@ -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({ } }} > + Column layout +
+ { + if (value === 'full' || value === 'fit') { + onColumnLayoutChange(value) + } + }} + variant="outline" + size="sm" + className="h-7 w-full justify-stretch" + aria-label="Workspace board column layout" + > + + Full width + + + Fit panel + + +
+ Statuses
{workspaceStatuses.map((status, index) => { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 0fa1fbe11..ebe001523 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 1a374d2d4..2621de52b 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (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 = (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, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 811fd741c..c965fe4cc 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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, diff --git a/src/shared/types.ts b/src/shared/types.ts index 4af2850db..1ec118400 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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, diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index ed8e8b7e5..2e8ae5915 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -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) + }) }) diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 08bc54c9e..ae144ca2b 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -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[]