20 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-state-management | 01 | execute | 1 |
|
true |
|
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.mdKey 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:'StoredSnapshotinterface:{ snapshot: ContextSnapshot, timestamp: number, version: number }saveSnapshot(contextId, snapshot)— wraps in StoredSnapshot with timestamp, saves viaset()loadSnapshot(contextId)— loads viaget(), checks TTL, returns null if expired (deletes expired entry)deleteSnapshot(contextId)— deletes viadel()cleanupExpired()— iterates all keys with prefix, deletes expired entriesisAvailable()— returns boolean indicating whether IndexedDB is accessible (try/catch around a testset/del)- Export as
contextStorageobject (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
*Loadingflags (projectsLoading, sessionsLoading, etc.) - All
*Errorflags (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:
- Early return if
targetContextId === activeContextId - Set
isContextSwitching: true, targetContextId - Capture current context's snapshot: extract persistable state from
get(), create ContextSnapshot - Save snapshot to IndexedDB via
contextStorage.saveSnapshot(activeContextId, snapshot) - Call
window.electronAPI.context.switch(targetContextId)to switch main process context - Attempt to restore snapshot for target:
contextStorage.loadSnapshot(targetContextId) - If snapshot exists: apply via
set()with validated state (see validation in Task 3) - If no snapshot: apply empty context state via
getEmptyContextState()helper - Fetch fresh data:
fetchProjects(),fetchRepositoryGroups(),fetchNotifications() - Set
isContextSwitching: false, targetContextId: null, activeContextId: targetContextId - 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:
- Check IndexedDB availability via
contextStorage.isAvailable() - If available: run
contextStorage.cleanupExpired()to purge stale snapshots - Set
contextSnapshotsReady: true - Fetch active context from main process:
window.electronAPI.context.getActive()and setactiveContextId
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.
// 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
ContextSlicefrom./slices/contextSlice - Add
ContextSliceto theAppStateintersection type
Update src/renderer/store/index.ts:
- Import
createContextSlicefrom./slices/contextSlice - Add
...createContextSlice(...args)to the store creation (compose with other slices) - Do NOT add it to
initializeNotificationListenersyet (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.
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:
- Call
window.electronAPI.context.switch(targetContextId)— switches main process - Fetch fresh data:
const [projects, repoGroups] = await Promise.all([window.electronAPI.getProjects(), window.electronAPI.getRepositoryGroups()]) - If snapshot exists: call
validateSnapshot(snapshot, projects, repoGroups)to get validated state, apply viaset() - If no snapshot: apply empty state, then set fresh projects/repoGroups
- Also fetch notifications in background:
void get().fetchNotifications() - 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>