diff --git a/apps/frontend/src/renderer/components/KanbanBoard.tsx b/apps/frontend/src/renderer/components/KanbanBoard.tsx index 7522ac2a..53d92b10 100644 --- a/apps/frontend/src/renderer/components/KanbanBoard.tsx +++ b/apps/frontend/src/renderer/components/KanbanBoard.tsx @@ -19,7 +19,7 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus } from 'lucide-react'; +import { Plus, Inbox, Loader2, Eye, CheckCircle2, Archive, RefreshCw, GitPullRequest, X, Settings, ListPlus, ChevronLeft, ChevronRight, ChevronsRight, Lock, Unlock } from 'lucide-react'; import { Checkbox } from './ui/checkbox'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; @@ -31,6 +31,7 @@ import { TASK_STATUS_COLUMNS, TASK_STATUS_LABELS } from '../../shared/constants' import { cn } from '../lib/utils'; import { persistTaskStatus, forceCompleteTask, archiveTasks, useTaskStore } from '../stores/task-store'; import { updateProjectSettings, useProjectStore } from '../stores/project-store'; +import { useKanbanSettingsStore, COLLAPSED_COLUMN_WIDTH, DEFAULT_COLUMN_WIDTH, MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH } from '../stores/kanban-settings-store'; import { useToast } from '../hooks/use-toast'; import { WorktreeCleanupDialog } from './WorktreeCleanupDialog'; import { BulkPRDialog } from './BulkPRDialog'; @@ -81,6 +82,17 @@ interface DroppableColumnProps { onSelectAll?: () => void; onDeselectAll?: () => void; onToggleSelect?: (taskId: string) => void; + // Collapse props + isCollapsed?: boolean; + onToggleCollapsed?: () => void; + // Resize props + columnWidth?: number; + isResizing?: boolean; + onResizeStart?: (startX: number) => void; + onResizeEnd?: () => void; + // Lock props + isLocked?: boolean; + onToggleLocked?: () => void; } /** @@ -130,6 +142,14 @@ function droppableColumnPropsAreEqual( if (prevProps.onSelectAll !== nextProps.onSelectAll) return false; if (prevProps.onDeselectAll !== nextProps.onDeselectAll) return false; if (prevProps.onToggleSelect !== nextProps.onToggleSelect) return false; + if (prevProps.isCollapsed !== nextProps.isCollapsed) return false; + if (prevProps.onToggleCollapsed !== nextProps.onToggleCollapsed) return false; + if (prevProps.columnWidth !== nextProps.columnWidth) return false; + if (prevProps.isResizing !== nextProps.isResizing) return false; + if (prevProps.onResizeStart !== nextProps.onResizeStart) return false; + if (prevProps.onResizeEnd !== nextProps.onResizeEnd) return false; + if (prevProps.isLocked !== nextProps.isLocked) return false; + if (prevProps.onToggleLocked !== nextProps.onToggleLocked) return false; // Compare selection props const prevSelected = prevProps.selectedTaskIds; @@ -200,7 +220,7 @@ const getEmptyStateContent = (status: TaskStatus, t: (key: string) => string): { } }; -const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect }: DroppableColumnProps) { +const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskClick, onStatusChange, isOver, onAddClick, onArchiveAll, onQueueSettings, onQueueAll, maxParallelTasks, archivedCount, showArchived, onToggleArchived, selectedTaskIds, onSelectAll, onDeselectAll, onToggleSelect, isCollapsed, onToggleCollapsed, columnWidth, isResizing, onResizeStart, onResizeEnd, isLocked, onToggleLocked }: DroppableColumnProps) { const { t } = useTranslation(['tasks', 'common']); const { setNodeRef } = useDroppable({ id: status @@ -298,19 +318,94 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli const emptyState = getEmptyStateContent(status, t); + // Collapsed state: show narrow vertical strip with rotated title and task count + if (isCollapsed) { + return ( +
+ {/* Expand button at top */} +
+ + + + + + {t('kanban.expandColumn')} + + +
+ + {/* Rotated title and task count */} +
+
+ + {tasks.length} + +

+ {t(TASK_STATUS_LABELS[status])} +

+
+
+
+ ); + } + return (
- {/* Column header - enhanced styling */} -
+
+ {/* Column header - enhanced styling */} +
+ {/* Collapse button */} + {onToggleCollapsed && ( + + + + + + {t('kanban.collapseColumn')} + + + )} {/* Select All checkbox for human_review column */} {isHumanReview && onSelectAll && onDeselectAll && ( @@ -347,6 +442,31 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli )}
+ {/* Lock toggle button - available for all columns */} + {onToggleLocked && ( + + + + + + {isLocked ? t('kanban.unlockColumn') : t('kanban.lockColumn')} + + + )} {status === 'backlog' && ( <> {onQueueAll && tasks.length > 0 && ( @@ -468,6 +588,39 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
+
+ + {/* Resize handle on right edge */} + {onResizeStart && onResizeEnd && ( +
{ + e.preventDefault(); + // Don't start resize if column is locked + if (isLocked) return; + onResizeStart(e.clientX); + }} + onTouchStart={(e) => { + e.preventDefault(); + // Don't start resize if column is locked + if (isLocked) return; + if (e.touches.length > 0) { + onResizeStart(e.touches[0].clientX); + } + }} + title={isLocked ? t('kanban.columnLocked') : undefined} + > + {/* Wider invisible hit area for easier grabbing */} +
+
+ )}
); }, droppableColumnPropsAreEqual); @@ -482,6 +635,22 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR // Project store for queue settings const projects = useProjectStore((state) => state.projects); + // Kanban settings store for column preferences (collapse state, width, lock state) + const columnPreferences = useKanbanSettingsStore((state) => state.columnPreferences); + const loadKanbanPreferences = useKanbanSettingsStore((state) => state.loadPreferences); + const saveKanbanPreferences = useKanbanSettingsStore((state) => state.savePreferences); + const toggleColumnCollapsed = useKanbanSettingsStore((state) => state.toggleColumnCollapsed); + const setColumnCollapsed = useKanbanSettingsStore((state) => state.setColumnCollapsed); + const setColumnWidth = useKanbanSettingsStore((state) => state.setColumnWidth); + const toggleColumnLocked = useKanbanSettingsStore((state) => state.toggleColumnLocked); + + // Column resize state + const [resizingColumn, setResizingColumn] = useState(null); + const resizeStartX = useRef(0); + const resizeStartWidth = useRef(0); + // Capture projectId at resize start to avoid stale closure if project changes during resize + const resizeProjectIdRef = useRef(null); + // Get projectId from first task const projectId = tasks[0]?.projectId; const project = projectId ? projects.find((p) => p.id === projectId) : undefined; @@ -524,6 +693,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR [tasks] ); + // Calculate collapsed column count for "Expand All" button + const collapsedColumnCount = useMemo(() => { + if (!columnPreferences) return 0; + return TASK_STATUS_COLUMNS.filter( + (status) => columnPreferences[status]?.isCollapsed + ).length; + }, [columnPreferences]); + // Filter tasks based on archive status const filteredTasks = useMemo(() => { if (showArchived) { @@ -932,6 +1109,127 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR } }, [projectId, loadTaskOrder]); + // Load kanban column preferences on mount and when project changes + useEffect(() => { + if (projectId) { + loadKanbanPreferences(projectId); + } + }, [projectId, loadKanbanPreferences]); + + // Create a callback to toggle collapsed state and save to storage + const handleToggleColumnCollapsed = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => { + // Capture projectId at function start to avoid stale closure in setTimeout + const currentProjectId = projectId; + toggleColumnCollapsed(status); + // Save preferences after toggling + if (currentProjectId) { + // Use setTimeout to ensure state is updated before saving + setTimeout(() => { + saveKanbanPreferences(currentProjectId); + }, 0); + } + }, [toggleColumnCollapsed, saveKanbanPreferences, projectId]); + + // Create a callback to expand all collapsed columns and save to storage + const handleExpandAll = useCallback(() => { + // Capture projectId at function start to avoid stale closure in setTimeout + const currentProjectId = projectId; + // Expand all collapsed columns + for (const status of TASK_STATUS_COLUMNS) { + if (columnPreferences?.[status]?.isCollapsed) { + setColumnCollapsed(status, false); + } + } + // Save preferences after expanding + if (currentProjectId) { + setTimeout(() => { + saveKanbanPreferences(currentProjectId); + }, 0); + } + }, [columnPreferences, setColumnCollapsed, saveKanbanPreferences, projectId]); + + // Create a callback to toggle locked state and save to storage + const handleToggleColumnLocked = useCallback((status: typeof TASK_STATUS_COLUMNS[number]) => { + // Capture projectId at function start to avoid stale closure in setTimeout + const currentProjectId = projectId; + toggleColumnLocked(status); + // Save preferences after toggling + if (currentProjectId) { + // Use setTimeout to ensure state is updated before saving + setTimeout(() => { + saveKanbanPreferences(currentProjectId); + }, 0); + } + }, [toggleColumnLocked, saveKanbanPreferences, projectId]); + + // Resize handlers for column width adjustment + const handleResizeStart = useCallback((status: typeof TASK_STATUS_COLUMNS[number], startX: number) => { + const currentWidth = columnPreferences?.[status]?.width ?? DEFAULT_COLUMN_WIDTH; + resizeStartX.current = startX; + resizeStartWidth.current = currentWidth; + // Capture projectId at resize start to ensure we save to the correct project + resizeProjectIdRef.current = projectId ?? null; + setResizingColumn(status); + }, [columnPreferences, projectId]); + + const handleResizeMove = useCallback((clientX: number) => { + if (!resizingColumn) return; + + const deltaX = clientX - resizeStartX.current; + const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, resizeStartWidth.current + deltaX)); + setColumnWidth(resizingColumn, newWidth); + }, [resizingColumn, setColumnWidth]); + + const handleResizeEnd = useCallback(() => { + // Use the projectId captured at resize start to avoid saving to wrong project + const savedProjectId = resizeProjectIdRef.current; + if (resizingColumn && savedProjectId) { + saveKanbanPreferences(savedProjectId); + } + setResizingColumn(null); + resizeProjectIdRef.current = null; + }, [resizingColumn, saveKanbanPreferences]); + + // Document-level event listeners for resize dragging + useEffect(() => { + if (!resizingColumn) return; + + const handleMouseMove = (e: MouseEvent) => { + handleResizeMove(e.clientX); + }; + + const handleMouseUp = () => { + handleResizeEnd(); + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length === 0) return; + handleResizeMove(e.touches[0].clientX); + }; + + const handleTouchEnd = () => { + handleResizeEnd(); + }; + + // Prevent text selection and set resize cursor during drag + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.addEventListener('touchmove', handleTouchMove, { passive: false }); + document.addEventListener('touchend', handleTouchEnd); + + return () => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + }, [resizingColumn, handleResizeMove, handleResizeEnd]); + // Clean up stale task IDs from order when tasks change (e.g., after deletion) // This ensures the persisted order doesn't contain IDs for deleted tasks useEffect(() => { @@ -1084,19 +1382,37 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR return (
- {/* Kanban header with refresh button */} - {onRefresh && ( -
- + {/* Kanban header with refresh button and expand all */} + {(onRefresh || collapsedColumnCount >= 3) && ( +
+
+ {/* Expand All button - appears when 3+ columns are collapsed */} + {collapsedColumnCount >= 3 && ( + + )} +
+
+ {onRefresh && ( + + )} +
)} {/* Kanban columns */} @@ -1133,6 +1449,14 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR onSelectAll={status === 'human_review' ? selectAllTasks : undefined} onDeselectAll={status === 'human_review' ? deselectAllTasks : undefined} onToggleSelect={status === 'human_review' ? toggleTaskSelection : undefined} + isCollapsed={columnPreferences?.[status]?.isCollapsed} + onToggleCollapsed={() => handleToggleColumnCollapsed(status)} + columnWidth={columnPreferences?.[status]?.width} + isResizing={resizingColumn === status} + onResizeStart={(startX) => handleResizeStart(status, startX)} + onResizeEnd={handleResizeEnd} + isLocked={columnPreferences?.[status]?.isLocked} + onToggleLocked={() => handleToggleColumnLocked(status)} /> ))}
diff --git a/apps/frontend/src/renderer/stores/kanban-settings-store.ts b/apps/frontend/src/renderer/stores/kanban-settings-store.ts new file mode 100644 index 00000000..35369072 --- /dev/null +++ b/apps/frontend/src/renderer/stores/kanban-settings-store.ts @@ -0,0 +1,312 @@ +import { create } from 'zustand'; +import type { TaskStatusColumn } from '../../shared/constants/task'; +import { TASK_STATUS_COLUMNS } from '../../shared/constants/task'; + +// ============================================ +// Types +// ============================================ + +/** + * Column preferences for a single kanban column + */ +export 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 + */ +export type KanbanColumnPreferences = Record; + +/** + * Kanban settings store state + */ +interface KanbanSettingsState { + /** Column preferences for each status column */ + columnPreferences: KanbanColumnPreferences | null; + + // Actions + /** Initialize column preferences (call on mount) */ + initializePreferences: () => void; + /** Set column width */ + setColumnWidth: (column: TaskStatusColumn, width: number) => void; + /** Toggle column collapsed state */ + toggleColumnCollapsed: (column: TaskStatusColumn) => void; + /** Set column collapsed state explicitly */ + setColumnCollapsed: (column: TaskStatusColumn, isCollapsed: boolean) => void; + /** Toggle column locked state */ + toggleColumnLocked: (column: TaskStatusColumn) => void; + /** Set column locked state explicitly */ + setColumnLocked: (column: TaskStatusColumn, isLocked: boolean) => void; + /** Load preferences from localStorage */ + loadPreferences: (projectId: string) => void; + /** Save preferences to localStorage */ + savePreferences: (projectId: string) => boolean; + /** Reset preferences to defaults */ + resetPreferences: (projectId: string) => void; + /** Get preferences for a single column */ + getColumnPreferences: (column: TaskStatusColumn) => ColumnPreferences; +} + +// ============================================ +// Constants +// ============================================ + +/** localStorage key prefix for kanban settings persistence */ +const KANBAN_SETTINGS_KEY_PREFIX = 'kanban-column-prefs'; + +/** 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 */ +export const COLLAPSED_COLUMN_WIDTH = 48; + +// ============================================ +// Helper Functions +// ============================================ + +/** + * Get the localStorage key for a project's kanban settings + */ +function getKanbanSettingsKey(projectId: string): string { + return `${KANBAN_SETTINGS_KEY_PREFIX}-${projectId}`; +} + +/** + * Create default column preferences for all columns + */ +function createDefaultPreferences(): KanbanColumnPreferences { + const preferences: Partial = {}; + + for (const column of TASK_STATUS_COLUMNS) { + preferences[column] = { + width: DEFAULT_COLUMN_WIDTH, + isCollapsed: false, + isLocked: false + }; + } + + return preferences as KanbanColumnPreferences; +} + +/** + * Validate column preferences structure + * Returns true if valid, false if invalid/incomplete + */ +function validatePreferences(data: unknown): data is KanbanColumnPreferences { + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return false; + } + + const prefs = data as Record; + + // Validate each required column exists with correct structure + for (const column of TASK_STATUS_COLUMNS) { + const columnPrefs = prefs[column]; + + if (!columnPrefs || typeof columnPrefs !== 'object') { + return false; + } + + const cp = columnPrefs as Record; + + // Validate width is a number within bounds + if (typeof cp.width !== 'number' || cp.width < MIN_COLUMN_WIDTH || cp.width > MAX_COLUMN_WIDTH) { + return false; + } + + // Validate boolean fields + if (typeof cp.isCollapsed !== 'boolean' || typeof cp.isLocked !== 'boolean') { + return false; + } + } + + return true; +} + +/** + * Clamp a width value to valid bounds + */ +function clampWidth(width: number): number { + return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, width)); +} + +// ============================================ +// Store +// ============================================ + +export const useKanbanSettingsStore = create((set, get) => ({ + columnPreferences: null, + + initializePreferences: () => { + const state = get(); + if (!state.columnPreferences) { + set({ columnPreferences: createDefaultPreferences() }); + } + }, + + setColumnWidth: (column, width) => { + set((state) => { + if (!state.columnPreferences) return state; + + // Don't allow width changes on locked columns + if (state.columnPreferences[column].isLocked) { + return state; + } + + const clampedWidth = clampWidth(width); + + return { + columnPreferences: { + ...state.columnPreferences, + [column]: { + ...state.columnPreferences[column], + width: clampedWidth + } + } + }; + }); + }, + + toggleColumnCollapsed: (column) => { + set((state) => { + if (!state.columnPreferences) return state; + + return { + columnPreferences: { + ...state.columnPreferences, + [column]: { + ...state.columnPreferences[column], + isCollapsed: !state.columnPreferences[column].isCollapsed + } + } + }; + }); + }, + + setColumnCollapsed: (column, isCollapsed) => { + set((state) => { + if (!state.columnPreferences) return state; + + return { + columnPreferences: { + ...state.columnPreferences, + [column]: { + ...state.columnPreferences[column], + isCollapsed + } + } + }; + }); + }, + + toggleColumnLocked: (column) => { + set((state) => { + if (!state.columnPreferences) return state; + + return { + columnPreferences: { + ...state.columnPreferences, + [column]: { + ...state.columnPreferences[column], + isLocked: !state.columnPreferences[column].isLocked + } + } + }; + }); + }, + + setColumnLocked: (column, isLocked) => { + set((state) => { + if (!state.columnPreferences) return state; + + return { + columnPreferences: { + ...state.columnPreferences, + [column]: { + ...state.columnPreferences[column], + isLocked + } + } + }; + }); + }, + + loadPreferences: (projectId) => { + try { + const key = getKanbanSettingsKey(projectId); + const stored = localStorage.getItem(key); + + if (stored) { + const parsed = JSON.parse(stored); + + // Validate structure before using + if (validatePreferences(parsed)) { + set({ columnPreferences: parsed }); + return; + } + + // Invalid data structure, use defaults + console.warn('[KanbanSettingsStore] Invalid preferences in localStorage, using defaults'); + } + + // No stored preferences or invalid, use defaults + set({ columnPreferences: createDefaultPreferences() }); + } catch (error) { + console.error('[KanbanSettingsStore] Failed to load preferences:', error); + set({ columnPreferences: createDefaultPreferences() }); + } + }, + + savePreferences: (projectId) => { + try { + const state = get(); + if (!state.columnPreferences) { + return false; + } + + const key = getKanbanSettingsKey(projectId); + localStorage.setItem(key, JSON.stringify(state.columnPreferences)); + return true; + } catch (error) { + console.error('[KanbanSettingsStore] Failed to save preferences:', error); + return false; + } + }, + + resetPreferences: (projectId) => { + try { + const key = getKanbanSettingsKey(projectId); + localStorage.removeItem(key); + set({ columnPreferences: createDefaultPreferences() }); + } catch (error) { + console.error('[KanbanSettingsStore] Failed to reset preferences:', error); + } + }, + + getColumnPreferences: (column) => { + const state = get(); + + if (!state.columnPreferences) { + return { + width: DEFAULT_COLUMN_WIDTH, + isCollapsed: false, + isLocked: false + }; + } + + return state.columnPreferences[column]; + } +})); + diff --git a/apps/frontend/src/renderer/styles/globals.css b/apps/frontend/src/renderer/styles/globals.css index dce2d1f3..3164caaf 100644 --- a/apps/frontend/src/renderer/styles/globals.css +++ b/apps/frontend/src/renderer/styles/globals.css @@ -1712,3 +1712,4 @@ body { [data-ui-scale="190"] { font-size: 30.4px; } [data-ui-scale="195"] { font-size: 31.2px; } [data-ui-scale="200"] { font-size: 32px; } + diff --git a/apps/frontend/src/shared/i18n/locales/en/tasks.json b/apps/frontend/src/shared/i18n/locales/en/tasks.json index 3f699e3b..f61d4f8f 100644 --- a/apps/frontend/src/shared/i18n/locales/en/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/en/tasks.json @@ -100,7 +100,14 @@ "selectedCountOne": "{{count}} task selected", "selectedCountOther": "{{count}} tasks selected", "createPRs": "Create PRs", - "clearSelection": "Clear Selection" + "clearSelection": "Clear Selection", + "collapseColumn": "Collapse column", + "expandColumn": "Expand column", + "resizeColumn": "Resize column", + "lockColumn": "Lock column width", + "unlockColumn": "Unlock column width", + "columnLocked": "Column width is locked", + "expandAll": "Expand all columns" }, "queue": { "limitReached": "Parallel task limit reached ({{current}}/{{max}}). Task moved to queue.", diff --git a/apps/frontend/src/shared/i18n/locales/fr/tasks.json b/apps/frontend/src/shared/i18n/locales/fr/tasks.json index 62ec5d26..1acd12c5 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/tasks.json +++ b/apps/frontend/src/shared/i18n/locales/fr/tasks.json @@ -99,7 +99,14 @@ "selectedCountOne": "{{count}} tâche sélectionnée", "selectedCountOther": "{{count}} tâches sélectionnées", "createPRs": "Créer les PRs", - "clearSelection": "Effacer la sélection" + "clearSelection": "Effacer la sélection", + "collapseColumn": "Réduire la colonne", + "expandColumn": "Développer la colonne", + "resizeColumn": "Redimensionner la colonne", + "lockColumn": "Verrouiller la largeur de la colonne", + "unlockColumn": "Déverrouiller la largeur de la colonne", + "columnLocked": "La largeur de la colonne est verrouillée", + "expandAll": "Développer toutes les colonnes" }, "queue": { "limitReached": "Limite de tâches parallèles atteinte ({{current}}/{{max}}). Tâche déplacée vers la file d'attente.",