Zustand store for managing Kanban board column preferences including width, collapse state, and lock state.
Type: Zustand State Management Store
Location: apps/frontend/src/renderer/stores/kanban-settings-store.ts
Category: State Management / UI Preferences
Status: Stable
Source of truth: Column status types are derived from
apps/frontend/src/shared/constants/task.ts(TASK_STATUS_COLUMNS). Store implementation is inapps/frontend/src/renderer/stores/kanban-settings-store.ts.
The Kanban Settings Store manages per-project column preferences for the Kanban board view. It persists column width, collapse state, and lock state to localStorage, allowing users to customize their board layout. Preferences are scoped by project ID, enabling different layouts for different projects.
- Column Width Management: Adjust column widths within 180-600px range with automatic clamping
- Collapse State: Toggle columns between full width and collapsed (48px vertical strip)
- Lock State: Prevent accidental column width changes by locking columns
- localStorage Persistence: Auto-save preferences and filters per project with validation
- Safe Defaults: Automatic fallback to defaults on invalid/missing data
- Width Constraints: Enforce minimum (180px) and maximum (600px) column widths
- Validation: Type-safe preference and filter validation before applying stored data
- Task Filtering: Search tasks by title/description with persisted state
- Task Sorting: Sort by manual order, priority, created date, or updated date
- Custom Board Layouts: Users can resize columns to fit their workflow (e.g., wider "In Progress" column)
- Focus Mode: Collapse unused columns to focus on active work
- Screen Space Optimization: Adjust column widths for different monitor sizes
- Lock Preferred Layouts: Prevent accidental resizing after finding optimal layout
- Per-Project Preferences: Different projects can have different column layouts
- Recovery from Corruption: Automatic reset to defaults if localStorage data is invalid
import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';import type {
ColumnPreferences,
KanbanColumnPreferences,
KanbanFilters,
SortMode,
SortOrder
} from '@/renderer/stores/kanban-settings-store';
import {
DEFAULT_COLUMN_WIDTH,
MIN_COLUMN_WIDTH,
MAX_COLUMN_WIDTH,
COLLAPSED_COLUMN_WIDTH
} from '@/renderer/stores/kanban-settings-store';Required:
zustand- State management library@/shared/constants/task- TaskStatusColumn and TASK_STATUS_COLUMNS
Optional:
- None (all dependencies are required)
interface KanbanSettingsState {
// Core state
columnPreferences: KanbanColumnPreferences | null; // Preferences for all columns (null until initialized)
filters: KanbanFilters | null; // Filter/sort state (null until initialized)
// Column preference actions
initializePreferences: () => void;
setColumnWidth: (column: TaskStatusColumn, width: number) => void;
toggleColumnCollapsed: (column: TaskStatusColumn) => void;
setColumnCollapsed: (column: TaskStatusColumn, isCollapsed: boolean) => void;
toggleColumnLocked: (column: TaskStatusColumn) => void;
setColumnLocked: (column: TaskStatusColumn, isLocked: boolean) => void;
loadPreferences: (projectId: string) => void;
savePreferences: (projectId: string) => boolean;
resetPreferences: (projectId: string) => void;
getColumnPreferences: (column: TaskStatusColumn) => ColumnPreferences;
// Filter/sort actions
setSearchQuery: (searchQuery: string) => void;
setSortBy: (sortBy: SortMode) => void;
setSortOrder: (sortOrder: SortOrder) => void;
loadFilters: (projectId: string) => void;
saveFilters: (projectId: string) => boolean;
resetFilters: (projectId: string) => void;
}Each column has three preference fields:
Note: When isLocked is true, setColumnWidth() is a no-op for that column.
Description: Initialize column preferences with defaults if not already set. Should be called on mount before any other operations.
Parameters: None
Returns: void
Example:
const { initializePreferences } = useKanbanSettingsStore();
// In component mount effect
useEffect(() => {
initializePreferences();
}, []);Behavior:
- If
columnPreferencesisnull, creates default preferences for all columns - If already initialized, does nothing (idempotent)
- Default: 320px width, not collapsed, not locked
Description: Set the width of a column with automatic clamping to valid range (180-600px). Respects locked state.
Parameters:
column(TaskStatusColumn) - Target column ('backlog', 'queue', 'in_progress', 'ai_review', 'human_review', 'done')width(number) - Desired width in pixels (will be clamped to 180-600 range)
Returns: void
Example:
const { setColumnWidth } = useKanbanSettingsStore();
// Set column width during resize
const handleResize = (newWidth: number) => {
setColumnWidth('in_progress', newWidth);
};
// Width is automatically clamped
setColumnWidth('in_progress', 1000); // Clamped to 600px
setColumnWidth('in_progress', 50); // Clamped to 180pxBehavior:
- Clamps width to MIN_COLUMN_WIDTH (180px) - MAX_COLUMN_WIDTH (600px)
- No-op if column is locked (
isLocked: true) - No-op if
columnPreferencesis null (callinitializePreferences()first)
Description: Toggle a column between collapsed (48px vertical strip) and expanded state.
Parameters:
column(TaskStatusColumn) - Target column to toggle
Returns: void
Example:
const { toggleColumnCollapsed } = useKanbanSettingsStore();
// Toggle on button click
<button onClick={() => toggleColumnCollapsed('backlog')}>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>Behavior:
- Flips
isCollapsedboolean - Does NOT affect
width(width is preserved when re-expanding) - No-op if
columnPreferencesis null
Description: Explicitly set a column's collapsed state (instead of toggling).
Parameters:
column(TaskStatusColumn) - Target columnisCollapsed(boolean) - Desired collapsed state
Returns: void
Example:
const { setColumnCollapsed } = useKanbanSettingsStore();
// Collapse all columns
['backlog', 'queue', 'in_progress', 'ai_review', 'human_review', 'done'].forEach(col => {
setColumnCollapsed(col, true);
});
// Expand specific column
setColumnCollapsed('in_progress', false);Behavior:
- Sets
isCollapsedto the specified value - No-op if
columnPreferencesis null
Description: Toggle a column's lock state. Locked columns cannot be resized.
Parameters:
column(TaskStatusColumn) - Target column to toggle lock
Returns: void
Example:
const { toggleColumnLocked } = useKanbanSettingsStore();
// Lock/unlock column on click
<button onClick={() => toggleColumnLocked('in_progress')}>
{isLocked ? '🔒 Locked' : '🔓 Unlocked'}
</button>Behavior:
- Flips
isLockedboolean - When locked,
setColumnWidth()becomes a no-op for that column - No-op if
columnPreferencesis null
Description: Explicitly set a column's lock state (instead of toggling).
Parameters:
column(TaskStatusColumn) - Target columnisLocked(boolean) - Desired lock state
Returns: void
Example:
const { setColumnLocked } = useKanbanSettingsStore();
// Lock all columns
['backlog', 'queue', 'in_progress', 'ai_review', 'human_review', 'done'].forEach(col => {
setColumnLocked(col, true);
});
// Unlock specific column
setColumnLocked('in_progress', false);Behavior:
- Sets
isLockedto the specified value - No-op if
columnPreferencesis null
Description: Load column preferences from localStorage for a specific project. Automatically validates data and falls back to defaults if invalid.
Parameters:
projectId(string) - Project identifier for preference scoping
Returns: void
Example:
const { loadPreferences } = useKanbanSettingsStore();
// Load preferences when project changes
useEffect(() => {
if (currentProjectId) {
loadPreferences(currentProjectId);
}
}, [currentProjectId]);Behavior:
- Reads from localStorage key:
kanban-column-prefs-{projectId} - Validates structure before applying (checks all columns exist, types are correct, widths in range)
- Falls back to defaults if:
- No stored data exists
- Data is invalid JSON
- Data structure is incomplete/incorrect
- Width values are out of range
- Logs warnings/errors to console on failure
localStorage Key Format: kanban-column-prefs-{projectId}
Description: Save current column preferences to localStorage for a specific project.
Parameters:
projectId(string) - Project identifier for preference scoping
Returns: boolean - true if save succeeded, false if failed
Example:
const { savePreferences } = useKanbanSettingsStore();
// Save after width change
const handleResizeEnd = () => {
const success = savePreferences(currentProjectId);
if (!success) {
console.error('Failed to save column preferences');
}
};
// Auto-save on changes (debounced)
useEffect(() => {
const timer = setTimeout(() => {
savePreferences(currentProjectId);
}, 500); // Debounce for 500ms
return () => clearTimeout(timer);
}, [columnPreferences, currentProjectId]);Behavior:
- Serializes
columnPreferencesto JSON - Writes to localStorage key:
kanban-column-prefs-{projectId} - Returns
falseifcolumnPreferencesis null - All
localStorageandJSON.parse/JSON.stringifycalls are wrapped in try/catch to handle quota exceeded, private browsing, or corrupted data gracefully - Returns
falseon any write failure and logs error to console - On read failure (corrupted JSON), falls back to default preferences rather than crashing
Description: Reset column preferences to defaults and clear localStorage for a project.
Parameters:
projectId(string) - Project identifier for preference scoping
Returns: void
Example:
const { resetPreferences } = useKanbanSettingsStore();
// Reset button handler
const handleReset = () => {
if (confirm('Reset all column settings to defaults?')) {
resetPreferences(currentProjectId);
}
};Behavior:
- Removes localStorage key:
kanban-column-prefs-{projectId} - Sets
columnPreferencesto defaults (320px, not collapsed, not locked) - Logs errors to console on failure
Description: Get preferences for a single column. Returns safe defaults if store is uninitialized.
Parameters:
column(TaskStatusColumn) - Target column
Returns: ColumnPreferences - Preferences for the column (width, isCollapsed, isLocked)
Example:
const { getColumnPreferences } = useKanbanSettingsStore();
const columnPrefs = getColumnPreferences('in_progress');
// Use in component
const columnStyle = {
width: columnPrefs.isCollapsed ? COLLAPSED_COLUMN_WIDTH : columnPrefs.width,
cursor: columnPrefs.isLocked ? 'not-allowed' : 'col-resize'
};Behavior:
- Returns actual preferences if
columnPreferencesis initialized - Returns defaults if
columnPreferencesis null:{ width: DEFAULT_COLUMN_WIDTH, // 320 isCollapsed: false, isLocked: false }
/**
* Column preferences for a single kanban column
*/
interface ColumnPreferences {
/** Column width in pixels (180-600px range) */
width: number;
/** Whether the column is collapsed (narrow vertical strip) */
isCollapsed: boolean;
/** Whether the column width is locked (prevents resize) */
isLocked: boolean;
}
/**
* All column preferences keyed by status column
*/
type KanbanColumnPreferences = Record<TaskStatusColumn, ColumnPreferences>;
/**
* Task status column types
* (from @/shared/constants/task - TASK_STATUS_COLUMNS)
*/
type TaskStatusColumn =
| 'backlog'
| 'queue'
| 'in_progress'
| 'ai_review'
| 'human_review'
| 'done';
/**
* Mapping for non-column statuses:
* - 'pr_created' → rendered in 'done' column
* - 'error' → rendered in 'human_review' column
*/
/**
* Sort mode for task ordering
*/
type SortMode = 'manual' | 'priority' | 'created' | 'updated';
/**
* Sort order direction
*/
type SortOrder = 'asc' | 'desc';
/**
* Filter state for kanban board
*/
interface KanbanFilters {
/** Search query to filter tasks by title/description */
searchQuery: string;
/** Sort mode for task ordering */
sortBy: SortMode;
/** Sort order direction */
sortOrder: SortOrder;
}/** Default column width in pixels */
export const DEFAULT_COLUMN_WIDTH = 320;
/** Minimum column width in pixels */
export const MIN_COLUMN_WIDTH = 180;
/** Maximum column width in pixels */
export const MAX_COLUMN_WIDTH = 600;
/** Collapsed column width in pixels (vertical strip) */
export const COLLAPSED_COLUMN_WIDTH = 48;/** localStorage key prefix for kanban settings persistence */
const KANBAN_SETTINGS_KEY_PREFIX = 'kanban-column-prefs';
// Actual key: `kanban-column-prefs-{projectId}`
/** localStorage key prefix for kanban filters persistence */
const KANBAN_FILTERS_KEY_PREFIX = 'kanban-filters';
// Actual key: `kanban-filters-{projectId}`function createDefaultFilters(): KanbanFilters {
return {
searchQuery: '',
sortBy: 'manual',
sortOrder: 'asc'
};
}Description: Set the search query to filter tasks by title/description.
Parameters:
searchQuery(string) - Text to search for
Returns: void
Description: Set the sort mode for task ordering. When set to anything other than manual, drag-and-drop is disabled.
Parameters:
sortBy(SortMode) -'manual'|'priority'|'created'|'updated'
Returns: void
Description: Set the sort order direction (only applies when sort mode is not manual).
Parameters:
sortOrder(SortOrder) -'asc'|'desc'
Returns: void
Description: Load filters from localStorage for a project. Falls back to defaults on missing or invalid data.
Parameters:
projectId(string) - Project identifier
Returns: void
Behavior:
- Reads from localStorage key
kanban-filters-{projectId} - Validates structure with
validateFilters()before applying - Falls back to
createDefaultFilters()on any error
Description: Persist current filters to localStorage.
Parameters:
projectId(string) - Project identifier
Returns: boolean - true on success, false on failure
Description: Reset filters to defaults and clear from localStorage.
Parameters:
projectId(string) - Project identifier
Returns: void
import { useEffect } from 'react';
import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';
function KanbanBoard({ projectId }: { projectId: string }) {
const {
initializePreferences,
loadPreferences,
getColumnPreferences
} = useKanbanSettingsStore();
// Initialize on mount
useEffect(() => {
initializePreferences();
loadPreferences(projectId);
}, [projectId]);
// Use preferences
const inProgressPrefs = getColumnPreferences('in_progress');
return (
<div style={{ width: inProgressPrefs.width }}>
{/* Column content */}
</div>
);
}import { useState, useEffect } from 'react';
import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';
function ResizableColumn({ column, projectId }: Props) {
const { setColumnWidth, savePreferences, getColumnPreferences } = useKanbanSettingsStore();
const prefs = getColumnPreferences(column);
const handleResize = (newWidth: number) => {
setColumnWidth(column, newWidth);
};
// Auto-save with debounce (centralize debounce logic to avoid duplication across components)
useEffect(() => {
const timer = setTimeout(() => {
try {
savePreferences(projectId);
} catch (err) {
console.error('Failed to save column preferences:', err);
}
}, 500);
return () => clearTimeout(timer);
}, [prefs.width, projectId]);
return (
<div
style={{
width: prefs.width,
cursor: prefs.isLocked ? 'not-allowed' : 'col-resize'
}}
onMouseDown={prefs.isLocked ? undefined : startResize}
>
{/* Column content */}
</div>
);
}import { useKanbanSettingsStore, COLLAPSED_COLUMN_WIDTH } from '@/renderer/stores/kanban-settings-store';
function ColumnHeader({ column }: { column: TaskStatusColumn }) {
const { toggleColumnCollapsed, getColumnPreferences, savePreferences } = useKanbanSettingsStore();
const prefs = getColumnPreferences(column);
const handleToggleCollapse = () => {
toggleColumnCollapsed(column);
savePreferences(currentProjectId); // Save immediately
};
return (
<div style={{ width: prefs.isCollapsed ? COLLAPSED_COLUMN_WIDTH : prefs.width }}>
<button onClick={handleToggleCollapse}>
{prefs.isCollapsed ? '→' : '←'}
</button>
{!prefs.isCollapsed && <h2>{column}</h2>}
</div>
);
}import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';
function ColumnControls({ column, projectId }: Props) {
const { toggleColumnLocked, getColumnPreferences, savePreferences } = useKanbanSettingsStore();
const prefs = getColumnPreferences(column);
const handleToggleLock = () => {
toggleColumnLocked(column);
savePreferences(projectId);
};
return (
<button onClick={handleToggleLock} title={prefs.isLocked ? 'Unlock column' : 'Lock column'}>
{prefs.isLocked ? '🔒' : '🔓'}
</button>
);
}import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';
function SettingsPanel({ projectId }: { projectId: string }) {
const { resetPreferences } = useKanbanSettingsStore();
const handleReset = () => {
if (confirm('Reset all column settings to defaults?')) {
resetPreferences(projectId);
}
};
return (
<button onClick={handleReset}>
Reset Column Layout
</button>
);
}import { useKanbanSettingsStore } from '@/renderer/stores/kanban-settings-store';
import { TASK_STATUS_COLUMNS } from '@/shared/constants/task';
function BulkActions({ projectId }: { projectId: string }) {
const { setColumnCollapsed, setColumnLocked, savePreferences } = useKanbanSettingsStore();
const collapseAll = () => {
TASK_STATUS_COLUMNS.forEach(col => setColumnCollapsed(col, true));
savePreferences(projectId);
};
const expandAll = () => {
TASK_STATUS_COLUMNS.forEach(col => setColumnCollapsed(col, false));
savePreferences(projectId);
};
const lockAll = () => {
TASK_STATUS_COLUMNS.forEach(col => setColumnLocked(col, true));
savePreferences(projectId);
};
const unlockAll = () => {
TASK_STATUS_COLUMNS.forEach(col => setColumnLocked(col, false));
savePreferences(projectId);
};
return (
<div>
<button onClick={collapseAll}>Collapse All</button>
<button onClick={expandAll}>Expand All</button>
<button onClick={lockAll}>Lock All</button>
<button onClick={unlockAll}>Unlock All</button>
</div>
);
}Key: kanban-column-prefs-{projectId}
Value: JSON-serialized KanbanColumnPreferences
{
"backlog": {
"width": 280,
"isCollapsed": false,
"isLocked": false
},
"queue": {
"width": 320,
"isCollapsed": false,
"isLocked": false
},
"in_progress": {
"width": 400,
"isCollapsed": false,
"isLocked": true
},
"ai_review": {
"width": 320,
"isCollapsed": false,
"isLocked": false
},
"human_review": {
"width": 320,
"isCollapsed": false,
"isLocked": false
},
"done": {
"width": 320,
"isCollapsed": true,
"isLocked": false
}
}Before applying stored preferences, the store validates:
- Structure: All required columns exist
- Types:
widthis number,isCollapsedandisLockedare booleans - Range:
widthis within MIN_COLUMN_WIDTH (180) and MAX_COLUMN_WIDTH (600)
Invalid data triggers automatic fallback to defaults with console warning.
Preferences are scoped by projectId, allowing:
- Different layouts for different projects
- Safe project switching without losing preferences
- Independent preference management per workspace
// ✅ CORRECT - Initialize before use
useEffect(() => {
initializePreferences();
loadPreferences(projectId);
}, [projectId]);
// ❌ WRONG - Using store before initialization
const prefs = getColumnPreferences('in_progress'); // May return defaults incorrectly// ✅ CORRECT - Debounce frequent updates
useEffect(() => {
const timer = setTimeout(() => {
savePreferences(projectId);
}, 500);
return () => clearTimeout(timer);
}, [columnPreferences]);
// ❌ WRONG - Save on every width change (performance issue)
const handleResize = (width: number) => {
setColumnWidth(column, width);
savePreferences(projectId); // Too frequent!
};// ✅ CORRECT - Disable resize UI when locked
const cursor = prefs.isLocked ? 'not-allowed' : 'col-resize';
const onMouseDown = prefs.isLocked ? undefined : startResize;
// ❌ WRONG - Allow resizing locked columns
<div onMouseDown={startResize}> {/* Ignores lock state */}// ✅ CORRECT - Apply COLLAPSED_COLUMN_WIDTH when collapsed
const effectiveWidth = prefs.isCollapsed ? COLLAPSED_COLUMN_WIDTH : prefs.width;
// ❌ WRONG - Ignore collapse state
<div style={{ width: prefs.width }}> {/* Shows full width when collapsed */}// ✅ CORRECT - Allow users to reset to defaults
<button onClick={() => resetPreferences(projectId)}>
Reset Layout
</button>
// ❌ WRONG - No way to recover from bad settings// ✅ CORRECT - Handle save failures
const success = savePreferences(projectId);
if (!success) {
toast.error('Failed to save column preferences');
}
// ⚠️ ACCEPTABLE - Silent failure (save is non-critical)
savePreferences(projectId); // Returns boolean but not checked// ✅ CORRECT - Use getter method
const prefs = getColumnPreferences('in_progress');
// ❌ WRONG - Access state directly (no null safety)
const prefs = columnPreferences['in_progress']; // Error if null- Task Store - Main task state management (uses Kanban Settings Store)
- Component API Template - Documentation template for components
- Task Constants - TaskStatusColumn definitions
- Zustand Documentation - https://github.com/pmndrs/zustand
Problem: Using store before calling initializePreferences() causes columnPreferences to be null.
Solution:
useEffect(() => {
initializePreferences(); // Call first!
loadPreferences(projectId);
}, []);Problem: Changes are applied to store but not persisted to localStorage.
Solution:
const handleChange = () => {
setColumnWidth(column, newWidth);
savePreferences(projectId); // Don't forget!
};Problem: Invalid localStorage data silently falls back to defaults, confusing users.
Solution: Check console for warnings:
[KanbanSettingsStore] Invalid preferences in localStorage, using defaults
Problem: savePreferences() fails silently when localStorage quota is exceeded.
Solution: Handle failure and notify user:
if (!savePreferences(projectId)) {
toast.error('Failed to save preferences - localStorage full');
}Problem: Preferences from old project shown when switching projects.
Solution: Reload on project change:
useEffect(() => {
if (projectId) {
loadPreferences(projectId);
}
}, [projectId]);- Initial implementation
- Column width, collapse, and lock state management
- localStorage persistence with validation
- Per-project preference scoping
- Automatic width clamping (180-600px)
- Safe defaults for missing/invalid data
