Skip to content

Persist zustand stores using electron-store#109

Merged
saddlepaddle merged 1 commit intomainfrom
add-persist-zustand
Nov 20, 2025
Merged

Persist zustand stores using electron-store#109
saddlepaddle merged 1 commit intomainfrom
add-persist-zustand

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Nov 20, 2025

Description

Related Issues

Type of Change

  • Bug fix
  • New feature
  • Documentation
  • Refactor
  • Other (please describe):

Testing

Screenshots (if applicable)

Additional Notes

Summary by CodeRabbit

  • New Features

    • Application state now persists across sessions—workspaces and tabs are automatically saved locally and restored when you restart the desktop application.
  • Updates

    • Updated workspace management interface with revised button label and description.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 20, 2025

Walkthrough

The changes introduce persistent storage to the Electron desktop app using electron-store. A new storage layer is implemented with IPC handlers in the main process, a Zustand adapter in the renderer, and persistence wiring added to existing tabs and workspaces stores. The UI is updated to leverage the new persistence functionality.

Changes

Cohort / File(s) Summary
Build & Dependencies
apps/desktop/electron.vite.config.ts, apps/desktop/package.json
Added electron-store ^11.0.2 dependency and updated build config to externalize electron-store alongside other Superset packages.
Storage Infrastructure
apps/desktop/src/main/lib/storage-manager.ts, apps/desktop/src/main/lib/storage-ipcs.ts
New modules for Electron Store initialization (persisting to ~/.superset/app-state.json) and IPC handler registration for storage:get, storage:set, and storage:delete channels.
IPC Channel Definitions
apps/desktop/src/shared/ipc-channels/storage.ts, apps/desktop/src/shared/ipc-channels/index.ts
New StorageChannels interface defining three typed storage operations (get, set, delete) and integration into the main IpcChannels interface.
Preload Bridge
apps/desktop/src/preload/index.ts
Extended Window interface to expose electronStore API with get, set, and delete methods, delegating to IPC channels via contextBridge.
Renderer Storage Adapter
apps/desktop/src/renderer/lib/electron-storage.ts
New Zustand-compatible JSON storage adapter wrapping window.electronStore for state persistence.
Store Persistence
apps/desktop/src/renderer/stores/tabs/store.ts, apps/desktop/src/renderer/stores/workspaces.ts
Added persist middleware with electronStorage backend to tabs and workspaces stores, enabling cross-session state retention.
App Initialization
apps/desktop/src/main/index.ts
Integrated registerStorageHandlers call during startup to enable IPC storage operations.
UI Updates
apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx
Updated button click handler to create and persist new workspaces and tabs, changed button label and subtitle.

Sequence Diagram

sequenceDiagram
    participant App as Main Process
    participant IPC as IPC Handler
    participant Store as electron-store
    participant Preload as Preload Script
    participant Renderer as Renderer Process
    participant Zustand as Zustand Store

    App->>IPC: registerStorageHandlers()
    Note over IPC: Set up 3 IPC channels<br/>storage:get/set/delete

    Renderer->>Zustand: Initialize with<br/>electronStorage adapter
    Zustand->>Preload: Call window.electronStore.get/set

    Preload->>IPC: Send IPC message<br/>(storage:get/set/delete)
    IPC->>Store: Delegate to store.get/set/delete
    Store->>Store: Persist to<br/>~/.superset/app-state.json
    
    Store-->>IPC: Return result
    IPC-->>Preload: IPC response
    Preload-->>Zustand: Return value
    Zustand-->>Renderer: Update state
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Multiple files touched across initialization, IPC, storage, and rendering layers with semi-repetitive storage operation patterns
  • Type-safe IPC channel definitions require verification for correctness
  • Store persistence initialization logic and cross-session state migration should be validated
  • UI component logic for workspace/tab creation with async store updates needs careful review

Possibly related PRs

  • tabs mangement interaction #105: Adds large UI rewrite of tabs/workspaces stores and components; this PR directly implements the persistence layer that supports those store modifications.

Poem

🐰 A rabbit hops through electron paths,
With storage keys and state mishaps,
Persistence blooms in ~/.superset,
Desktop dreams are manifest—rest assured, we've blessed!
IPC channels sing in perfect tune, 🎵

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description contains only the template structure with all sections empty—no actual content, related issues, type of change, testing steps, or additional context provided. Fill in all relevant sections: describe the changes and their purpose, link any related issues, select the appropriate change type, explain testing steps performed, and add any relevant notes.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Persist zustand stores using electron-store' directly and accurately describes the main change: adding persistence functionality to Zustand stores via electron-store integration.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch add-persist-zustand

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Comment @coderabbitai help to get the list of available commands and usage tips.

@saddlepaddle saddlepaddle merged commit b4d632d into main Nov 20, 2025
0 of 5 checks passed
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/stores/tabs/store.ts (1)

43-95: Remove unused createInitialTabs function.

This function is no longer called after switching to empty initial state. Either use it for first-time initialization or remove it to avoid confusion.

🧹 Nitpick comments (6)
apps/desktop/src/main/index.ts (1)

27-28: Consider moving storage handler registration inside app.whenReady().

While ipcMain.handle() can be called before the app is ready, electron-store may require the app to be fully initialized to determine the correct user data path. Moving the registration inside the app.whenReady() callback ensures all Electron APIs are available.

Apply this diff:

-// Register storage IPC handlers
-registerStorageHandlers();
-
 // Allow multiple instances - removed single instance lock
 (async () => {
 	await app.whenReady();
 
+	// Register storage IPC handlers
+	registerStorageHandlers();
+
 	await makeAppSetup(() => MainWindow());
 
 	// Stop all periodic rescans when app is quitting
apps/desktop/src/renderer/stores/tabs/store.ts (1)

611-614: Consider adding state validation and migration logic.

The persist configuration lacks:

  • Schema validation on load (corrupt or incompatible state could crash the app)
  • Migration logic for schema changes
  • Version tracking

Zustand's persist middleware supports a migrate function and version field to handle schema evolution.

Example:

 {
   name: "tabs-storage",
   storage: electronStorage,
+  version: 1,
+  migrate: (persistedState: any, version: number) => {
+    // Validate and migrate state if needed
+    if (!persistedState?.tabs || !Array.isArray(persistedState.tabs)) {
+      return { tabs: [], activeTabIds: {}, tabHistoryStacks: {} };
+    }
+    return persistedState;
+  },
 }
apps/desktop/src/renderer/lib/electron-storage.ts (1)

7-18: Add error handling for IPC failures.

IPC calls can fail due to various reasons (main process crashed, IPC channel not registered, etc.). The adapter should handle these gracefully to prevent store corruption.

 const electronStorageAdapter = {
   getItem: async (name: string): Promise<string | null> => {
+    try {
       const value = await window.electronStore.get(name);
       return value as string | null;
+    } catch (error) {
+      console.error(`Failed to get item "${name}":`, error);
+      return null;
+    }
   },
   setItem: async (name: string, value: string): Promise<void> => {
+    try {
       await window.electronStore.set(name, value);
+    } catch (error) {
+      console.error(`Failed to set item "${name}":`, error);
+    }
   },
   removeItem: async (name: string): Promise<void> => {
+    try {
       await window.electronStore.delete(name);
+    } catch (error) {
+      console.error(`Failed to remove item "${name}":`, error);
+    }
   },
 };
apps/desktop/src/main/lib/storage-manager.ts (1)

9-12: Consider adding TypeScript schema for type safety.

electron-store supports generic type parameters to provide type-safe access to stored data. This prevents runtime errors from type mismatches.

Example:

+interface StoreSchema {
+  'tabs-storage': string;
+  'workspaces-storage': string;
+  // Add other storage keys as needed
+}
+
-export const store = new Store({
+export const store = new Store<StoreSchema>({
   cwd: join(homedir(), ".superset"),
   name: "app-state",
 });

This ensures compile-time type checking for all storage operations.

apps/desktop/src/main/lib/storage-ipcs.ts (1)

15-15: Replace any with a more specific type.

Using any for the value parameter disables type checking and could allow storing incompatible data that breaks on deserialization.

Consider:

-async (_event, input: { key: string; value: any }) => {
+async (_event, input: { key: string; value: unknown }) => {

Using unknown forces explicit type checking before use, preventing runtime errors.

apps/desktop/src/preload/index.ts (1)

13-17: Improve type safety for electronStore API.

The get method returns any, which disables type checking. Consider using unknown to enforce type validation at the call site.

 electronStore: {
-  get: (key: string) => any;
+  get: (key: string) => Promise<unknown>;
   set: (key: string, value: any) => void;
   delete: (key: string) => void;
 };

Note: The methods should also return Promise<void> for consistency since they're async IPC calls:

 electronStore: {
   get: (key: string) => Promise<unknown>;
-  set: (key: string, value: any) => void;
-  delete: (key: string) => void;
+  set: (key: string, value: any) => Promise<void>;
+  delete: (key: string) => Promise<void>;
 };
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d0ca08a and f3e4b00.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • apps/desktop/electron.vite.config.ts (1 hunks)
  • apps/desktop/package.json (1 hunks)
  • apps/desktop/src/main/index.ts (2 hunks)
  • apps/desktop/src/main/lib/storage-ipcs.ts (1 hunks)
  • apps/desktop/src/main/lib/storage-manager.ts (1 hunks)
  • apps/desktop/src/preload/index.ts (2 hunks)
  • apps/desktop/src/renderer/lib/electron-storage.ts (1 hunks)
  • apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx (2 hunks)
  • apps/desktop/src/renderer/stores/tabs/store.ts (3 hunks)
  • apps/desktop/src/renderer/stores/workspaces.ts (2 hunks)
  • apps/desktop/src/shared/ipc-channels/index.ts (2 hunks)
  • apps/desktop/src/shared/ipc-channels/storage.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
apps/desktop/src/main/lib/storage-ipcs.ts (1)
apps/desktop/src/main/lib/storage-manager.ts (1)
  • store (9-12)
apps/desktop/src/shared/ipc-channels/index.ts (1)
apps/desktop/src/shared/ipc-channels/storage.ts (1)
  • StorageChannels (5-20)
apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx (2)
apps/desktop/src/renderer/stores/workspaces.ts (1)
  • useWorkspacesStore (28-97)
apps/desktop/src/renderer/stores/tabs/store.ts (1)
  • useAddTab (624-624)
apps/desktop/src/renderer/stores/tabs/store.ts (2)
apps/old-desktop/src/main/lib/storage/lowdb-adapter.ts (2)
  • set (174-184)
  • get (163-172)
apps/desktop/src/renderer/lib/electron-storage.ts (1)
  • electronStorage (20-20)
apps/desktop/src/main/index.ts (1)
apps/desktop/src/main/lib/storage-ipcs.ts (1)
  • registerStorageHandlers (8-23)
apps/desktop/src/renderer/stores/workspaces.ts (1)
apps/desktop/src/renderer/lib/electron-storage.ts (1)
  • electronStorage (20-20)
🔇 Additional comments (4)
apps/desktop/electron.vite.config.ts (1)

28-28: LGTM!

Correctly externalizes electron-store to prevent bundling issues with native Electron modules.

apps/desktop/src/preload/index.ts (1)

74-83: LGTM!

The electronStore API correctly bridges IPC calls to the main process storage handlers via contextBridge, following Electron security best practices.

apps/desktop/package.json (1)

47-47: electron-store version 11.0.2 is valid with no known vulnerabilities.

Verification confirms that electron-store version 11.0.2 exists on npm and has no known security advisories in the GitHub security database.

apps/desktop/src/shared/ipc-channels/index.ts (1)

11-11: LGTM: Storage channels properly integrated.

The StorageChannels interface is correctly imported and added to the IpcChannels type aggregation, maintaining type safety across IPC boundaries.

Also applies to: 39-40

Comment on lines +8 to +23
export function registerStorageHandlers() {
ipcMain.handle("storage:get", async (_event, input: { key: string }) => {
return store.get(input.key);
});

ipcMain.handle(
"storage:set",
async (_event, input: { key: string; value: any }) => {
store.set(input.key, input.value);
},
);

ipcMain.handle("storage:delete", async (_event, input: { key: string }) => {
store.delete(input.key);
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling and input validation.

The IPC handlers lack:

  1. Error handling for store operations (could throw)
  2. Input validation (empty/invalid keys)
  3. Return value handling for storage:set

Without error handling, exceptions will crash or hang the renderer process awaiting the IPC response.

Apply this diff:

 export function registerStorageHandlers() {
   ipcMain.handle("storage:get", async (_event, input: { key: string }) => {
+    if (!input?.key || typeof input.key !== 'string') {
+      throw new Error('Invalid key provided');
+    }
+    try {
       return store.get(input.key);
+    } catch (error) {
+      console.error(`Failed to get key "${input.key}":`, error);
+      throw error;
+    }
   });
 
   ipcMain.handle(
     "storage:set",
     async (_event, input: { key: string; value: any }) => {
+      if (!input?.key || typeof input.key !== 'string') {
+        throw new Error('Invalid key provided');
+      }
+      try {
         store.set(input.key, input.value);
+      } catch (error) {
+        console.error(`Failed to set key "${input.key}":`, error);
+        throw error;
+      }
     },
   );
 
   ipcMain.handle("storage:delete", async (_event, input: { key: string }) => {
+    if (!input?.key || typeof input.key !== 'string') {
+      throw new Error('Invalid key provided');
+    }
+    try {
       store.delete(input.key);
+    } catch (error) {
+      console.error(`Failed to delete key "${input.key}":`, error);
+      throw error;
+    }
   });
 }

Comment on lines +8 to +10
getItem: async (name: string): Promise<string | null> => {
const value = await window.electronStore.get(name);
return value as string | null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unsafe type assertion could cause runtime errors.

The type cast as string | null on line 10 assumes the stored value is always a string, but electron-store can return any type. If non-string data was previously stored under the same key, this will cause issues when Zustand tries to parse it as JSON.

Apply this diff to add validation:

 getItem: async (name: string): Promise<string | null> => {
   const value = await window.electronStore.get(name);
-  return value as string | null;
+  if (value === undefined || value === null) return null;
+  if (typeof value !== 'string') {
+    console.warn(`Expected string for key "${name}", got ${typeof value}`);
+    return null;
+  }
+  return value;
 },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
getItem: async (name: string): Promise<string | null> => {
const value = await window.electronStore.get(name);
return value as string | null;
getItem: async (name: string): Promise<string | null> => {
const value = await window.electronStore.get(name);
if (value === undefined || value === null) return null;
if (typeof value !== 'string') {
console.warn(`Expected string for key "${name}", got ${typeof value}`);
return null;
}
return value;
},
🤖 Prompt for AI Agents
In apps/desktop/src/renderer/lib/electron-storage.ts around lines 8 to 10,
remove the unsafe type assertion and add runtime validation: check if the
retrieved value is undefined or null and return null; if it's already a string
return it; otherwise convert non-string values to a JSON string (JSON.stringify)
before returning so Zustand receives a string and you avoid runtime type errors.

Comment on lines +44 to +59
onClick={() => {
if (!activeWorkspaceId) {
addWorkspace();
setTimeout(() => {
const newWorkspaceId =
useWorkspacesStore.getState().activeWorkspaceId;
if (newWorkspaceId) {
addTab(newWorkspaceId);
markWorkspaceAsUsed(newWorkspaceId);
}
}, 0);
} else {
addTab(activeWorkspaceId);
markWorkspaceAsUsed(activeWorkspaceId);
}
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: Remove setTimeout anti-pattern.

The setTimeout(..., 0) pattern is unnecessary and introduces a race condition. Zustand state updates are synchronous by default, so activeWorkspaceId is updated immediately when addWorkspace() completes. The setTimeout adds complexity without benefit and the direct getState() call (line 49) bypasses the hook-based reactivity model.

Apply this diff to simplify the logic:

 onClick={() => {
   if (!activeWorkspaceId) {
     addWorkspace();
-    setTimeout(() => {
-      const newWorkspaceId =
-        useWorkspacesStore.getState().activeWorkspaceId;
-      if (newWorkspaceId) {
-        addTab(newWorkspaceId);
-        markWorkspaceAsUsed(newWorkspaceId);
-      }
-    }, 0);
-  } else {
-    addTab(activeWorkspaceId);
-    markWorkspaceAsUsed(activeWorkspaceId);
   }
+  // activeWorkspaceId is now guaranteed to be set (either existing or newly created)
+  const currentWorkspaceId = useWorkspacesStore.getState().activeWorkspaceId;
+  if (currentWorkspaceId) {
+    addTab(currentWorkspaceId);
+    markWorkspaceAsUsed(currentWorkspaceId);
+  }
 }}

Alternatively, refactor addWorkspace to return the new workspace ID for even cleaner code.

🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx
around lines 44 to 59, remove the setTimeout anti-pattern and the bypass of hook
reactivity: call addWorkspace(), then immediately read the new ID from
useWorkspacesStore.getState() (or even better, change addWorkspace to return the
new workspace ID) and proceed to addTab(newWorkspaceId) and
markWorkspaceAsUsed(newWorkspaceId); if activeWorkspaceId already exists, keep
the existing branch that calls addTab(activeWorkspaceId) and
markWorkspaceAsUsed(activeWorkspaceId). Ensure no asynchronous wrapper
(setTimeout) is used and rely on Zustand’s synchronous update or return value
from addWorkspace.

Comment on lines +63 to +64
<div className="font-medium text-foreground">Create Tab & Start</div>
<div className="text-sm text-muted-foreground">Test persistence</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Replace debug text with production-ready copy.

The subtitle "Test persistence" appears to be temporary debug text. Please update with appropriate user-facing copy before merging.

Suggested alternatives:

-<div className="text-sm text-muted-foreground">Test persistence</div>
+<div className="text-sm text-muted-foreground">Open a new tab</div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="font-medium text-foreground">Create Tab & Start</div>
<div className="text-sm text-muted-foreground">Test persistence</div>
<div className="font-medium text-foreground">Create Tab & Start</div>
<div className="text-sm text-muted-foreground">Open a new tab</div>
🤖 Prompt for AI Agents
In
apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView.tsx
around lines 63-64, the subtitle currently reads "Test persistence" which is
debug text; replace it with production-ready user-facing copy such as "Save your
workspace settings", "Automatically persist tabs and state", or simply
"Workspace will be saved automatically" (choose the wording that best fits the
UI tone), and ensure the string is i18n-ready if the app uses localization.

Comment on lines +180 to +182
tabs: [],
activeTabIds: {},
tabHistoryStacks: {},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Breaking change: Empty initial state will affect new and existing users.

The initial state has changed from populated tabs (via createInitialTabs()) to empty structures. This means:

  • New users won't see welcome tabs on first launch
  • The createInitialTabs() function (lines 43-95) is now unused

Consider initializing with createInitialTabs() on first load, then relying on persistence for subsequent sessions.

Example approach:

-		tabs: [],
-		activeTabIds: {},
-		tabHistoryStacks: {},
+		tabs: createInitialTabs(),
+		activeTabIds: { "workspace-1": "tab-single-1" },
+		tabHistoryStacks: {},

Then let persistence override these defaults on subsequent loads.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/desktop/src/renderer/stores/tabs/store.ts around lines 180 to 182, the
reducer/store initial state was changed to empty structures which removes the
welcome/default tabs and leaves createInitialTabs() unused; restore initial tabs
by calling createInitialTabs() when initializing state if no persisted state
exists (i.e., use createInitialTabs() as the default initial value or run it on
first load), then allow the persistence layer to override that default on
subsequent loads so new users still see the welcome tabs while existing users
keep their persisted tabs.

Comment on lines +30 to +94
persist(
(set) => ({
workspaces: [],
activeWorkspaceId: null,

addWorkspace: () => {
const newWorkspace = createNewWorkspace();
set((state) => ({
workspaces: [...state.workspaces, newWorkspace],
activeWorkspaceId: newWorkspace.id,
}));
},

removeWorkspace: (id) => {
set((state) => {
const workspaces = state.workspaces.filter(
(workspace) => workspace.id !== id,
);
if (workspaces.length === 0) {
const newWorkspace = createNewWorkspace();
return {
workspaces: [newWorkspace],
activeWorkspaceId: newWorkspace.id,
};
}
addWorkspace: () => {
const newWorkspace = createNewWorkspace();
set((state) => ({
workspaces: [...state.workspaces, newWorkspace],
activeWorkspaceId: newWorkspace.id,
}));
},

if (id === state.activeWorkspaceId) {
const closedIndex = state.workspaces.findIndex(
(workspace) => workspace.id === id,
removeWorkspace: (id) => {
set((state) => {
const workspaces = state.workspaces.filter(
(workspace) => workspace.id !== id,
);
const nextWorkspace =
workspaces[closedIndex] || workspaces[closedIndex - 1];
return { workspaces, activeWorkspaceId: nextWorkspace.id };
}
if (workspaces.length === 0) {
const newWorkspace = createNewWorkspace();
return {
workspaces: [newWorkspace],
activeWorkspaceId: newWorkspace.id,
};
}

return { workspaces };
});
},
if (id === state.activeWorkspaceId) {
const closedIndex = state.workspaces.findIndex(
(workspace) => workspace.id === id,
);
const nextWorkspace =
workspaces[closedIndex] || workspaces[closedIndex - 1];
return { workspaces, activeWorkspaceId: nextWorkspace.id };
}

setActiveWorkspace: (id) => {
set({ activeWorkspaceId: id });
},
return { workspaces };
});
},

reorderWorkspaces: (startIndex, endIndex) => {
set((state) => {
const workspaces = [...state.workspaces];
const [removed] = workspaces.splice(startIndex, 1);
workspaces.splice(endIndex, 0, removed);
return { workspaces };
});
},
setActiveWorkspace: (id) => {
set({ activeWorkspaceId: id });
},

reorderWorkspaces: (startIndex, endIndex) => {
set((state) => {
const workspaces = [...state.workspaces];
const [removed] = workspaces.splice(startIndex, 1);
workspaces.splice(endIndex, 0, removed);
return { workspaces };
});
},

markWorkspaceAsUsed: (id) => {
set((state) => ({
workspaces: state.workspaces.map((workspace) =>
workspace.id === id ? { ...workspace, isNew: false } : workspace,
),
}));
markWorkspaceAsUsed: (id) => {
set((state) => ({
workspaces: state.workspaces.map((workspace) =>
workspace.id === id ? { ...workspace, isNew: false } : workspace,
),
}));
},
}),
{
name: "workspaces-storage",
storage: electronStorage,
},
}),
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Verify initial empty state doesn't break existing components.

The initial state changed to workspaces: [] and activeWorkspaceId: null (lines 32-33). While appropriate for persistence (loading saved state), this differs from previous behavior and may break components that assume at least one workspace always exists. The empty-workspace guard in removeWorkspace (lines 48-54) doesn't cover the initial load case.

Run this script to verify all consumers handle null/empty state:

Ensure all consuming components handle:

  • activeWorkspaceId === null
  • workspaces.length === 0

Or initialize with a default workspace if this breaks expected behavior.


🏁 Script executed:

#!/bin/bash
# Description: Find components using activeWorkspaceId and workspaces to verify null/empty handling

# Search for activeWorkspaceId usage
echo "=== Components accessing activeWorkspaceId ==="
rg -n --type=tsx --type=ts -C3 'activeWorkspaceId' -g '!*.test.*' -g '!workspaces.ts'

echo -e "\n=== Components accessing workspaces array ==="
rg -n --type=tsx --type=ts -C3 'useWorkspacesStore.*workspaces[^a-zA-Z]' -g '!*.test.*' -g '!workspaces.ts'

echo -e "\n=== Check for .workspaces[0] or similar direct access ==="
rg -n --type=tsx --type=ts 'workspaces\[0\]' -g '!*.test.*'

Length of output: 236


I need to fix the script—the --type=tsx flag isn't recognized by ripgrep. Let me run a corrected version that properly searches TypeScript files:


🏁 Script executed:

#!/bin/bash
# Description: Find components using activeWorkspaceId and workspaces to verify null/empty handling

# Search for activeWorkspaceId usage (corrected)
echo "=== Components accessing activeWorkspaceId ==="
rg -n 'activeWorkspaceId' --type=ts -C3 -g '!*.test.*' -g '!workspaces.ts'

echo -e "\n=== Components accessing workspaces array ==="
rg -n 'workspaces\s*\??\.' --type=ts -C3 -g '!*.test.*' -g '!workspaces.ts'

echo -e "\n=== Check for .workspaces[0] or similar direct array access ==="
rg -n 'workspaces\s*\[0\]' --type=ts -g '!*.test.*'

echo -e "\n=== Check for activeWorkspaceId?.id or null checks ==="
rg -n 'activeWorkspaceId\?' --type=ts -C2 -g '!workspaces.ts'

Length of output: 66636


I need to examine more closely what happens when the store initializes with empty state. Let me search for app initialization and the WorkspaceView render logic:

Let me search for the electronStorage implementation to see if it's synchronous or asynchronous:


🏁 Script executed:

#!/bin/bash
# Find electronStorage definition and usage
echo "=== Find electronStorage definition ==="
rg -n 'electronStorage' --type=ts -B2 -A5 -g '!*.test.*' | head -60

echo -e "\n=== Check if electronStorage is sync or async ==="
rg -n 'export.*electronStorage|const.*electronStorage.*=' --type=ts -A3 -g '!*.test.*'

Length of output: 3928


Now let me examine the WorkspaceView component more closely to see how it handles the case where activeWorkspace might be undefined:


🏁 Script executed:

#!/bin/bash
# Get full WorkspaceView implementation
echo "=== WorkspaceView full implementation ==="
cat apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx

echo -e "\n=== Check app initialization / root component ==="
rg -n 'WorkspaceView|main entry' --type=ts -B3 -A3 | head -40

Length of output: 4474


Let me verify if there's app initialization that creates a default workspace, and check what Sidebar renders:


🏁 Script executed:

#!/bin/bash
# Check for app initialization / root setup
echo "=== Check App.tsx or main entry ==="
find apps/desktop/src/renderer -name "App.tsx" -o -name "main.tsx" -o -name "index.tsx" | head -5 | xargs -I {} sh -c 'echo "File: {}"; head -40 "{}"'

echo -e "\n=== Check Sidebar implementation ==="
cat apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx 2>/dev/null | head -60

echo -e "\n=== Check if there's any useEffect that initializes workspace ==="
rg -n 'useEffect|init.*workspace' --type=ts apps/desktop/src/renderer/screens/main/ -A3 | head -40

Length of output: 11062


WorkspaceView doesn't handle the undefined activeWorkspace case during initial load.

While most consumers properly guard against null activeWorkspaceId, the WorkspaceView component (line 12) only checks if (activeWorkspace?.isNew). When activeWorkspaceId is null during async storage hydration, activeWorkspace becomes undefined, causing the condition to be false. This renders the default Sidebar + ContentView layout instead of NewWorkspaceView, creating a broken UI momentarily.

Fix: Change line 12 to:

if (!activeWorkspace || activeWorkspace?.isNew) {

This ensures NewWorkspaceView renders when no active workspace exists, matching the intended UX flow where users create their first workspace.

🤖 Prompt for AI Agents
In apps/desktop/src/renderer/stores/workspaces.ts around lines 30 to 94, the
store allows activeWorkspaceId to be null during async hydration which can make
consumers receive undefined activeWorkspace; update consumers to treat missing
activeWorkspace as the "new" case by checking for both undefined/null and isNew.
Specifically, change the guard in WorkspaceView from only checking
activeWorkspace?.isNew to a combined check that treats !activeWorkspace
(null/undefined) or activeWorkspace.isNew as the NewWorkspaceView case so the UI
shows the creation flow during initial load.

Comment on lines +5 to +20
export interface StorageChannels {
"storage:get": {
request: { key: string };
response: any;
};

"storage:set": {
request: { key: string; value: any };
response: void;
};

"storage:delete": {
request: { key: string };
response: void;
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Replace any with unknown for type safety.

Using any for response (line 8) and value (line 12) bypasses TypeScript's type checking. Use unknown instead to maintain type safety while allowing any value—consumers must then explicitly narrow the type before use.

Apply this diff:

 export interface StorageChannels {
   "storage:get": {
     request: { key: string };
-    response: any;
+    response: unknown;
   };

   "storage:set": {
-    request: { key: string; value: any };
+    request: { key: string; value: unknown };
     response: void;
   };

   "storage:delete": {
     request: { key: string };
     response: void;
   };
 }
🤖 Prompt for AI Agents
In apps/desktop/src/shared/ipc-channels/storage.ts around lines 5 to 20, replace
the uses of the any type with unknown: change the "storage:get" response type
from any to unknown and change the "storage:set" request.value type from any to
unknown so callers must explicitly narrow types; leave other shapes unchanged
and run TypeScript checks to ensure no downstream type errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant