Persist Kanban column collapse state per project via main process (#1579)
* auto-claude: subtask-1-1 - Add KANBAN_PREFS_GET and KANBAN_PREFS_SAVE IPC channel constants Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-2 - Add kanban preferences storage methods to ProjectStore Add getKanbanPreferences(projectId) and saveKanbanPreferences(projectId, prefs) methods to the main process ProjectStore, following the existing tabState pattern. Preferences are stored per project ID in the projects.json file under a new kanbanPreferences key on StoreData. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-3 - Register IPC handlers for KANBAN_PREFS_GET and KANBAN_PREFS_SAVE Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-1-4 - Expose getKanbanPreferences and saveKanbanPreferences in preload API Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * auto-claude: subtask-2-1 - Update kanban-settings-store.ts to persist via IPC Update kanban-settings-store to persist column preferences via IPC to the main process instead of relying solely on localStorage. loadPreferences now first loads from localStorage as a sync cache, then async loads from the main process (source of truth) and updates both store and localStorage. savePreferences writes to localStorage synchronously and triggers a debounced IPC save to the main process (100ms debounce following the saveTabStateToMain pattern). resetPreferences also saves the reset state to the main process. Added getKanbanPreferences and saveKanbanPreferences to the ElectronAPI interface and browser mock. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address race conditions and cleanup in kanban preferences - Fix debounced save capturing stale store state by capturing columnPreferences at call time instead of when timer fires - Fix async IPC load overwriting current project's preferences by tracking currentLoadingProjectId and discarding stale results - Clear pending save timer on project switch to prevent cross-project contamination - Clean up kanban preferences when project is removed to prevent orphaned data in projects.json - Extract shared KanbanColumnPreference type to reduce duplication across IPC boundary (main, preload, renderer) - Add debug logging for IPC save failures for consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: stabilize flaky subprocess-spawn test for task tracking The test was flaky because it expected immediate task cleanup after emitting exit events, but cleanup is async. Changed to use vi.waitFor to wait for tasks to be removed from tracking, and use Promise.allSettled to ensure both task promises settle before checking. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<IPCResult<Record<string, { width: number; isCollapsed: boolean; isLocked: boolean }> | 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<string, { width: number; isCollapsed: boolean; isLocked: boolean }>
|
||||
): Promise<IPCResult> => {
|
||||
try {
|
||||
projectStore.saveKanbanPreferences(projectId, preferences);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Project Initialization Operations
|
||||
// ============================================
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
tabState?: TabState;
|
||||
kanbanPreferences?: Record<string, KanbanPreferences>;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<IPCResult<TabState>>;
|
||||
saveTabState: (tabState: TabState) => Promise<IPCResult>;
|
||||
|
||||
// Kanban Preferences (persisted in main process per project)
|
||||
getKanbanPreferences: (projectId: string) => Promise<IPCResult<KanbanPreferences | null>>;
|
||||
saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences) => Promise<IPCResult>;
|
||||
|
||||
// Context Operations
|
||||
getProjectContext: (projectId: string) => Promise<IPCResult<unknown>>;
|
||||
refreshProjectIndex: (projectId: string) => Promise<IPCResult<unknown>>;
|
||||
@@ -170,6 +175,13 @@ export const createProjectAPI = (): ProjectAPI => ({
|
||||
saveTabState: (tabState: TabState): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TAB_STATE_SAVE, tabState),
|
||||
|
||||
// Kanban Preferences (persisted in main process per project)
|
||||
getKanbanPreferences: (projectId: string): Promise<IPCResult<KanbanPreferences | null>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.KANBAN_PREFS_GET, projectId),
|
||||
|
||||
saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences): Promise<IPCResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.KANBAN_PREFS_SAVE, projectId, preferences),
|
||||
|
||||
// Context Operations
|
||||
getProjectContext: (projectId: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.CONTEXT_GET, projectId),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<KanbanSettingsState>((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<KanbanSettingsState>((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<KanbanSettingsState>((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<KanbanSettingsState>((set, get) =>
|
||||
return state.columnPreferences[column];
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<IPCResult<TabState>>;
|
||||
saveTabState: (tabState: TabState) => Promise<IPCResult>;
|
||||
|
||||
// Kanban Preferences (persisted in main process per project)
|
||||
getKanbanPreferences: (projectId: string) => Promise<IPCResult<KanbanPreferences | null>>;
|
||||
saveKanbanPreferences: (projectId: string, preferences: KanbanPreferences) => Promise<IPCResult>;
|
||||
|
||||
// Task operations
|
||||
getTasks: (projectId: string, options?: { forceRefresh?: boolean }) => Promise<IPCResult<Task[]>>;
|
||||
createTask: (projectId: string, title: string, description: string, metadata?: TaskMetadata) => Promise<IPCResult<Task>>;
|
||||
|
||||
@@ -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<string, KanbanColumnPreference>;
|
||||
Reference in New Issue
Block a user