Files
Maestro/src/renderer/components/Wizard/WizardContext.tsx
Pedram Amini dca51447fd fix(ssh): stabilize SSH remote execution across wizard, file browser, and process manager
- Added extensive DEBUG-level logging for SSH command execution, spawn details, exit codes, and configuration flow
- Improved Wizard SSH remote support:
  - Debounced remote directory validation to reduce excessive SSH calls
  - Fixed git.isRepo() to correctly pass remoteCwd for remote checks
  - Persisted SSH config in SerializableWizardState and validated directories over SSH
  - Ensured ConversationScreen and ConversationSession consistently pass SSH config for remote agent execution
  - Fixed "agent not available" errors by forwarding stdin via exec and enabling stream-json mode for large prompts
- Enhanced remote agent execution logic in ProcessManager with stdin streaming, exec-based forwarding, and useStdin flag
- Improved SSH file browser behavior:
  - Added resolveSshPath() to locate SSH binaries on Windows (Electron spawn PATH issue)
  - Corrected getSshContext() handling of enabled/remoteId states
  - Ensured synopsis background tasks run via SSH instead of local paths
- Added Windows development improvements: dev:win script and PowerShell launcher for separate renderer/main terminals
- Added additional SSH directory debugging logs for remote-fs and wizard flows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:48:41 +00:00

993 lines
28 KiB
TypeScript

/**
* WizardContext.tsx
*
* State management for the onboarding wizard multi-screen flow.
* Manages wizard progression, agent selection, directory configuration,
* conversation history, and document generation state.
*/
import {
createContext,
useContext,
useCallback,
useReducer,
useEffect,
useRef,
useMemo,
ReactNode,
} from 'react';
import type { ToolType, AgentConfig } from '../../types';
/**
* Wizard steps in order of progression
*/
export type WizardStep =
| 'agent-selection'
| 'directory-selection'
| 'conversation'
| 'preparing-plan'
| 'phase-review';
/**
* Total number of steps in the wizard
*/
export const WIZARD_TOTAL_STEPS = 5;
/**
* Map step names to their numeric index (1-based for display)
*/
export const STEP_INDEX: Record<WizardStep, number> = {
'agent-selection': 1,
'directory-selection': 2,
conversation: 3,
'preparing-plan': 4,
'phase-review': 5,
};
/**
* Map numeric index to step name
*/
export const INDEX_TO_STEP: Record<number, WizardStep> = {
1: 'agent-selection',
2: 'directory-selection',
3: 'conversation',
4: 'preparing-plan',
5: 'phase-review',
};
/**
* Conversation message in the wizard conversation flow
*/
export interface WizardMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
/** Parsed confidence from assistant responses */
confidence?: number;
/** Parsed ready flag from assistant responses */
ready?: boolean;
}
/**
* Generated document from the phase generation step
*/
export interface GeneratedDocument {
filename: string;
content: string;
taskCount: number;
/** Absolute path after saving */
savedPath?: string;
}
/**
* Complete wizard state
*/
export interface WizardState {
/** Current step in the wizard flow */
currentStep: WizardStep;
/** Whether the wizard is open/visible */
isOpen: boolean;
// Agent Selection (Step 1)
/** Selected agent type */
selectedAgent: ToolType | null;
/** Available agents from detection */
availableAgents: AgentConfig[];
/** User-provided project name */
agentName: string;
/** Per-agent custom path */
customPath?: string;
/** Per-agent custom CLI arguments */
customArgs?: string;
/** Per-agent custom environment variables */
customEnvVars?: Record<string, string>;
/** Per-session SSH remote configuration (stored per-session, not per-agent) */
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
// Directory Selection (Step 2)
/** Selected directory path */
directoryPath: string;
/** Whether the selected path is a git repo */
isGitRepo: boolean;
/** Auto-detected agent path (if any) */
detectedAgentPath: string | null;
/** Directory selection error (if any) */
directoryError: string | null;
/** Whether an Auto Run Docs folder exists in the selected directory */
hasExistingAutoRunDocs: boolean;
/** Number of documents in existing Auto Run Docs folder */
existingDocsCount: number;
/** User's choice for existing docs: 'continue' to read them, 'fresh' to delete them */
existingDocsChoice: 'continue' | 'fresh' | null;
// Conversation (Step 3)
/** Conversation history with the agent */
conversationHistory: WizardMessage[];
/** Current confidence level (0-100) from agent responses */
confidenceLevel: number;
/** Whether agent has indicated ready=true and confidence > 80 */
isReadyToProceed: boolean;
/** Whether conversation is in progress (agent is thinking) */
isConversationLoading: boolean;
/** Error message if conversation fails */
conversationError: string | null;
// Phase Review (Step 4)
/** Generated Auto Run documents */
generatedDocuments: GeneratedDocument[];
/** Index of the currently displayed document (Phase 1 = 0) */
currentDocumentIndex: number;
/** Whether documents are being generated */
isGeneratingDocuments: boolean;
/** Error message if generation fails */
generationError: string | null;
/** User's edited content for Phase 1 (if modified) */
editedPhase1Content: string | null;
// Tour Preference
/** Whether user wants the walkthrough tour after setup */
wantsTour: boolean;
// Wizard completion state
/** Whether wizard completed successfully */
isComplete: boolean;
/** Session ID created by the wizard (if any) */
createdSessionId: string | null;
}
/**
* Initial/default wizard state
*/
const initialState: WizardState = {
currentStep: 'agent-selection',
isOpen: false,
// Agent Selection
selectedAgent: null,
availableAgents: [],
agentName: '',
customPath: undefined,
customArgs: undefined,
customEnvVars: undefined,
sessionSshRemoteConfig: undefined,
// Directory Selection
directoryPath: '',
isGitRepo: false,
detectedAgentPath: null,
directoryError: null,
hasExistingAutoRunDocs: false,
existingDocsCount: 0,
existingDocsChoice: null,
// Conversation
conversationHistory: [],
confidenceLevel: 0,
isReadyToProceed: false,
isConversationLoading: false,
conversationError: null,
// Phase Review
generatedDocuments: [],
currentDocumentIndex: 0,
isGeneratingDocuments: false,
generationError: null,
editedPhase1Content: null,
// Tour
wantsTour: true, // Default to wanting the tour
// Completion
isComplete: false,
createdSessionId: null,
};
/**
* Action types for the wizard reducer
*/
type WizardAction =
| { type: 'OPEN_WIZARD' }
| { type: 'CLOSE_WIZARD' }
| { type: 'RESET_WIZARD' }
| { type: 'SET_STEP'; step: WizardStep }
| { type: 'NEXT_STEP' }
| { type: 'PREVIOUS_STEP' }
| { type: 'SET_SELECTED_AGENT'; agent: ToolType | null }
| { type: 'SET_AVAILABLE_AGENTS'; agents: AgentConfig[] }
| { type: 'SET_AGENT_NAME'; name: string }
| { type: 'SET_CUSTOM_PATH'; path: string | undefined }
| { type: 'SET_CUSTOM_ARGS'; args: string | undefined }
| { type: 'SET_CUSTOM_ENV_VARS'; envVars: Record<string, string> | undefined }
| {
type: 'SET_SESSION_SSH_REMOTE_CONFIG';
config:
| { enabled: boolean; remoteId: string | null; workingDirOverride?: string }
| undefined;
}
| { type: 'SET_DIRECTORY_PATH'; path: string }
| { type: 'SET_IS_GIT_REPO'; isGitRepo: boolean }
| { type: 'SET_DETECTED_AGENT_PATH'; path: string | null }
| { type: 'SET_DIRECTORY_ERROR'; error: string | null }
| { type: 'SET_HAS_EXISTING_AUTORUN_DOCS'; hasExisting: boolean; count: number }
| { type: 'SET_EXISTING_DOCS_CHOICE'; choice: 'continue' | 'fresh' | null }
| { type: 'ADD_MESSAGE'; message: WizardMessage }
| { type: 'SET_CONVERSATION_HISTORY'; history: WizardMessage[] }
| { type: 'SET_CONFIDENCE_LEVEL'; level: number }
| { type: 'SET_IS_READY_TO_PROCEED'; ready: boolean }
| { type: 'SET_CONVERSATION_LOADING'; loading: boolean }
| { type: 'SET_CONVERSATION_ERROR'; error: string | null }
| { type: 'SET_GENERATED_DOCUMENTS'; documents: GeneratedDocument[] }
| { type: 'SET_CURRENT_DOCUMENT_INDEX'; index: number }
| { type: 'SET_GENERATING_DOCUMENTS'; generating: boolean }
| { type: 'SET_GENERATION_ERROR'; error: string | null }
| { type: 'SET_EDITED_PHASE1_CONTENT'; content: string | null }
| { type: 'SET_WANTS_TOUR'; wantsTour: boolean }
| { type: 'SET_COMPLETE'; sessionId: string | null }
| { type: 'RESTORE_STATE'; state: Partial<WizardState> };
/**
* Get the next step in the wizard flow
*/
function getNextStep(current: WizardStep): WizardStep | null {
const currentIndex = STEP_INDEX[current];
const nextIndex = currentIndex + 1;
return nextIndex <= WIZARD_TOTAL_STEPS ? INDEX_TO_STEP[nextIndex] : null;
}
/**
* Get the previous step in the wizard flow
*/
function getPreviousStep(current: WizardStep): WizardStep | null {
const currentIndex = STEP_INDEX[current];
const prevIndex = currentIndex - 1;
return prevIndex >= 1 ? INDEX_TO_STEP[prevIndex] : null;
}
/**
* Wizard state reducer
*/
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
switch (action.type) {
case 'OPEN_WIZARD':
return { ...state, isOpen: true };
case 'CLOSE_WIZARD':
return { ...state, isOpen: false };
case 'RESET_WIZARD':
return { ...initialState };
case 'SET_STEP':
return { ...state, currentStep: action.step };
case 'NEXT_STEP': {
const nextStep = getNextStep(state.currentStep);
return nextStep ? { ...state, currentStep: nextStep } : state;
}
case 'PREVIOUS_STEP': {
const prevStep = getPreviousStep(state.currentStep);
return prevStep ? { ...state, currentStep: prevStep } : state;
}
case 'SET_SELECTED_AGENT':
return { ...state, selectedAgent: action.agent };
case 'SET_AVAILABLE_AGENTS':
return { ...state, availableAgents: action.agents };
case 'SET_AGENT_NAME':
return { ...state, agentName: action.name };
case 'SET_CUSTOM_PATH':
return { ...state, customPath: action.path };
case 'SET_CUSTOM_ARGS':
return { ...state, customArgs: action.args };
case 'SET_CUSTOM_ENV_VARS':
return { ...state, customEnvVars: action.envVars };
case 'SET_SESSION_SSH_REMOTE_CONFIG':
return { ...state, sessionSshRemoteConfig: action.config };
case 'SET_DIRECTORY_PATH':
return { ...state, directoryPath: action.path, directoryError: null };
case 'SET_IS_GIT_REPO':
return { ...state, isGitRepo: action.isGitRepo };
case 'SET_DETECTED_AGENT_PATH':
return { ...state, detectedAgentPath: action.path };
case 'SET_DIRECTORY_ERROR':
return { ...state, directoryError: action.error };
case 'SET_HAS_EXISTING_AUTORUN_DOCS':
return {
...state,
hasExistingAutoRunDocs: action.hasExisting,
existingDocsCount: action.count,
};
case 'SET_EXISTING_DOCS_CHOICE':
return { ...state, existingDocsChoice: action.choice };
case 'ADD_MESSAGE':
return {
...state,
conversationHistory: [...state.conversationHistory, action.message],
};
case 'SET_CONVERSATION_HISTORY':
return { ...state, conversationHistory: action.history };
case 'SET_CONFIDENCE_LEVEL':
return { ...state, confidenceLevel: action.level };
case 'SET_IS_READY_TO_PROCEED':
return { ...state, isReadyToProceed: action.ready };
case 'SET_CONVERSATION_LOADING':
return { ...state, isConversationLoading: action.loading };
case 'SET_CONVERSATION_ERROR':
return { ...state, conversationError: action.error };
case 'SET_GENERATED_DOCUMENTS':
return { ...state, generatedDocuments: action.documents };
case 'SET_CURRENT_DOCUMENT_INDEX':
return { ...state, currentDocumentIndex: action.index };
case 'SET_GENERATING_DOCUMENTS':
return { ...state, isGeneratingDocuments: action.generating };
case 'SET_GENERATION_ERROR':
return { ...state, generationError: action.error };
case 'SET_EDITED_PHASE1_CONTENT':
return { ...state, editedPhase1Content: action.content };
case 'SET_WANTS_TOUR':
return { ...state, wantsTour: action.wantsTour };
case 'SET_COMPLETE':
return {
...state,
isComplete: true,
createdSessionId: action.sessionId,
isOpen: false,
};
case 'RESTORE_STATE':
return { ...state, ...action.state };
default:
return state;
}
}
/**
* Serializable wizard state for persistence
*/
export interface SerializableWizardState {
currentStep: WizardStep;
selectedAgent: ToolType | null;
agentName: string;
directoryPath: string;
isGitRepo: boolean;
conversationHistory: WizardMessage[];
confidenceLevel: number;
isReadyToProceed: boolean;
generatedDocuments: GeneratedDocument[];
editedPhase1Content: string | null;
wantsTour: boolean;
/** Per-session SSH remote configuration (for remote execution) */
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}
/**
* Wizard context API type
*/
export interface WizardContextAPI {
/** Current wizard state */
state: WizardState;
// Wizard lifecycle
/** Open the wizard */
openWizard: () => void;
/** Close the wizard (can be reopened) */
closeWizard: () => void;
/** Reset wizard to initial state */
resetWizard: () => void;
// Navigation
/** Go to a specific step */
goToStep: (step: WizardStep) => void;
/** Go to the next step */
nextStep: () => void;
/** Go to the previous step */
previousStep: () => void;
/** Check if can go to next step (validation) */
canProceedToNext: () => boolean;
/** Get current step number (1-based) */
getCurrentStepNumber: () => number;
// Agent Selection
/** Set the selected agent type */
setSelectedAgent: (agent: ToolType | null) => void;
/** Set available agents from detection */
setAvailableAgents: (agents: AgentConfig[]) => void;
/** Set the project/agent name */
setAgentName: (name: string) => void;
/** Set custom path for the agent */
setCustomPath: (path: string | undefined) => void;
/** Set custom CLI arguments for the agent */
setCustomArgs: (args: string | undefined) => void;
/** Set custom environment variables for the agent */
setCustomEnvVars: (envVars: Record<string, string> | undefined) => void;
/** Set per-session SSH remote configuration */
setSessionSshRemoteConfig: (
config: { enabled: boolean; remoteId: string | null; workingDirOverride?: string } | undefined
) => void;
// Directory Selection
/** Set the directory path */
setDirectoryPath: (path: string) => void;
/** Set whether the path is a git repo */
setIsGitRepo: (isGitRepo: boolean) => void;
/** Set the auto-detected agent path */
setDetectedAgentPath: (path: string | null) => void;
/** Set directory validation error */
setDirectoryError: (error: string | null) => void;
/** Set whether existing Auto Run Docs folder was found */
setHasExistingAutoRunDocs: (hasExisting: boolean, count: number) => void;
/** Set user's choice for existing docs */
setExistingDocsChoice: (choice: 'continue' | 'fresh' | null) => void;
// Conversation
/** Add a message to conversation history */
addMessage: (message: Omit<WizardMessage, 'id' | 'timestamp'>) => void;
/** Set the entire conversation history (for restore) */
setConversationHistory: (history: WizardMessage[]) => void;
/** Set the confidence level from agent response */
setConfidenceLevel: (level: number) => void;
/** Set whether ready to proceed (confidence > 80 and ready=true) */
setIsReadyToProceed: (ready: boolean) => void;
/** Set conversation loading state */
setConversationLoading: (loading: boolean) => void;
/** Set conversation error */
setConversationError: (error: string | null) => void;
// Phase Review
/** Set generated documents */
setGeneratedDocuments: (documents: GeneratedDocument[]) => void;
/** Set current document index for viewing */
setCurrentDocumentIndex: (index: number) => void;
/** Set document generation loading state */
setGeneratingDocuments: (generating: boolean) => void;
/** Set generation error */
setGenerationError: (error: string | null) => void;
/** Set user's edited Phase 1 content */
setEditedPhase1Content: (content: string | null) => void;
/** Get current Phase 1 content (edited or original) */
getPhase1Content: () => string;
// Tour
/** Set whether user wants the tour */
setWantsTour: (wantsTour: boolean) => void;
// Completion
/** Mark wizard as complete with optional session ID */
completeWizard: (sessionId: string | null) => void;
// State persistence (for resume functionality)
/** Save current state for later resume */
saveStateForResume: () => void;
/** Restore state from saved data */
restoreState: (state: Partial<WizardState>) => void;
/** Get serializable state for persistence */
getSerializableState: () => SerializableWizardState;
/** Check if there's saved resume state (async) */
hasResumeState: () => Promise<boolean>;
/** Load saved resume state (async, returns null if none) */
loadResumeState: () => Promise<SerializableWizardState | null>;
/** Clear saved resume state */
clearResumeState: () => void;
}
// Create the context
const WizardContext = createContext<WizardContextAPI | null>(null);
/**
* WizardProvider props
*/
interface WizardProviderProps {
children: ReactNode;
}
/**
* Generate a unique message ID
*/
function generateMessageId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
/**
* WizardProvider - Provides wizard state management to the component tree
*/
export function WizardProvider({ children }: WizardProviderProps) {
const [state, dispatch] = useReducer(wizardReducer, initialState);
// Wizard lifecycle
const openWizard = useCallback(() => {
// If previous wizard was completed, reset state for fresh start
// This prevents showing stale state when running wizard multiple times in same session
if (state.isComplete) {
dispatch({ type: 'RESET_WIZARD' });
}
dispatch({ type: 'OPEN_WIZARD' });
}, [state.isComplete]);
const closeWizard = useCallback(() => {
dispatch({ type: 'CLOSE_WIZARD' });
}, []);
const resetWizard = useCallback(() => {
dispatch({ type: 'RESET_WIZARD' });
}, []);
// Navigation
const goToStep = useCallback((step: WizardStep) => {
dispatch({ type: 'SET_STEP', step });
}, []);
const nextStep = useCallback(() => {
dispatch({ type: 'NEXT_STEP' });
}, []);
const previousStep = useCallback(() => {
dispatch({ type: 'PREVIOUS_STEP' });
}, []);
const canProceedToNext = useCallback((): boolean => {
switch (state.currentStep) {
case 'agent-selection':
// Must have selected an agent and provided a name
return state.selectedAgent !== null && state.agentName.trim() !== '';
case 'directory-selection':
// Must have a valid directory path
return state.directoryPath.trim() !== '' && state.directoryError === null;
case 'conversation':
// Must have confidence > 80 and ready=true from agent
return state.isReadyToProceed;
case 'phase-review':
// Must have at least one generated document
return state.generatedDocuments.length > 0;
default:
return false;
}
}, [
state.currentStep,
state.selectedAgent,
state.agentName,
state.directoryPath,
state.directoryError,
state.isReadyToProceed,
state.generatedDocuments.length,
]);
const getCurrentStepNumber = useCallback((): number => {
return STEP_INDEX[state.currentStep];
}, [state.currentStep]);
// Agent Selection
const setSelectedAgent = useCallback((agent: ToolType | null) => {
dispatch({ type: 'SET_SELECTED_AGENT', agent });
}, []);
const setAvailableAgents = useCallback((agents: AgentConfig[]) => {
dispatch({ type: 'SET_AVAILABLE_AGENTS', agents });
}, []);
const setAgentName = useCallback((name: string) => {
dispatch({ type: 'SET_AGENT_NAME', name });
}, []);
const setCustomPath = useCallback((path: string | undefined) => {
dispatch({ type: 'SET_CUSTOM_PATH', path });
}, []);
const setCustomArgs = useCallback((args: string | undefined) => {
dispatch({ type: 'SET_CUSTOM_ARGS', args });
}, []);
const setCustomEnvVars = useCallback((envVars: Record<string, string> | undefined) => {
dispatch({ type: 'SET_CUSTOM_ENV_VARS', envVars });
}, []);
const setSessionSshRemoteConfig = useCallback(
(
config: { enabled: boolean; remoteId: string | null; workingDirOverride?: string } | undefined
) => {
dispatch({ type: 'SET_SESSION_SSH_REMOTE_CONFIG', config });
},
[]
);
// Directory Selection
const setDirectoryPath = useCallback((path: string) => {
dispatch({ type: 'SET_DIRECTORY_PATH', path });
}, []);
const setIsGitRepo = useCallback((isGitRepo: boolean) => {
dispatch({ type: 'SET_IS_GIT_REPO', isGitRepo });
}, []);
const setDetectedAgentPath = useCallback((path: string | null) => {
dispatch({ type: 'SET_DETECTED_AGENT_PATH', path });
}, []);
const setDirectoryError = useCallback((error: string | null) => {
dispatch({ type: 'SET_DIRECTORY_ERROR', error });
}, []);
const setHasExistingAutoRunDocs = useCallback((hasExisting: boolean, count: number) => {
dispatch({ type: 'SET_HAS_EXISTING_AUTORUN_DOCS', hasExisting, count });
}, []);
const setExistingDocsChoice = useCallback((choice: 'continue' | 'fresh' | null) => {
dispatch({ type: 'SET_EXISTING_DOCS_CHOICE', choice });
}, []);
// Conversation
const addMessage = useCallback((message: Omit<WizardMessage, 'id' | 'timestamp'>) => {
const fullMessage: WizardMessage = {
...message,
id: generateMessageId(),
timestamp: Date.now(),
};
dispatch({ type: 'ADD_MESSAGE', message: fullMessage });
}, []);
const setConversationHistory = useCallback((history: WizardMessage[]) => {
dispatch({ type: 'SET_CONVERSATION_HISTORY', history });
}, []);
const setConfidenceLevel = useCallback((level: number) => {
dispatch({ type: 'SET_CONFIDENCE_LEVEL', level });
}, []);
const setIsReadyToProceed = useCallback((ready: boolean) => {
dispatch({ type: 'SET_IS_READY_TO_PROCEED', ready });
}, []);
const setConversationLoading = useCallback((loading: boolean) => {
dispatch({ type: 'SET_CONVERSATION_LOADING', loading });
}, []);
const setConversationError = useCallback((error: string | null) => {
dispatch({ type: 'SET_CONVERSATION_ERROR', error });
}, []);
// Phase Review
const setGeneratedDocuments = useCallback((documents: GeneratedDocument[]) => {
dispatch({ type: 'SET_GENERATED_DOCUMENTS', documents });
}, []);
const setCurrentDocumentIndex = useCallback((index: number) => {
dispatch({ type: 'SET_CURRENT_DOCUMENT_INDEX', index });
}, []);
const setGeneratingDocuments = useCallback((generating: boolean) => {
dispatch({ type: 'SET_GENERATING_DOCUMENTS', generating });
}, []);
const setGenerationError = useCallback((error: string | null) => {
dispatch({ type: 'SET_GENERATION_ERROR', error });
}, []);
const setEditedPhase1Content = useCallback((content: string | null) => {
dispatch({ type: 'SET_EDITED_PHASE1_CONTENT', content });
}, []);
const getPhase1Content = useCallback((): string => {
// Return edited content if available, otherwise original Phase 1 content
if (state.editedPhase1Content !== null) {
return state.editedPhase1Content;
}
const phase1Doc = state.generatedDocuments[0];
return phase1Doc?.content || '';
}, [state.editedPhase1Content, state.generatedDocuments]);
// Tour
const setWantsTour = useCallback((wantsTour: boolean) => {
dispatch({ type: 'SET_WANTS_TOUR', wantsTour });
}, []);
// Completion
const completeWizard = useCallback((sessionId: string | null) => {
// Clear saved resume state since wizard completed successfully
window.maestro.settings.set('wizardResumeState', null);
dispatch({ type: 'SET_COMPLETE', sessionId });
}, []);
// State persistence
const getSerializableState = useCallback((): SerializableWizardState => {
return {
currentStep: state.currentStep,
selectedAgent: state.selectedAgent,
agentName: state.agentName,
directoryPath: state.directoryPath,
isGitRepo: state.isGitRepo,
conversationHistory: state.conversationHistory,
confidenceLevel: state.confidenceLevel,
isReadyToProceed: state.isReadyToProceed,
generatedDocuments: state.generatedDocuments,
editedPhase1Content: state.editedPhase1Content,
wantsTour: state.wantsTour,
sessionSshRemoteConfig: state.sessionSshRemoteConfig,
};
}, [
state.currentStep,
state.selectedAgent,
state.agentName,
state.directoryPath,
state.isGitRepo,
state.conversationHistory,
state.confidenceLevel,
state.isReadyToProceed,
state.generatedDocuments,
state.editedPhase1Content,
state.wantsTour,
state.sessionSshRemoteConfig,
]);
const saveStateForResume = useCallback(() => {
const serializableState = getSerializableState();
// Save to settings (async, fire-and-forget)
window.maestro.settings.set('wizardResumeState', serializableState);
}, [getSerializableState]);
const restoreState = useCallback((savedState: Partial<WizardState>) => {
dispatch({ type: 'RESTORE_STATE', state: savedState });
}, []);
const hasResumeState = useCallback(async (): Promise<boolean> => {
try {
const saved = await window.maestro.settings.get('wizardResumeState');
// Check if saved state exists and wizard is not complete
return saved !== undefined && saved !== null && typeof saved === 'object';
} catch {
return false;
}
}, []);
const loadResumeState = useCallback(async (): Promise<SerializableWizardState | null> => {
try {
const saved = await window.maestro.settings.get('wizardResumeState');
if (saved && typeof saved === 'object') {
// Validate that required fields exist
const state = saved as SerializableWizardState;
if (state.currentStep && state.currentStep !== 'agent-selection') {
// Only return state if past the first step
return state;
}
}
return null;
} catch {
return null;
}
}, []);
const clearResumeState = useCallback(() => {
window.maestro.settings.set('wizardResumeState', null);
}, []);
// Store full state in a ref so we can access current values without triggering effect re-runs
const stateRef = useRef(state);
stateRef.current = state;
// Auto-save state when step changes (only for steps past the first)
// PERF: Only depends on state.currentStep to avoid running on every state change
useEffect(() => {
// Save state when advancing past the first step
// This ensures user progress is preserved for resume
if (STEP_INDEX[state.currentStep] > 1) {
// Access current state via ref to get latest values
const currentState = stateRef.current;
const serializableState: SerializableWizardState = {
currentStep: currentState.currentStep,
selectedAgent: currentState.selectedAgent,
agentName: currentState.agentName,
directoryPath: currentState.directoryPath,
isGitRepo: currentState.isGitRepo,
conversationHistory: currentState.conversationHistory,
confidenceLevel: currentState.confidenceLevel,
isReadyToProceed: currentState.isReadyToProceed,
generatedDocuments: currentState.generatedDocuments,
editedPhase1Content: currentState.editedPhase1Content,
wantsTour: currentState.wantsTour,
};
window.maestro.settings.set('wizardResumeState', serializableState);
}
}, [state.currentStep]);
// Build the context value - memoized to prevent unnecessary re-renders
const contextValue: WizardContextAPI = useMemo(
() => ({
state,
// Lifecycle
openWizard,
closeWizard,
resetWizard,
// Navigation
goToStep,
nextStep,
previousStep,
canProceedToNext,
getCurrentStepNumber,
// Agent Selection
setSelectedAgent,
setAvailableAgents,
setAgentName,
setCustomPath,
setCustomArgs,
setCustomEnvVars,
setSessionSshRemoteConfig,
// Directory Selection
setDirectoryPath,
setIsGitRepo,
setDetectedAgentPath,
setDirectoryError,
setHasExistingAutoRunDocs,
setExistingDocsChoice,
// Conversation
addMessage,
setConversationHistory,
setConfidenceLevel,
setIsReadyToProceed,
setConversationLoading,
setConversationError,
// Phase Review
setGeneratedDocuments,
setCurrentDocumentIndex,
setGeneratingDocuments,
setGenerationError,
setEditedPhase1Content,
getPhase1Content,
// Tour
setWantsTour,
// Completion
completeWizard,
// State persistence
saveStateForResume,
restoreState,
getSerializableState,
hasResumeState,
loadResumeState,
clearResumeState,
}),
[
state,
openWizard,
closeWizard,
resetWizard,
goToStep,
nextStep,
previousStep,
canProceedToNext,
getCurrentStepNumber,
setSelectedAgent,
setAvailableAgents,
setAgentName,
setCustomPath,
setCustomArgs,
setCustomEnvVars,
setSessionSshRemoteConfig,
setDirectoryPath,
setIsGitRepo,
setDetectedAgentPath,
setDirectoryError,
setHasExistingAutoRunDocs,
setExistingDocsChoice,
addMessage,
setConversationHistory,
setConfidenceLevel,
setIsReadyToProceed,
setConversationLoading,
setConversationError,
setGeneratedDocuments,
setCurrentDocumentIndex,
setGeneratingDocuments,
setGenerationError,
setEditedPhase1Content,
getPhase1Content,
setWantsTour,
completeWizard,
saveStateForResume,
restoreState,
getSerializableState,
hasResumeState,
loadResumeState,
clearResumeState,
]
);
return <WizardContext.Provider value={contextValue}>{children}</WizardContext.Provider>;
}
/**
* useWizard - Hook to access the wizard context API
*
* Must be used within a WizardProvider. Throws an error if used outside.
*
* @returns WizardContextAPI - Methods and state for managing the wizard
*
* @example
* const { state, nextStep, setSelectedAgent } = useWizard();
*
* // Check current step
* if (state.currentStep === 'agent-selection') {
* setSelectedAgent('claude-code');
* }
*
* // Navigate to next step
* if (canProceedToNext()) {
* nextStep();
* }
*/
export function useWizard(): WizardContextAPI {
const context = useContext(WizardContext);
if (!context) {
throw new Error('useWizard must be used within a WizardProvider');
}
return context;
}