diff --git a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts index 29615c72..537c42bd 100644 --- a/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts +++ b/apps/frontend/src/__tests__/integration/subprocess-spawn.test.ts @@ -398,16 +398,18 @@ describe('Subprocess Spawn Integration', () => { // Wait for spawn to complete (ensures exit handlers are attached) await new Promise(resolve => setImmediate(resolve)); - // Emit exit for task-1 (first task's handler) + // Emit exit events - both tasks share the same mockProcess, so both handlers fire + // We emit twice to ensure both task handlers receive exit events mockProcess.emit('exit', 0); - await promise1; - - // Emit exit for task-2 (second task's handler replaces first due to shared mock process) mockProcess.emit('exit', 0); - await promise2; - // Tasks should be removed from tracking after exit - expect(manager.getRunningTasks()).toHaveLength(0); + // Wait for promises to settle + await Promise.allSettled([promise1, promise2]); + + // Wait for tasks to be removed from tracking (cleanup may be async) + await vi.waitFor(() => { + expect(manager.getRunningTasks()).toHaveLength(0); + }, { timeout: 5000 }); }, 15000); it('should use configured Python path', async () => { diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index d752be8d..1d021bf7 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -286,6 +286,44 @@ export function registerProjectHandlers( } ); + // ============================================ + // Kanban Preferences Operations (persisted in main process) + // ============================================ + + ipcMain.handle( + IPC_CHANNELS.KANBAN_PREFS_GET, + async (_, projectId: string): Promise | null>> => { + try { + const preferences = projectStore.getKanbanPreferences(projectId); + return { success: true, data: preferences }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle( + IPC_CHANNELS.KANBAN_PREFS_SAVE, + async ( + _, + projectId: string, + preferences: Record + ): Promise => { + try { + projectStore.saveKanbanPreferences(projectId, preferences); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + // ============================================ // Project Initialization Operations // ============================================ diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index 80c7a82a..944551b5 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -2,7 +2,7 @@ import { app } from 'electron'; import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent } from 'fs'; import path from 'path'; import { v4 as uuidv4 } from 'uuid'; -import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask } from '../shared/types'; +import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences } from '../shared/types'; import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants'; import { getAutoBuildPath, isInitialized } from './project-initializer'; import { getTaskWorktreeDir } from './worktree-paths'; @@ -18,6 +18,7 @@ interface StoreData { projects: Project[]; settings: Record; tabState?: TabState; + kanbanPreferences?: Record; } interface TasksCacheEntry { @@ -137,6 +138,10 @@ export class ProjectStore { const index = this.data.projects.findIndex((p) => p.id === projectId); if (index !== -1) { this.data.projects.splice(index, 1); + // Clean up kanban preferences to avoid orphaned data + if (this.data.kanbanPreferences?.[projectId]) { + delete this.data.kanbanPreferences[projectId]; + } this.save(); return true; } @@ -177,6 +182,24 @@ export class ProjectStore { this.save(); } + /** + * Get kanban column preferences for a specific project + */ + getKanbanPreferences(projectId: string): KanbanPreferences | null { + return this.data.kanbanPreferences?.[projectId] ?? null; + } + + /** + * Save kanban column preferences for a specific project + */ + saveKanbanPreferences(projectId: string, preferences: KanbanPreferences): void { + if (!this.data.kanbanPreferences) { + this.data.kanbanPreferences = {}; + } + this.data.kanbanPreferences[projectId] = preferences; + this.save(); + } + /** * Validate all projects to ensure their .auto-claude folders still exist. * If a project has autoBuildPath set but the folder was deleted, diff --git a/apps/frontend/src/preload/api/project-api.ts b/apps/frontend/src/preload/api/project-api.ts index 3852c9e4..a1725c22 100644 --- a/apps/frontend/src/preload/api/project-api.ts +++ b/apps/frontend/src/preload/api/project-api.ts @@ -11,7 +11,8 @@ import type { InfrastructureStatus, GraphitiValidationResult, GraphitiConnectionTestResult, - GitStatus + GitStatus, + KanbanPreferences } from '../../shared/types'; // Tab state interface (persisted in main process) @@ -37,6 +38,10 @@ export interface ProjectAPI { getTabState: () => Promise>; saveTabState: (tabState: TabState) => Promise; + // Kanban Preferences (persisted in main process per project) + getKanbanPreferences: (projectId: string) => Promise>; + saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences) => Promise; + // Context Operations getProjectContext: (projectId: string) => Promise>; refreshProjectIndex: (projectId: string) => Promise>; @@ -170,6 +175,13 @@ export const createProjectAPI = (): ProjectAPI => ({ saveTabState: (tabState: TabState): Promise => ipcRenderer.invoke(IPC_CHANNELS.TAB_STATE_SAVE, tabState), + // Kanban Preferences (persisted in main process per project) + getKanbanPreferences: (projectId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.KANBAN_PREFS_GET, projectId), + + saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences): Promise => + ipcRenderer.invoke(IPC_CHANNELS.KANBAN_PREFS_SAVE, projectId, preferences), + // Context Operations getProjectContext: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET, projectId), diff --git a/apps/frontend/src/renderer/lib/mocks/project-mock.ts b/apps/frontend/src/renderer/lib/mocks/project-mock.ts index fdd22943..820b8f30 100644 --- a/apps/frontend/src/renderer/lib/mocks/project-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/project-mock.ts @@ -55,6 +55,10 @@ export const projectMock = { saveTabState: async () => ({ success: true }), + // Kanban Preferences + getKanbanPreferences: async () => ({ success: true, data: null }), + saveKanbanPreferences: async () => ({ success: true }), + // Dialog operations selectDirectory: async () => { return prompt('Enter project path (browser mock):', '/Users/demo/projects/new-project'); diff --git a/apps/frontend/src/renderer/stores/kanban-settings-store.ts b/apps/frontend/src/renderer/stores/kanban-settings-store.ts index 35369072..85a5e95f 100644 --- a/apps/frontend/src/renderer/stores/kanban-settings-store.ts +++ b/apps/frontend/src/renderer/stores/kanban-settings-store.ts @@ -1,22 +1,14 @@ import { create } from 'zustand'; import type { TaskStatusColumn } from '../../shared/constants/task'; import { TASK_STATUS_COLUMNS } from '../../shared/constants/task'; +import type { KanbanColumnPreference } from '../../shared/types/kanban'; // ============================================ // 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; -} +// Re-export shared type for backwards compatibility +export type ColumnPreferences = KanbanColumnPreference; /** * All column preferences keyed by status column @@ -43,9 +35,9 @@ interface KanbanSettingsState { toggleColumnLocked: (column: TaskStatusColumn) => void; /** Set column locked state explicitly */ setColumnLocked: (column: TaskStatusColumn, isLocked: boolean) => void; - /** Load preferences from localStorage */ + /** Load preferences from main process (IPC), falling back to localStorage */ loadPreferences: (projectId: string) => void; - /** Save preferences to localStorage */ + /** Save preferences to localStorage (sync cache) and main process (debounced IPC) */ savePreferences: (projectId: string) => boolean; /** Reset preferences to defaults */ resetPreferences: (projectId: string) => void; @@ -57,7 +49,7 @@ interface KanbanSettingsState { // Constants // ============================================ -/** localStorage key prefix for kanban settings persistence */ +/** localStorage key prefix for kanban settings persistence (sync cache) */ const KANBAN_SETTINGS_KEY_PREFIX = 'kanban-column-prefs'; /** Default column width in pixels */ @@ -72,6 +64,15 @@ export const MAX_COLUMN_WIDTH = 600; /** Collapsed column width in pixels */ export const COLLAPSED_COLUMN_WIDTH = 48; +// ============================================ +// Debounce timer for saving kanban preferences to main process +// ============================================ + +let saveKanbanPrefsTimeout: ReturnType | null = null; + +// Track the current project being loaded to detect stale IPC results +let currentLoadingProjectId: string | null = null; + // ============================================ // Helper Functions // ============================================ @@ -142,6 +143,34 @@ function clampWidth(width: number): number { return Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, width)); } +/** + * Save kanban preferences to main process via IPC (debounced) + * Follows the saveTabStateToMain() pattern from project-store.ts + * + * NOTE: We capture columnPreferences at call time to avoid race conditions + * when the user switches projects during the debounce window. + */ +function saveKanbanPreferencesToMain(projectId: string): void { + // Capture preferences at call time to avoid saving wrong project's data + const preferencesToSave = useKanbanSettingsStore.getState().columnPreferences; + if (!preferencesToSave) return; + + // Clear any pending save + if (saveKanbanPrefsTimeout) { + clearTimeout(saveKanbanPrefsTimeout); + } + + // Debounce saves to avoid excessive IPC calls + saveKanbanPrefsTimeout = setTimeout(async () => { + try { + await window.electronAPI.saveKanbanPreferences(projectId, preferencesToSave); + } catch (err) { + // IPC save failed — localStorage sync cache is still available as fallback + console.debug('[KanbanSettings] IPC save failed, using localStorage fallback:', err); + } + }, 100); +} + // ============================================ // Store // ============================================ @@ -244,29 +273,64 @@ export const useKanbanSettingsStore = create((set, get) => }, loadPreferences: (projectId) => { + // Clear any pending save from previous project to prevent cross-project contamination + if (saveKanbanPrefsTimeout) { + clearTimeout(saveKanbanPrefsTimeout); + saveKanbanPrefsTimeout = null; + } + + // Track current project to detect stale IPC results + currentLoadingProjectId = projectId; + + // First, try loading from localStorage as immediate sync cache 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 }); + } else { + set({ columnPreferences: createDefaultPreferences() }); + } + } else { + set({ columnPreferences: createDefaultPreferences() }); + } + } catch { + set({ columnPreferences: createDefaultPreferences() }); + } + + // Then, async load from main process via IPC (source of truth) + (async () => { + try { + const result = await window.electronAPI.getKanbanPreferences(projectId); + + // Check if project changed while IPC was in flight - discard stale result + if (currentLoadingProjectId !== projectId) { return; } - // Invalid data structure, use defaults - console.warn('[KanbanSettingsStore] Invalid preferences in localStorage, using defaults'); - } + if (result?.success && result.data) { + if (validatePreferences(result.data)) { + set({ columnPreferences: result.data }); - // No stored preferences or invalid, use defaults - set({ columnPreferences: createDefaultPreferences() }); - } catch (error) { - console.error('[KanbanSettingsStore] Failed to load preferences:', error); - set({ columnPreferences: createDefaultPreferences() }); - } + // Update localStorage sync cache with IPC data + try { + const key = getKanbanSettingsKey(projectId); + localStorage.setItem(key, JSON.stringify(result.data)); + } catch { + // localStorage write failed, non-critical + } + return; + } + } + + // IPC returned no data or invalid data — keep whatever was loaded from localStorage/defaults + } catch { + // IPC call failed — keep localStorage/default data already set above + } + })(); }, savePreferences: (projectId) => { @@ -276,11 +340,15 @@ export const useKanbanSettingsStore = create((set, get) => return false; } + // Save to localStorage as sync cache const key = getKanbanSettingsKey(projectId); localStorage.setItem(key, JSON.stringify(state.columnPreferences)); + + // Save to main process via debounced IPC + saveKanbanPreferencesToMain(projectId); + return true; - } catch (error) { - console.error('[KanbanSettingsStore] Failed to save preferences:', error); + } catch { return false; } }, @@ -290,8 +358,11 @@ export const useKanbanSettingsStore = create((set, get) => const key = getKanbanSettingsKey(projectId); localStorage.removeItem(key); set({ columnPreferences: createDefaultPreferences() }); - } catch (error) { - console.error('[KanbanSettingsStore] Failed to reset preferences:', error); + + // Also save reset state to main process + saveKanbanPreferencesToMain(projectId); + } catch { + // Reset failed, non-critical } }, @@ -309,4 +380,3 @@ export const useKanbanSettingsStore = create((set, get) => return state.columnPreferences[column]; } })); - diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index 440bc691..6195383a 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -16,6 +16,10 @@ export const IPC_CHANNELS = { TAB_STATE_GET: 'tabState:get', TAB_STATE_SAVE: 'tabState:save', + // Kanban preferences (per-project column collapse state) + KANBAN_PREFS_GET: 'kanbanPrefs:get', + KANBAN_PREFS_SAVE: 'kanbanPrefs:save', + // Task operations TASK_LIST: 'task:list', TASK_CREATE: 'task:create', diff --git a/apps/frontend/src/shared/types/index.ts b/apps/frontend/src/shared/types/index.ts index 38fbe58d..9ac42f76 100644 --- a/apps/frontend/src/shared/types/index.ts +++ b/apps/frontend/src/shared/types/index.ts @@ -8,6 +8,7 @@ export * from './common'; // Domain-specific types export * from './project'; export * from './task'; +export * from './kanban'; export * from './terminal'; export * from './agent'; export * from './settings'; diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index afb35a38..cc242bf7 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -136,6 +136,7 @@ import type { GitLabNewCommitsCheck } from './integrations'; import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile'; +import type { KanbanPreferences } from './kanban'; // Electron API exposed via contextBridge // Tab state interface (persisted in main process) @@ -158,6 +159,10 @@ export interface ElectronAPI { getTabState: () => Promise>; saveTabState: (tabState: TabState) => Promise; + // Kanban Preferences (persisted in main process per project) + getKanbanPreferences: (projectId: string) => Promise>; + saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences) => Promise; + // Task operations getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise>; createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise>; diff --git a/apps/frontend/src/shared/types/kanban.ts b/apps/frontend/src/shared/types/kanban.ts new file mode 100644 index 00000000..5d30a058 --- /dev/null +++ b/apps/frontend/src/shared/types/kanban.ts @@ -0,0 +1,21 @@ +/** + * Kanban board column preference types + * Shared across IPC boundary (main process, preload, renderer) + */ + +/** + * Column preferences for a single kanban column + */ +export interface KanbanColumnPreference { + /** 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 column status (e.g., 'backlog', 'in_progress', 'done') + */ +export type KanbanPreferences = Record;