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

476 lines
20 KiB
Markdown

---
phase: 03-state-management
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- 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
autonomous: true
must_haves:
truths:
- "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"
artifacts:
- path: "src/renderer/services/contextStorage.ts"
provides: "IndexedDB persistence layer for context snapshots with TTL"
exports: ["contextStorage"]
- path: "src/renderer/store/slices/contextSlice.ts"
provides: "Zustand slice for context switching state and snapshot/restore orchestration"
exports: ["ContextSlice", "createContextSlice"]
- path: "src/renderer/hooks/useContextSwitch.ts"
provides: "React hook that exposes context switch action to components"
exports: ["useContextSwitch"]
- path: "src/renderer/components/common/ContextSwitchOverlay.tsx"
provides: "Full-screen loading overlay shown during context transitions"
exports: ["ContextSwitchOverlay"]
key_links:
- from: "src/renderer/store/slices/contextSlice.ts"
to: "src/renderer/services/contextStorage.ts"
via: "import contextStorage; called in captureSnapshot/restoreSnapshot actions"
pattern: "contextStorage\\.(saveSnapshot|loadSnapshot)"
- from: "src/renderer/store/slices/contextSlice.ts"
to: "src/renderer/store/index.ts"
via: "createContextSlice composed into useStore"
pattern: "createContextSlice"
- from: "src/renderer/hooks/useContextSwitch.ts"
to: "src/renderer/store/index.ts"
via: "useStore selector for switchContext action"
pattern: "useStore.*switchContext"
- from: "src/renderer/components/common/ContextSwitchOverlay.tsx"
to: "src/renderer/store/index.ts"
via: "useStore selector for isContextSwitching state"
pattern: "useStore.*isContextSwitching"
- from: "src/renderer/App.tsx"
to: "src/renderer/components/common/ContextSwitchOverlay.tsx"
via: "ContextSwitchOverlay rendered at root level"
pattern: "<ContextSwitchOverlay"
---
<objective>
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.
</objective>
<execution_context>
@/home/bskim/.claude/get-shit-done/workflows/execute-plan.md
@/home/bskim/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<tasks>
<task type="auto">
<name>Task 1: Create IndexedDB storage layer and contextSlice</name>
<files>
src/renderer/services/contextStorage.ts
src/renderer/store/slices/contextSlice.ts
</files>
<action>
**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:
```typescript
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):
```typescript
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<AppState> 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`.
</action>
<verify>
pnpm typecheck passes. Both files exist with correct exports. idb-keyval in package.json dependencies.
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Create overlay component, hook, and wire into store</name>
<files>
src/renderer/components/common/ContextSwitchOverlay.tsx
src/renderer/hooks/useContextSwitch.ts
src/renderer/store/types.ts
src/renderer/store/index.ts
</files>
<action>
**Create `src/renderer/components/common/ContextSwitchOverlay.tsx`:**
Full-screen loading overlay displayed during context switches. Uses theme CSS variables for consistency.
```tsx
// 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.
```typescript
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)
</action>
<verify>
pnpm typecheck passes. ContextSwitchOverlay, useContextSwitch, updated types.ts and index.ts all compile without errors. useStore now includes ContextSlice properties.
</verify>
<done>
ContextSwitchOverlay renders loading state during context switch. useContextSwitch hook exposes switchContext/isContextSwitching/activeContextId. Store types and creation updated to include contextSlice.
</done>
</task>
<task type="auto">
<name>Task 3: Wire overlay into App, add context event listener, add snapshot validation</name>
<files>
src/renderer/App.tsx
src/renderer/store/index.ts
src/renderer/store/slices/contextSlice.ts
</files>
<action>
**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 `<ContextSwitchOverlay />` as first child inside `<ErrorBoundary>`, before `<TabbedLayout />`
**Update `src/renderer/store/index.ts` — add context:onChanged listener:**
In `initializeNotificationListeners()`, add a listener for context change events from main process:
```typescript
// 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`:
```typescript
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.
</action>
<verify>
pnpm typecheck passes. pnpm test passes (all existing tests). App.tsx renders ContextSwitchOverlay. Context change listener registered in initializeNotificationListeners. validateSnapshot filters invalid tabs/selections.
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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 `<ContextSwitchOverlay />` inside ErrorBoundary
7. Verify `initializeNotificationListeners` includes context:onChanged listener
</verification>
<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>
<output>
After completion, create `.planning/phases/03-state-management/03-01-SUMMARY.md`
</output>