agent-ecosystem/.planning/research/STACK.md
matt ae4833e310 feat(planning): add project planning documents and configuration for SSH multi-context workspaces
- Introduced new planning files including PROJECT.md, REQUIREMENTS.md, ROADMAP.md, and STATE.md to outline the vision and requirements for SSH multi-context workspaces.
- Added ARCHITECTURE.md and CONCERNS.md to detail the codebase structure and address technical debt, known bugs, and security considerations.
- Created CONVENTIONS.md to establish coding standards and practices for the project.
- Updated .gitignore to exclude demo files and added configuration for planning tools.

This commit lays the groundwork for enhancing SSH functionality and user experience in managing multiple workspaces.
2026-02-12 09:37:58 +09:00

17 KiB
Raw Blame History

Technology Stack: Multi-Context Workspace Management

Domain: Electron desktop app with SSH remote + local context switching Researched: 2026-02-12 Confidence: HIGH

Core State Management Pattern

Technology Version Purpose Why Recommended
Zustand persist middleware 4.x State snapshot/restoration Built-in hydration control (skipHydration, rehydrate), selective persistence via partialize, storage backend abstraction. Already in use.
Context isolation per workspace Pattern Independent workspace state Each workspace (local + N SSH hosts) gets own Zustand store instance, snapshotted to storage, restored on context switch.
Broadcast Channel API Native Multi-window state sync Native browser API for same-origin window messaging. VS Code Remote and Akiflow use this. Zero dependencies, straightforward implementation.
Workspace registry in main process Pattern Service lifecycle management Main process owns workspace registry mapping contextId → { store snapshot, service instances, connection state }. IPC triggers context switches.

IPC Architecture

Technology Version Purpose Why Recommended
ipcMain.handle / ipcRenderer.invoke Electron 28.x+ Request/response IPC Official Electron recommendation for async two-way IPC. Already in use. Type-safe with preload bridge.
Workspace-scoped IPC channels Pattern Context-aware requests Prefix channels with contextId: workspace:${contextId}:getSessions. Main process routes to correct service instance.
EventEmitter for status broadcasts Node.js native Connection state events Already used by SshConnectionManager. Extend pattern for workspace lifecycle events.

State Persistence

Technology Version Purpose Why Recommended
Zustand persist → localStorage 4.x Active workspace state Fast synchronous access. Already configured. Each workspace gets namespaced key: claude-devtools-workspace-${contextId}.
Zustand persist → IndexedDB 4.x (via idb-keyval) Inactive workspace snapshots Async storage for multiple workspace snapshots without localStorage quota limits. Zustand persist middleware supports custom storage backends.
partialize for selective persistence Zustand built-in Minimize storage overhead Persist only domain data (projects, sessions, tabs), exclude transient UI state (loading flags, scroll positions).

Service Registry Pattern (Main Process)

Component Purpose Implementation
WorkspaceRegistry Owns all workspace instances Map<contextId, WorkspaceContext> where WorkspaceContext = { provider: FileSystemProvider, services: ServiceInstances, lastAccessed: timestamp }
ServiceInstances Per-workspace service lifecycle Each workspace gets own instances of ProjectScanner, SessionParser, FileWatcher, etc. Services use workspace-specific FileSystemProvider.
Active workspace tracking Single active context at a time activeContextId: string. IPC handlers route to registry.get(activeContextId).services.
Lazy initialization Services created on first use Registry initializes workspace services on connect (SSH) or app launch (local). Disposes inactive workspaces after TTL (e.g., 30min).

Context Switching Flow

Phase Mechanism Rationale
1. Save current state zustand.getState() → IndexedDB Snapshot entire store before switch. Non-blocking async operation.
2. Broadcast workspace change new BroadcastChannel('workspace-switch').postMessage({ contextId }) Notify all renderer windows to update their UI for new context.
3. Switch main process context WorkspaceRegistry.setActive(contextId) Atomic switch of active FileSystemProvider + service instances.
4. Restore target state IndexedDB → zustand.setState() Hydrate store from snapshot. If no snapshot (new workspace), use empty initial state.
5. Re-fetch live data fetchProjects(), fetchRepositoryGroups() Refresh data from new context's file system. Already implemented in connectionSlice.

What NOT to Use

Avoid Why Use Instead
Zutron library Designed for syncing single store across main+renderer. We need multiple independent workspace stores. Adds unnecessary abstraction. Direct Zustand persist + IPC for workspace switching.
Redux/Redux Toolkit Overkill for context switching. Boilerplate-heavy. Zustand already in use and handles this elegantly with persist middleware. Zustand with workspace-scoped stores.
Global store with workspace slice Single store with workspaces: { [id]: WorkspaceState } scales poorly with large states, makes persistence complex, increases re-render surface. Separate Zustand store instance per workspace.
SharedWorker for multi-window More complex than BroadcastChannel, requires separate worker script, no tangible benefit for workspace switching use case. BroadcastChannel API.
Electron IPC for multi-window sync Round-trip through main process for window-to-window messaging is slower and more complex than BroadcastChannel. BroadcastChannel for renderer-to-renderer, IPC for renderer-to-main.

Stack Patterns by Variant

Pattern 1: Workspace Store Initialization

When: User switches to a workspace (SSH connect or local mode)

Implementation:

// Renderer: Create workspace-scoped store
const createWorkspaceStore = (contextId: string) => {
  return create<AppState>()(
    persist(
      (...args) => ({
        ...createProjectSlice(...args),
        ...createSessionSlice(...args),
        // ... other slices
      }),
      {
        name: `claude-devtools-workspace-${contextId}`,
        storage: createJSONStorage(() => indexedDBStorage), // For inactive
        partialize: (state) => ({
          // Only persist domain data, not UI state
          projects: state.projects,
          sessions: state.sessions,
          tabs: state.tabs,
          // Exclude: loading, error, selectedIds
        }),
        skipHydration: true, // Manual hydration control
      }
    )
  );
};

// After switch, manually rehydrate
await store.persist.rehydrate();

Why:

  • skipHydration: true prevents auto-load on store creation, gives control over when to restore state
  • partialize minimizes storage size, excludes transient state that shouldn't survive context switch
  • IndexedDB storage for inactive workspaces avoids localStorage 5MB quota issues

Pattern 2: Main Process Service Registry

When: Routing IPC calls to workspace-specific services

Implementation:

// Main process
class WorkspaceRegistry {
  private workspaces = new Map<string, WorkspaceContext>();
  private activeContextId: string = 'local';

  async initializeWorkspace(contextId: string, provider: FileSystemProvider) {
    const context: WorkspaceContext = {
      provider,
      services: {
        projectScanner: new ProjectScanner(provider),
        sessionParser: new SessionParser(provider),
        fileWatcher: new FileWatcher(provider),
        // ... other services
      },
      lastAccessed: Date.now(),
    };
    this.workspaces.set(contextId, context);
  }

  getActiveServices(): ServiceInstances {
    const context = this.workspaces.get(this.activeContextId);
    if (!context) throw new Error('No active workspace');
    return context.services;
  }

  setActive(contextId: string) {
    this.activeContextId = contextId;
  }
}

// IPC handler uses active workspace
ipcMain.handle('getProjects', async () => {
  const services = workspaceRegistry.getActiveServices();
  return services.projectScanner.scan();
});

Why:

  • Services use FileSystemProvider interface → same code works for local + SSH
  • Main process owns service lifecycle → renderer just calls IPC, doesn't manage connections
  • Active workspace pattern → single source of truth for which context is current

Pattern 3: Context Switch with State Preservation

When: User clicks "Switch to SSH" or "Switch to Local"

Implementation:

// Renderer
async function switchWorkspace(newContextId: string) {
  const currentStore = useStore.getState();

  // 1. Save current workspace state to IndexedDB
  const currentSnapshot = currentStore;
  await saveWorkspaceSnapshot(currentContextId, currentSnapshot);

  // 2. Broadcast switch event to other windows
  const channel = new BroadcastChannel('workspace-switch');
  channel.postMessage({ contextId: newContextId });

  // 3. Tell main process to switch active context
  await window.electronAPI.workspace.setActive(newContextId);

  // 4. Restore target workspace state from IndexedDB
  const targetSnapshot = await loadWorkspaceSnapshot(newContextId);
  if (targetSnapshot) {
    useStore.setState(targetSnapshot);
  } else {
    // New workspace: reset to initial state
    useStore.setState(getInitialState());
  }

  // 5. Re-fetch live data from new context
  await currentStore.fetchProjects();
  await currentStore.fetchRepositoryGroups();
}

Why:

  • State snapshot before switch → instant restoration when switching back
  • BroadcastChannel → multi-window apps stay in sync
  • Main process switch → IPC handlers route to correct services
  • Re-fetch after restore → fresh data from new filesystem

Pattern 4: Multi-Window Sync with BroadcastChannel

When: User has multiple app windows open, switches workspace in one window

Implementation:

// Each renderer window listens
const channel = new BroadcastChannel('workspace-switch');
channel.onmessage = async (event) => {
  const { contextId } = event.data;

  // Don't process if this window initiated the switch
  if (contextId === currentContextId) return;

  // Update this window's state to match
  await switchWorkspace(contextId);
};

// Send when switching
channel.postMessage({ contextId: newContextId });

Why:

  • Native API, no library needed
  • Same-origin by default → secure
  • Simpler than IPC round-trip for window-to-window messaging
  • VS Code Remote and Akiflow use this pattern

Installation

Core Dependencies (Already Installed)

# Already in package.json
zustand@4.x
electron@28.x
ssh2@latest  # For SSH connections

Additional Dependencies

# For IndexedDB storage backend
pnpm install idb-keyval

# No other dependencies needed - BroadcastChannel is native

Architecture Best Practices

Memory Management

  • Dispose inactive workspaces: After 30min of inactivity, dispose service instances and FileSystemProvider. Keep snapshot in IndexedDB for instant restoration.
  • Limit snapshot count: Keep max 5 workspace snapshots in IndexedDB. LRU eviction by lastAccessed timestamp.
  • Clear transient state on switch: Don't persist loading flags, error messages, scroll positions → they're meaningless in restored context.

Error Handling

  • Connection errors: If SSH disconnects mid-session, auto-switch to local mode, preserve SSH workspace snapshot for reconnect.
  • Storage quota: IndexedDB quota errors → warn user, fall back to localStorage for active workspace only, disable snapshot restoration.
  • Service initialization failures: If workspace service init fails (e.g., remote path doesn't exist), show error banner but don't crash app. Allow manual retry.

Performance Optimization

  • Debounced persistence: Don't save snapshot on every state change. Debounce by 2-5 seconds, trigger on workspace blur.
  • Lazy service initialization: Don't create all services on workspace init. Create ProjectScanner immediately, defer SessionParser until first session view.
  • Partial state updates: When re-fetching after context switch, use refreshSessionsInPlace pattern (already implemented) to avoid flickering.

TypeScript Patterns

Workspace Context Type

type WorkspaceContextId = `local` | `ssh-${string}`;

interface WorkspaceContext {
  id: WorkspaceContextId;
  provider: FileSystemProvider;
  services: ServiceInstances;
  lastAccessed: number;
  connectionInfo?: {
    host: string;
    port: number;
    username: string;
  };
}

interface ServiceInstances {
  projectScanner: ProjectScanner;
  sessionParser: SessionParser;
  fileWatcher: FileWatcher;
  // ... other services
}

Storage Backend Interface

interface WorkspaceStorage {
  getItem(key: string): Promise<string | null>;
  setItem(key: string, value: string): Promise<void>;
  removeItem(key: string): Promise<void>;
}

// IndexedDB implementation
const indexedDBStorage: WorkspaceStorage = {
  getItem: async (key) => {
    const value = await idbKeyval.get(key);
    return value ?? null;
  },
  setItem: async (key, value) => {
    await idbKeyval.set(key, value);
  },
  removeItem: async (key) => {
    await idbKeyval.del(key);
  },
};

Version Compatibility

Package Version Compatible With Notes
zustand 4.x persist middleware 4.x Persist middleware ships with zustand 4.x, no separate install
electron 28.x+ BroadcastChannel native BroadcastChannel available in Chromium 54+ (Electron 1.x+)
idb-keyval 6.x TypeScript 5.x Simple IndexedDB wrapper, 600 bytes minified
ssh2 Latest Node.js 18+ Already in use for SSH connections

Confidence Levels

Area Confidence Rationale
Zustand persist for state snapshotting HIGH Official Zustand middleware, documented patterns for selective persistence and hydration control. Production-proven.
BroadcastChannel for multi-window HIGH Native browser API, used by VS Code Remote, Akiflow, and other Electron apps. Well-documented, zero dependencies.
Service registry in main process HIGH Standard Electron pattern for managing per-context resources. FileSystemProvider interface already supports this (SshFileSystemProvider vs LocalFileSystemProvider).
IndexedDB for inactive snapshots MEDIUM Zustand persist supports custom storage, idb-keyval is battle-tested, but no direct examples of multi-workspace Electron apps using this exact pattern. Low risk.
Workspace TTL and eviction MEDIUM Pattern is sound, but optimal TTL (30min) and max snapshots (5) are heuristics. May need tuning based on real-world usage.

Sources

State Management

Electron Multi-Window & IPC

BroadcastChannel Pattern

VS Code Architecture

Electron State Management Libraries


Stack research for: Multi-context workspace management in Electron + Zustand Researched: 2026-02-12