agent-ecosystem/src/renderer/store/slices/contextSlice.ts
2026-05-26 18:54:17 +03:00

476 lines
16 KiB
TypeScript

/**
* Context Slice - Manages context switching lifecycle.
*
* Orchestrates snapshot capture/restore for instant workspace switching
* between local and SSH contexts, with IndexedDB persistence and TTL.
*/
import { api } from '@renderer/api';
import { contextStorage } from '@renderer/services/contextStorage';
import { draftStorage } from '@renderer/services/draftStorage';
import {
captureContextScopedRequestEpoch,
invalidateContextScopedRequestEpoch,
isContextScopedRequestEpochCurrent,
} from '../utils/contextScopedRequestEpoch';
import { getContextScopedTeamResetState, getFullResetState } from '../utils/stateResetHelpers';
import type { AppState } from '../types';
import type { ContextSnapshot } from '@renderer/services/contextStorage';
import type { Project, RepositoryGroup } from '@renderer/types/data';
import type { Pane } from '@renderer/types/panes';
import type { ContextInfo } from '@shared/types/api';
import type { StateCreator } from 'zustand';
// =============================================================================
// Slice 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
availableContexts: ContextInfo[]; // list of all available contexts (local + SSH)
// Actions
switchContext: (targetContextId: string) => Promise<void>;
initializeContextSystem: () => Promise<void>;
fetchAvailableContexts: () => Promise<void>;
}
// =============================================================================
// Helpers
// =============================================================================
/**
* Get empty context state for fresh contexts.
* Returns state with empty arrays, null selections, and default dashboard tab.
*/
function getEmptyContextState(): Partial<AppState> {
return {
...getFullResetState(),
...getContextScopedTeamResetState(),
projects: [],
projectsLoading: false,
projectsInitialized: false,
projectsError: null,
repositoryGroups: [],
repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null,
sessions: [],
notifications: [],
unreadCount: 0,
openTabs: [],
activeTabId: null,
selectedTabIds: [],
activeProjectId: null,
paneLayout: {
panes: [
{
id: 'pane-default',
tabs: [],
activeTabId: null,
selectedTabIds: [],
widthFraction: 1,
},
],
focusedPaneId: 'pane-default',
},
};
}
function isContextSwitchRequestCurrent(
get: () => AppState,
targetContextId: string,
requestEpoch: number
): boolean {
return (
get().activeContextId === targetContextId && isContextScopedRequestEpochCurrent(requestEpoch)
);
}
/**
* Validate snapshot against fresh data from target context.
* Filters invalid tabs, selections, and ensures at-least-one-pane invariant.
*/
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: Pane[] =
validatedPanes.length > 0
? validatedPanes
: [
{
id: 'pane-default',
tabs: [],
activeTabId: null,
selectedTabIds: [],
widthFraction: 1,
},
];
return {
// Restored from snapshot (use fresh data for projects/repoGroups)
projects: freshProjects,
projectsLoading: false,
projectsInitialized: true,
projectsError: null,
selectedProjectId,
repositoryGroups: freshRepoGroups,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: true,
repositoryGroupsError: null,
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,
};
}
/**
* Capture current context state as a snapshot.
* Excludes transient state (loading flags, errors, search, Maps/Sets).
*/
function captureSnapshot(state: AppState, contextId: string): ContextSnapshot {
return {
// Data state
projects: state.projects,
selectedProjectId: state.selectedProjectId,
repositoryGroups: state.repositoryGroups,
selectedRepositoryId: state.selectedRepositoryId,
selectedWorktreeId: state.selectedWorktreeId,
viewMode: state.viewMode,
sessions: state.sessions,
selectedSessionId: state.selectedSessionId,
sessionsCursor: state.sessionsCursor,
sessionsHasMore: state.sessionsHasMore,
sessionsTotalCount: state.sessionsTotalCount,
pinnedSessionIds: state.pinnedSessionIds,
notifications: state.notifications,
unreadCount: state.unreadCount,
// Tab/pane state
openTabs: state.openTabs,
activeTabId: state.activeTabId,
selectedTabIds: state.selectedTabIds,
activeProjectId: state.activeProjectId,
paneLayout: state.paneLayout,
// UI state
sidebarCollapsed: state.sidebarCollapsed,
// Metadata
_metadata: {
contextId,
capturedAt: Date.now(),
version: 1,
},
};
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> = (set, get) => ({
// Initial state — local context is always ready, no initialization needed.
// initializeContextSystem() is called lazily when SSH connects.
activeContextId: 'local',
isContextSwitching: false,
targetContextId: null,
contextSnapshotsReady: true,
availableContexts: [{ id: 'local', type: 'local' as const }],
// Initialize context system (called once on app mount)
initializeContextSystem: async () => {
try {
// Check IndexedDB availability
const available = await contextStorage.isAvailable();
if (available) {
// Clean up expired snapshots
void contextStorage.cleanupExpired();
void draftStorage.cleanupExpired();
}
// Fetch active context from main process
const activeContextId = await api.context.getActive();
const previousContextId = get().activeContextId;
const contextChanged = activeContextId !== previousContextId;
if (contextChanged) {
invalidateContextScopedRequestEpoch();
}
set({
...(contextChanged
? {
...getFullResetState(),
...getContextScopedTeamResetState(),
projects: [],
projectsLoading: false,
projectsInitialized: false,
projectsError: null,
repositoryGroups: [],
repositoryGroupsLoading: false,
repositoryGroupsInitialized: false,
repositoryGroupsError: null,
}
: {}),
contextSnapshotsReady: true,
activeContextId,
});
if (contextChanged) {
void get().fetchProjects();
void get().fetchRepositoryGroups();
void get().fetchTeams();
void get().fetchAllTasks();
}
// Fetch available contexts
await get().fetchAvailableContexts();
} catch (error) {
console.error('[contextSlice] Failed to initialize context system:', error);
set({ contextSnapshotsReady: true }); // Continue anyway
}
},
// Fetch list of available contexts (local + SSH)
fetchAvailableContexts: async () => {
try {
const result = await api.context.list();
set({ availableContexts: result });
} catch (error) {
console.error('[contextSlice] Failed to fetch available contexts:', error);
// Fallback to local-only
set({ availableContexts: [{ id: 'local', type: 'local' }] });
}
},
// Switch to a different context
switchContext: async (targetContextId: string) => {
const state = get();
// Early return if already on target context
if (targetContextId === state.activeContextId) {
return;
}
// Re-entrancy guard: prevent concurrent switch races from overlapping events
if (state.isContextSwitching) {
return;
}
set({
isContextSwitching: true,
targetContextId,
});
try {
// Step 1: Save current snapshot + load target snapshot + switch main process
// These are independent — run in parallel for speed
const currentSnapshot = captureSnapshot(state, state.activeContextId);
const [, targetSnapshot] = await Promise.all([
contextStorage.saveSnapshot(state.activeContextId, currentSnapshot),
contextStorage.loadSnapshot(targetContextId),
api.context.switch(targetContextId),
]);
invalidateContextScopedRequestEpoch();
// Step 2: Apply cached snapshot immediately for instant visual feedback
if (targetSnapshot) {
set({
...getContextScopedTeamResetState(),
projects: targetSnapshot.projects,
projectsLoading: false,
projectsInitialized: true,
projectsError: null,
repositoryGroups: targetSnapshot.repositoryGroups,
repositoryGroupsLoading: false,
repositoryGroupsInitialized: true,
repositoryGroupsError: null,
selectedProjectId: targetSnapshot.selectedProjectId,
selectedRepositoryId: targetSnapshot.selectedRepositoryId,
selectedWorktreeId: targetSnapshot.selectedWorktreeId,
viewMode: targetSnapshot.viewMode,
sessions: targetSnapshot.sessions,
selectedSessionId: targetSnapshot.selectedSessionId,
sessionsCursor: targetSnapshot.sessionsCursor,
sessionsHasMore: targetSnapshot.sessionsHasMore,
sessionsTotalCount: targetSnapshot.sessionsTotalCount,
pinnedSessionIds: targetSnapshot.pinnedSessionIds,
notifications: targetSnapshot.notifications,
unreadCount: targetSnapshot.unreadCount,
openTabs: targetSnapshot.openTabs,
activeTabId: targetSnapshot.activeTabId,
selectedTabIds: targetSnapshot.selectedTabIds,
activeProjectId: targetSnapshot.activeProjectId,
paneLayout: targetSnapshot.paneLayout,
sidebarCollapsed: targetSnapshot.sidebarCollapsed,
// Finalize switch — overlay disappears, user sees cached data instantly
activeContextId: targetContextId,
isContextSwitching: false,
targetContextId: null,
});
} else {
set({
...getEmptyContextState(),
activeContextId: targetContextId,
isContextSwitching: true,
targetContextId,
});
}
const switchRequestEpoch = captureContextScopedRequestEpoch();
// Step 3: Fetch fresh data in background (slow over SSH)
// Wrapped in try/catch so fetch failures don't wipe valid snapshot data.
// IPC handlers return [] on SSH scan failure — we must guard against that.
try {
const [freshProjects, freshRepoGroups] = await Promise.all([
api.getProjects(),
api.getRepositoryGroups(),
]);
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
return;
}
if (targetSnapshot) {
// Guard: don't overwrite snapshot data if fetch returned empty
// (likely transient SSH scan failure, not genuinely empty workspace)
const snapshotHadData =
targetSnapshot.projects.length > 0 || targetSnapshot.repositoryGroups.length > 0;
const freshIsEmpty = freshProjects.length === 0 && freshRepoGroups.length === 0;
if (snapshotHadData && freshIsEmpty) {
console.warn(
'[contextSlice] Background fetch returned empty but snapshot had data — keeping snapshot'
);
} else {
set(validateSnapshot(targetSnapshot, freshProjects, freshRepoGroups));
}
} else {
// No cache (first visit) — apply empty state with fresh data
set({
...getEmptyContextState(),
projects: freshProjects,
projectsInitialized: true,
repositoryGroups: freshRepoGroups,
repositoryGroupsInitialized: true,
activeContextId: targetContextId,
isContextSwitching: false,
targetContextId: null,
});
}
} catch (fetchError) {
console.error('[contextSlice] Background data refresh failed:', fetchError);
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
return;
}
// Keep snapshot data as fallback — don't wipe user's view
if (!targetSnapshot) {
// No snapshot and fetch failed — finalize switch with empty state
set({
...getEmptyContextState(),
activeContextId: targetContextId,
isContextSwitching: false,
targetContextId: null,
});
}
}
// Step 4: Fetch notifications in background
if (!isContextSwitchRequestCurrent(get, targetContextId, switchRequestEpoch)) {
return;
}
void get().fetchNotifications();
void get().fetchTeams();
void get().fetchAllTasks();
} catch (error) {
console.error('[contextSlice] Failed to switch context:', error);
// Do NOT leave in broken state
set({
isContextSwitching: false,
targetContextId: null,
});
}
},
});