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:
Andy
2026-01-29 14:34:23 +01:00
committed by GitHub
parent bfc232825b
commit a1114664eb
10 changed files with 219 additions and 39 deletions
@@ -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
// ============================================
+24 -1
View File
@@ -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,
+13 -1
View File
@@ -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',
+1
View File
@@ -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';
+5
View File
@@ -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>>;
+21
View File
@@ -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>;