agent-ecosystem/.planning/phases/03-state-management/03-01-PLAN.md
2026-02-12 05:54:04 +00:00

20 KiB

phase plan type wave depends_on files_modified autonomous must_haves
03-state-management 01 execute 1
src/renderer/services/contextStorage.ts
src/renderer/store/slices/contextSlice.ts
src/renderer/store/types.ts
src/renderer/store/index.ts
src/renderer/hooks/useContextSwitch.ts
src/renderer/components/common/ContextSwitchOverlay.tsx
src/renderer/App.tsx
true
truths artifacts key_links
Switching from local to SSH and back restores exact tab state, selected project, and sidebar selections
First-time switch to a new SSH context shows empty state with dashboard tab (not stale local data)
Previously visited context restores instantly from snapshot without refetching
Loading overlay covers entire app during context switch to prevent stale data flash
Context snapshots survive app restart via IndexedDB persistence
Expired snapshots (older than 5 minutes) are treated as missing and cleaned up
path provides exports
src/renderer/services/contextStorage.ts IndexedDB persistence layer for context snapshots with TTL
contextStorage
path provides exports
src/renderer/store/slices/contextSlice.ts Zustand slice for context switching state and snapshot/restore orchestration
ContextSlice
createContextSlice
path provides exports
src/renderer/hooks/useContextSwitch.ts React hook that exposes context switch action to components
useContextSwitch
path provides exports
src/renderer/components/common/ContextSwitchOverlay.tsx Full-screen loading overlay shown during context transitions
ContextSwitchOverlay
from to via pattern
src/renderer/store/slices/contextSlice.ts src/renderer/services/contextStorage.ts import contextStorage; called in captureSnapshot/restoreSnapshot actions contextStorage.(saveSnapshot|loadSnapshot)
from to via pattern
src/renderer/store/slices/contextSlice.ts src/renderer/store/index.ts createContextSlice composed into useStore createContextSlice
from to via pattern
src/renderer/hooks/useContextSwitch.ts src/renderer/store/index.ts useStore selector for switchContext action useStore.*switchContext
from to via pattern
src/renderer/components/common/ContextSwitchOverlay.tsx src/renderer/store/index.ts useStore selector for isContextSwitching state useStore.*isContextSwitching
from to via pattern
src/renderer/App.tsx src/renderer/components/common/ContextSwitchOverlay.tsx ContextSwitchOverlay rendered at root level <ContextSwitchOverlay
Implement the complete context snapshot/restore system for instant workspace switching.

Purpose: When users switch between local and SSH workspaces, their exact UI state (open tabs, selected project, sidebar selections, scroll positions) must be captured, persisted to IndexedDB, and restored instantly on switch-back. New contexts show clean empty state. A full-screen overlay prevents stale data flash during transitions.

Output: contextSlice (Zustand), contextStorage (IndexedDB), useContextSwitch hook, ContextSwitchOverlay component, all wired into the store and App.

<execution_context> @/home/bskim/.claude/get-shit-done/workflows/execute-plan.md @/home/bskim/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-state-management/03-RESEARCH.md @.planning/phases/02-service-infrastructure/02-03-SUMMARY.md

Key existing files to reference:

@src/renderer/store/index.ts @src/renderer/store/types.ts @src/renderer/store/slices/connectionSlice.ts @src/renderer/store/utils/stateResetHelpers.ts @src/renderer/App.tsx @src/renderer/store/slices/projectSlice.ts @src/renderer/store/slices/tabSlice.ts @src/renderer/store/slices/paneSlice.ts @src/renderer/store/slices/repositorySlice.ts @src/renderer/store/slices/sessionSlice.ts @src/renderer/store/slices/notificationSlice.ts @src/renderer/store/slices/sessionDetailSlice.ts @src/renderer/store/slices/conversationSlice.ts @src/renderer/store/slices/uiSlice.ts

Task 1: Create IndexedDB storage layer and contextSlice src/renderer/services/contextStorage.ts src/renderer/store/slices/contextSlice.ts **Install idb-keyval:** ```bash pnpm add idb-keyval ```

Create src/renderer/services/contextStorage.ts: IndexedDB persistence layer using idb-keyval (get, set, del, keys). Implements:

  • SNAPSHOT_TTL_MS = 5 * 60 * 1000 (5-minute TTL)
  • STORAGE_KEY_PREFIX = 'context-snapshot:'
  • StoredSnapshot interface: { snapshot: ContextSnapshot, timestamp: number, version: number }
  • saveSnapshot(contextId, snapshot) — wraps in StoredSnapshot with timestamp, saves via set()
  • loadSnapshot(contextId) — loads via get(), checks TTL, returns null if expired (deletes expired entry)
  • deleteSnapshot(contextId) — deletes via del()
  • cleanupExpired() — iterates all keys with prefix, deletes expired entries
  • isAvailable() — returns boolean indicating whether IndexedDB is accessible (try/catch around a test set/del)
  • Export as contextStorage object (not class)
  • All methods are async and handle errors gracefully (catch + console.error + return safe defaults)

Create src/renderer/store/slices/contextSlice.ts: Zustand slice managing context switching lifecycle. Interface:

export interface ContextSlice {
  // State
  activeContextId: string;         // 'local' initially
  isContextSwitching: boolean;     // true during switch transition
  targetContextId: string | null;  // context being switched to
  contextSnapshotsReady: boolean;  // true after initial IndexedDB check

  // Actions
  switchContext: (targetContextId: string) => Promise<void>;
  initializeContextSystem: () => Promise<void>;
}

ContextSnapshot type — define within contextSlice.ts (not exported from types.ts to keep it internal):

interface ContextSnapshot {
  // Data state (persistable)
  projects: Project[];
  selectedProjectId: string | null;
  repositoryGroups: RepositoryGroup[];
  selectedRepositoryId: string | null;
  selectedWorktreeId: string | null;
  viewMode: 'flat' | 'grouped';
  sessions: Session[];
  selectedSessionId: string | null;
  sessionsCursor: string | null;
  sessionsHasMore: boolean;
  sessionsTotalCount: number;
  pinnedSessionIds: string[];
  notifications: DetectedError[];
  unreadCount: number;

  // Tab/pane state
  openTabs: Tab[];
  activeTabId: string | null;
  selectedTabIds: string[];
  activeProjectId: string | null;
  paneLayout: PaneLayout;

  // UI state
  sidebarCollapsed: boolean;

  // Metadata
  _metadata: {
    contextId: string;
    capturedAt: number;
    version: number;
  };
}

IMPORTANT exclusions from snapshot (transient state that must NOT be persisted):

  • All *Loading flags (projectsLoading, sessionsLoading, etc.)
  • All *Error flags (projectsError, sessionsError, etc.)
  • sessionDetail, conversation, sessionClaudeMdStats, sessionContextStats, sessionPhaseInfo (per-session detail data — too large and stale)
  • tabSessionData (per-tab cached data — will be re-fetched)
  • tabUIStates (per-tab expansion state — Set/Map types not serializable)
  • searchQuery, searchVisible, searchMatches, etc. (transient search state)
  • commandPaletteOpen (transient UI)
  • connectionMode, connectionState, connectedHost, etc. (connection state managed separately)
  • configState (managed by ConfigManager, not per-context)
  • update state (app-level, not per-context)
  • conversationSlice expansion states (not serializable Maps/Sets)
  • sessionsLoadingMore (transient)

switchContext action implementation:

  1. Early return if targetContextId === activeContextId
  2. Set isContextSwitching: true, targetContextId
  3. Capture current context's snapshot: extract persistable state from get(), create ContextSnapshot
  4. Save snapshot to IndexedDB via contextStorage.saveSnapshot(activeContextId, snapshot)
  5. Call window.electronAPI.context.switch(targetContextId) to switch main process context
  6. Attempt to restore snapshot for target: contextStorage.loadSnapshot(targetContextId)
  7. If snapshot exists: apply via set() with validated state (see validation in Task 3)
  8. If no snapshot: apply empty context state via getEmptyContextState() helper
  9. Fetch fresh data: fetchProjects(), fetchRepositoryGroups(), fetchNotifications()
  10. Set isContextSwitching: false, targetContextId: null, activeContextId: targetContextId
  11. Wrap in try/catch — on error, log, set isContextSwitching: false, do NOT leave in broken state

getEmptyContextState() helper (internal function): Returns Partial with empty arrays, null selections, single-pane dashboard layout. Pattern: same as getFullResetState() but also resets tabs to empty dashboard tab and single pane.

initializeContextSystem action:

  1. Check IndexedDB availability via contextStorage.isAvailable()
  2. If available: run contextStorage.cleanupExpired() to purge stale snapshots
  3. Set contextSnapshotsReady: true
  4. Fetch active context from main process: window.electronAPI.context.getActive() and set activeContextId

Follow existing slice patterns: use StateCreator<AppState, [], [], ContextSlice>, export createContextSlice. pnpm typecheck passes. Both files exist with correct exports. idb-keyval in package.json dependencies. contextStorage provides IndexedDB save/load/delete/cleanup with TTL. contextSlice provides switchContext and initializeContextSystem actions with proper snapshot capture/restore flow. Transient state (loading, errors, search, Maps/Sets) excluded from snapshots.

Task 2: Create overlay component, hook, and wire into store src/renderer/components/common/ContextSwitchOverlay.tsx src/renderer/hooks/useContextSwitch.ts src/renderer/store/types.ts src/renderer/store/index.ts **Create `src/renderer/components/common/ContextSwitchOverlay.tsx`:** Full-screen loading overlay displayed during context switches. Uses theme CSS variables for consistency.
// Functional component, no props needed
// Reads isContextSwitching and targetContextId from useStore
// If !isContextSwitching, return null
// Render: fixed inset-0 div with bg-surface z-[9999], centered spinner + text
// Spinner: animate-spin h-8 w-8 border-4 border-text border-t-transparent rounded-full
// Text: "Switching to {contextLabel}..." where contextLabel is:
//   - 'Local' if targetContextId === 'local'
//   - targetContextId with 'ssh-' prefix stripped otherwise
// Use Tailwind classes from project theme (bg-surface, text-text, text-text-secondary)

Create src/renderer/hooks/useContextSwitch.ts: Thin hook exposing context switch to components.

import { useCallback } from 'react';
import { useStore } from '../store';

export function useContextSwitch() {
  const switchContext = useStore(state => state.switchContext);
  const isContextSwitching = useStore(state => state.isContextSwitching);
  const activeContextId = useStore(state => state.activeContextId);

  const handleSwitch = useCallback(async (targetContextId: string) => {
    await switchContext(targetContextId);
  }, [switchContext]);

  return {
    switchContext: handleSwitch,
    isContextSwitching,
    activeContextId,
  };
}

Update src/renderer/store/types.ts:

  • Import ContextSlice from ./slices/contextSlice
  • Add ContextSlice to the AppState intersection type

Update src/renderer/store/index.ts:

  • Import createContextSlice from ./slices/contextSlice
  • Add ...createContextSlice(...args) to the store creation (compose with other slices)
  • Do NOT add it to initializeNotificationListeners yet (that's Task 3) pnpm typecheck passes. ContextSwitchOverlay, useContextSwitch, updated types.ts and index.ts all compile without errors. useStore now includes ContextSlice properties. ContextSwitchOverlay renders loading state during context switch. useContextSwitch hook exposes switchContext/isContextSwitching/activeContextId. Store types and creation updated to include contextSlice.
Task 3: Wire overlay into App, add context event listener, add snapshot validation src/renderer/App.tsx src/renderer/store/index.ts src/renderer/store/slices/contextSlice.ts **Update `src/renderer/App.tsx`:** - Import `ContextSwitchOverlay` from `./components/common/ContextSwitchOverlay` - Import `useStore` from `./store` - Add a `useEffect` that calls `useStore.getState().initializeContextSystem()` on mount (before notification listeners init) - Render `` as first child inside ``, before ``

Update src/renderer/store/index.ts — add context:onChanged listener: In initializeNotificationListeners(), add a listener for context change events from main process:

// Listen for context changes from main process (e.g., SSH disconnect)
if (window.electronAPI.context?.onChanged) {
  const cleanup = window.electronAPI.context.onChanged((_event: unknown, data: unknown) => {
    const { contextId } = data as { contextId: string };
    const currentContextId = useStore.getState().activeContextId;
    if (contextId !== currentContextId) {
      // Main process switched context externally (e.g., SSH disconnect)
      // Trigger renderer-side context switch to sync state
      void useStore.getState().switchContext(contextId);
    }
  });
  if (typeof cleanup === 'function') {
    cleanupFns.push(cleanup);
  }
}

Add snapshot validation to src/renderer/store/slices/contextSlice.ts: Add a validateSnapshot internal function called during restoreSnapshot step of switchContext:

function validateSnapshot(
  snapshot: ContextSnapshot,
  freshProjects: Project[],
  freshRepoGroups: RepositoryGroup[]
): Partial<AppState> {
  const validProjectIds = new Set(freshProjects.map(p => p.id));
  const validWorktreeIds = new Set(
    freshRepoGroups.flatMap(rg => rg.worktrees.map(w => w.id))
  );

  // Validate selectedProjectId
  const selectedProjectId = snapshot.selectedProjectId && validProjectIds.has(snapshot.selectedProjectId)
    ? snapshot.selectedProjectId
    : null;

  // Validate selectedRepositoryId and selectedWorktreeId
  const selectedRepositoryId = snapshot.selectedRepositoryId; // repos may differ but allow graceful fallback
  const selectedWorktreeId = snapshot.selectedWorktreeId && validWorktreeIds.has(snapshot.selectedWorktreeId)
    ? snapshot.selectedWorktreeId
    : null;

  // Validate tabs — filter out session tabs referencing invalid projects
  const validTabs = snapshot.openTabs.filter(tab => {
    if (tab.type === 'session' && tab.projectId) {
      return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId);
    }
    return true; // Keep dashboard and non-session tabs
  });

  // Validate activeTabId
  let activeTabId = snapshot.activeTabId;
  if (activeTabId && !validTabs.find(t => t.id === activeTabId)) {
    activeTabId = validTabs[0]?.id ?? null;
  }

  // Validate pane layout tabs
  const validatedPanes = snapshot.paneLayout.panes.map(pane => {
    const paneTabs = pane.tabs.filter(tab => {
      if (tab.type === 'session' && tab.projectId) {
        return validProjectIds.has(tab.projectId) || validWorktreeIds.has(tab.projectId);
      }
      return true;
    });
    const paneActiveId = paneTabs.find(t => t.id === pane.activeTabId)
      ? pane.activeTabId
      : paneTabs[0]?.id ?? null;
    return {
      ...pane,
      tabs: paneTabs,
      activeTabId: paneActiveId,
      selectedTabIds: pane.selectedTabIds.filter(id => paneTabs.some(t => t.id === id)),
    };
  }).filter(pane => pane.tabs.length > 0); // Remove empty panes

  // Ensure at least one pane exists
  const finalPanes = validatedPanes.length > 0
    ? validatedPanes
    : [{
        id: 'pane-default',
        tabs: [],
        activeTabId: null,
        selectedTabIds: [],
        widthFraction: 1,
      }];

  return {
    // Restored from snapshot
    projects: freshProjects, // Use fresh data, not snapshot's stale list
    selectedProjectId,
    repositoryGroups: freshRepoGroups, // Use fresh data
    selectedRepositoryId,
    selectedWorktreeId,
    viewMode: snapshot.viewMode,
    sessions: snapshot.sessions,
    selectedSessionId: snapshot.selectedSessionId,
    sessionsCursor: snapshot.sessionsCursor,
    sessionsHasMore: snapshot.sessionsHasMore,
    sessionsTotalCount: snapshot.sessionsTotalCount,
    pinnedSessionIds: snapshot.pinnedSessionIds,
    notifications: snapshot.notifications,
    unreadCount: snapshot.unreadCount,
    openTabs: validTabs,
    activeTabId,
    selectedTabIds: snapshot.selectedTabIds.filter(id => validTabs.some(t => t.id === id)),
    activeProjectId: snapshot.activeProjectId && (validProjectIds.has(snapshot.activeProjectId) || validWorktreeIds.has(snapshot.activeProjectId))
      ? snapshot.activeProjectId
      : selectedProjectId,
    paneLayout: {
      panes: finalPanes,
      focusedPaneId: finalPanes.find(p => p.id === snapshot.paneLayout.focusedPaneId)
        ? snapshot.paneLayout.focusedPaneId
        : finalPanes[0].id,
    },
    sidebarCollapsed: snapshot.sidebarCollapsed,
  };
}

Update switchContext action flow to use validation: After loading snapshot and BEFORE applying it, fetch fresh projects and repoGroups:

  1. Call window.electronAPI.context.switch(targetContextId) — switches main process
  2. Fetch fresh data: const [projects, repoGroups] = await Promise.all([window.electronAPI.getProjects(), window.electronAPI.getRepositoryGroups()])
  3. If snapshot exists: call validateSnapshot(snapshot, projects, repoGroups) to get validated state, apply via set()
  4. If no snapshot: apply empty state, then set fresh projects/repoGroups
  5. Also fetch notifications in background: void get().fetchNotifications()
  6. Set isContextSwitching: false, activeContextId: targetContextId, targetContextId: null

This ensures restored data references are validated against REAL data from the switched context. pnpm typecheck passes. pnpm test passes (all existing tests). App.tsx renders ContextSwitchOverlay. Context change listener registered in initializeNotificationListeners. validateSnapshot filters invalid tabs/selections. App renders overlay during context switches. Main process context change events trigger renderer-side state sync. Snapshot validation ensures restored tabs reference valid projects/worktrees in the target context. Empty panes are removed, at-least-one-pane invariant maintained.

1. `pnpm typecheck` — zero TypeScript errors 2. `pnpm test` — all existing tests pass (no regressions) 3. `pnpm build` — production build succeeds 4. Verify these files exist with correct exports: - `src/renderer/services/contextStorage.ts` exports `contextStorage` - `src/renderer/store/slices/contextSlice.ts` exports `ContextSlice`, `createContextSlice` - `src/renderer/hooks/useContextSwitch.ts` exports `useContextSwitch` - `src/renderer/components/common/ContextSwitchOverlay.tsx` exports `ContextSwitchOverlay` 5. Verify `useStore` includes context switching state (activeContextId, isContextSwitching) 6. Verify App.tsx renders `` inside ErrorBoundary 7. Verify `initializeNotificationListeners` includes context:onChanged listener

<success_criteria>

  • Context snapshot captures all user-facing data state (projects, sessions, tabs, pane layout, selections, notifications) while excluding transient state (loading flags, errors, search, non-serializable Maps/Sets)
  • Snapshot is saved to IndexedDB on context exit and restored on context re-entry
  • Expired snapshots (>5 min) are deleted and treated as missing
  • New/never-visited contexts get clean empty state with dashboard tab
  • Loading overlay prevents stale data flash during the switch transition
  • Restored tabs are validated against fresh project/worktree data from the target context
  • Main process context change events sync renderer state
  • No regressions in existing tests or type checking </success_criteria>
After completion, create `.planning/phases/03-state-management/03-01-SUMMARY.md`