diff --git a/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/CreatedFilesList.test.tsx b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/CreatedFilesList.test.tsx new file mode 100644 index 0000000000..c05f6d2f50 --- /dev/null +++ b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/CreatedFilesList.test.tsx @@ -0,0 +1,43 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { CreatedFilesList } from '../../../../../renderer/components/InlineWizard/DocumentGenerationView/components'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +const firstDoc = { + filename: 'Phase-01-Setup.md', + content: '# Setup\n\nFirst paragraph.\n\n- [ ] Task', + taskCount: 1, +}; + +const secondDoc = { + filename: 'Phase-02-Build.md', + content: '# Build\n\nSecond paragraph.\n\n- [ ] Task', + taskCount: 1, +}; + +function getDescriptionPanel(text: string): HTMLElement { + const description = screen.getByText(text); + const panel = description.parentElement; + if (!panel) throw new Error('Missing description panel'); + return panel; +} + +describe('CreatedFilesList', () => { + it('auto-expands the newest file when it is added', () => { + const { rerender } = render(); + + rerender(); + + expect(getDescriptionPanel('Second paragraph.')).toHaveStyle({ maxHeight: '120px' }); + }); + + it('preserves a user-expanded file when a newer file appears', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: /phase-01-setup/i })); + rerender(); + + expect(getDescriptionPanel('First paragraph.')).toHaveStyle({ maxHeight: '120px' }); + expect(getDescriptionPanel('Second paragraph.')).toHaveStyle({ maxHeight: '120px' }); + }); +}); diff --git a/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.test.tsx b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.test.tsx new file mode 100644 index 0000000000..ceb6cec76f --- /dev/null +++ b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { DocumentGenerationView } from '../../../../../renderer/components/InlineWizard/DocumentGenerationView'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +const generatedDoc = { + filename: 'Phase-01-Setup.md', + content: '# Setup\n\nPlan the setup.\n\n- [ ] Create project\n- [x] Verify project', + taskCount: 2, +}; + +function renderView(overrides: Partial> = {}) { + return render( + + ); +} + +describe('DocumentGenerationView', () => { + it('renders the empty state and cancel action', () => { + const onCancel = vi.fn(); + renderView({ onCancel }); + + expect(screen.getByText('No documents generated yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('renders the generating state with cancel action', () => { + const onCancel = vi.fn(); + renderView({ isGenerating: true, onCancel }); + + expect(screen.getByText('Generating Auto Run Documents...')).toBeInTheDocument(); + expect(screen.getByText(/This may take a while/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('renders the complete state with task totals and saved location', () => { + renderView({ + documents: [generatedDoc], + subfolderName: 'Generated-Plan', + }); + + expect(screen.getByText('Documentation generation complete.')).toBeInTheDocument(); + expect(screen.getByText('Generated-Plan/')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('Tasks Planned')).toBeInTheDocument(); + expect(screen.getByText('Work Plans Drafted (1)')).toBeInTheDocument(); + }); + + it('calls completion actions', () => { + const onComplete = vi.fn(); + const onCompleteAndStartAutoRun = vi.fn(); + renderView({ + documents: [generatedDoc], + onComplete, + onCompleteAndStartAutoRun, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Exit Wizard' })); + fireEvent.click(screen.getByRole('button', { name: 'Start Auto Run' })); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onCompleteAndStartAutoRun).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/useElapsedGenerationTime.test.tsx b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/useElapsedGenerationTime.test.tsx new file mode 100644 index 0000000000..c3e9574ec4 --- /dev/null +++ b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/useElapsedGenerationTime.test.tsx @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useElapsedGenerationTime } from '../../../../../renderer/components/InlineWizard/DocumentGenerationView/hooks/useElapsedGenerationTime'; + +describe('useElapsedGenerationTime', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('uses the persisted start timestamp while generating', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + + const { result } = renderHook(() => useElapsedGenerationTime(true, 7_000)); + + expect(result.current).toBe(3_000); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + + expect(result.current).toBe(4_000); + }); + + it('does not start an interval after generation is complete', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + + const { result } = renderHook(() => useElapsedGenerationTime(false, 7_000)); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + + expect(result.current).toBe(3_000); + }); + + it('finalizes elapsed time when generation stops', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + + const { result, rerender } = renderHook( + ({ isGenerating }) => useElapsedGenerationTime(isGenerating, 7_000), + { initialProps: { isGenerating: true } } + ); + + act(() => { + vi.advanceTimersByTime(1_500); + }); + + expect(result.current).toBe(4_000); + + rerender({ isGenerating: false }); + + expect(result.current).toBe(4_500); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + + expect(result.current).toBe(4_500); + }); +}); diff --git a/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/utils.test.ts b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/utils.test.ts new file mode 100644 index 0000000000..5421b167a9 --- /dev/null +++ b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/utils.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + countTasks, + countTotalTasks, + extractDocumentDescription, +} from '../../../../../renderer/components/InlineWizard/DocumentGenerationView/utils/documentStats'; + +describe('DocumentGenerationView document stats', () => { + it('counts simple markdown task rows', () => { + expect(countTasks('- [ ] One\n- [x] Two\n- [X] ignored by legacy UI regex')).toBe(2); + }); + + it('counts total tasks across documents', () => { + expect( + countTotalTasks([ + { filename: 'a.md', content: '- [ ] One', taskCount: 1 }, + { filename: 'b.md', content: '- [ ] Two\n- [x] Three', taskCount: 2 }, + ]) + ).toBe(3); + }); + + it('extracts the first non-heading non-list paragraph', () => { + expect(extractDocumentDescription('# Title\n\n- [ ] Task\n\nFirst useful paragraph.')).toBe( + 'First useful paragraph.' + ); + }); + + it('truncates long descriptions', () => { + const description = extractDocumentDescription('a'.repeat(160)); + expect(description).toHaveLength(150); + expect(description?.endsWith('...')).toBe(true); + }); +}); diff --git a/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/wrappers.test.tsx b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/wrappers.test.tsx new file mode 100644 index 0000000000..504a1b2fb4 --- /dev/null +++ b/src/__tests__/renderer/components/InlineWizard/DocumentGenerationView/wrappers.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { mockTheme } from '../../../../helpers/mockTheme'; + +vi.mock('../../../../../renderer/components/Wizard/shared/DocumentSelector', () => ({ + DocumentSelector: ({ + className, + showTaskCounts, + selectedIndex, + }: { + className?: string; + showTaskCounts?: boolean; + selectedIndex: number; + }) => ( +
+ ), +})); + +vi.mock('../../../../../renderer/components/Wizard/shared/DocumentEditor', () => ({ + DocumentEditor: ({ + folderPath, + selectedFile, + showHeader, + proseClassPrefix, + }: { + folderPath: string; + selectedFile: string; + showHeader: boolean; + proseClassPrefix: string; + }) => ( +
+ ), +})); + +import { + DocumentEditor, + DocumentSelector, +} from '../../../../../renderer/components/InlineWizard/DocumentGenerationView'; + +describe('DocumentGenerationView legacy wrappers', () => { + it('delegates DocumentSelector to the shared selector with task counts enabled', () => { + render( + + ); + + const selector = screen.getByTestId('shared-selector'); + expect(selector).toHaveAttribute('data-class-name', 'flex-1 min-w-0'); + expect(selector).toHaveAttribute('data-show-task-counts', 'true'); + expect(selector).toHaveAttribute('data-selected-index', '0'); + }); + + it('delegates DocumentEditor to the shared editor with hidden header defaults', () => { + render( + ()} + previewRef={createRef()} + /> + ); + + const editor = screen.getByTestId('shared-editor'); + expect(editor).toHaveAttribute('data-folder-path', '/docs'); + expect(editor).toHaveAttribute('data-selected-file', 'Phase.md'); + expect(editor).toHaveAttribute('data-show-header', 'false'); + expect(editor).toHaveAttribute('data-prose-class-prefix', 'doc-gen-view'); + }); +}); diff --git a/src/__tests__/renderer/hooks/batch/inlineWizard/documents.test.ts b/src/__tests__/renderer/hooks/batch/inlineWizard/documents.test.ts new file mode 100644 index 0000000000..9faff729de --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/inlineWizard/documents.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + fetchHistoryFilePath, + hasExistingDocuments, + listExistingDocuments, + loadDocumentContents, + resolveAutoRunFolderPath, +} from '../../../../../renderer/hooks/batch/inlineWizard/documents'; + +describe('inline wizard document helpers', () => { + beforeEach(() => { + vi.clearAllMocks(); + (window.maestro as any).history = { + getFilePath: vi.fn(), + }; + }); + + it('prefers configured Auto Run folder path', () => { + expect(resolveAutoRunFolderPath('/repo', '/custom/playbooks')).toBe('/custom/playbooks'); + }); + + it('falls back to the default playbooks path', () => { + expect(resolveAutoRunFolderPath('/repo')).toBe('/repo/.maestro/playbooks'); + }); + + it('detects whether existing documents are present', async () => { + vi.mocked(window.maestro.autorun.listDocs).mockResolvedValueOnce({ + success: true, + files: ['phase-1'], + }); + + await expect(hasExistingDocuments('/repo/.maestro/playbooks')).resolves.toBe(true); + expect(window.maestro.autorun.listDocs).toHaveBeenCalledWith('/repo/.maestro/playbooks'); + }); + + it('treats list errors as no existing documents', async () => { + vi.mocked(window.maestro.autorun.listDocs).mockRejectedValueOnce(new Error('missing')); + + await expect(hasExistingDocuments('/missing')).resolves.toBe(false); + }); + + it('maps existing document names to markdown paths', async () => { + vi.mocked(window.maestro.autorun.listDocs).mockResolvedValueOnce({ + success: true, + files: ['phase-1', 'phase-2'], + }); + + await expect(listExistingDocuments('/docs')).resolves.toEqual([ + { name: 'phase-1', filename: 'phase-1.md', path: '/docs/phase-1.md' }, + { name: 'phase-2', filename: 'phase-2.md', path: '/docs/phase-2.md' }, + ]); + }); + + it('loads document contents and preserves unreadable docs with placeholder content', async () => { + vi.mocked(window.maestro.autorun.readDoc) + .mockResolvedValueOnce({ success: true, content: '# One' }) + .mockRejectedValueOnce(new Error('read failed')); + + await expect( + loadDocumentContents( + [ + { name: 'phase-1', filename: 'phase-1.md', path: '/docs/phase-1.md' }, + { name: 'phase-2', filename: 'phase-2.md', path: '/docs/phase-2.md' }, + ], + '/docs' + ) + ).resolves.toEqual([ + { name: 'phase-1', filename: 'phase-1.md', path: '/docs/phase-1.md', content: '# One' }, + { + name: 'phase-2', + filename: 'phase-2.md', + path: '/docs/phase-2.md', + content: '(Failed to load content)', + }, + ]); + }); + + it('fetches local history file paths', async () => { + vi.mocked(window.maestro.history.getFilePath).mockResolvedValueOnce('/history/session.jsonl'); + + await expect(fetchHistoryFilePath('session-1')).resolves.toBe('/history/session.jsonl'); + expect(window.maestro.history.getFilePath).toHaveBeenCalledWith('session-1'); + }); + + it('skips history file lookup for SSH sessions', async () => { + await expect( + fetchHistoryFilePath('session-1', { enabled: true, remoteId: 'remote-1' }) + ).resolves.toBeUndefined(); + expect(window.maestro.history.getFilePath).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/renderer/hooks/batch/inlineWizard/generationActions.test.ts b/src/__tests__/renderer/hooks/batch/inlineWizard/generationActions.test.ts new file mode 100644 index 0000000000..9d4c8fec12 --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/inlineWizard/generationActions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + getProjectNameForGeneration, + parseGenerationProgress, +} from '../../../../../renderer/hooks/batch/inlineWizard/generationActions'; +import { initialInlineWizardState } from '../../../../../renderer/hooks/batch/inlineWizard/state'; + +describe('inline wizard generation helpers', () => { + it('parses "of" progress messages', () => { + expect(parseGenerationProgress('Saving 2 of 5 document(s)...')).toEqual({ + current: 2, + total: 5, + }); + }); + + it('parses slash progress messages', () => { + expect(parseGenerationProgress('Writing 3 / 7')).toEqual({ current: 3, total: 7 }); + }); + + it('ignores messages without progress counters', () => { + expect(parseGenerationProgress('Starting document generation...')).toBeNull(); + }); + + it('prefers the AI-extracted project name for generation', () => { + expect( + getProjectNameForGeneration({ + ...initialInlineWizardState, + extractedProjectName: ' HTML Chat Interface ', + sessionName: 'rc', + }) + ).toBe('HTML Chat Interface'); + }); + + it('falls back to the session name and then Project', () => { + expect( + getProjectNameForGeneration({ + ...initialInlineWizardState, + sessionName: 'Feature Session', + }) + ).toBe('Feature Session'); + expect(getProjectNameForGeneration(initialInlineWizardState)).toBe('Project'); + }); +}); diff --git a/src/__tests__/renderer/hooks/batch/inlineWizard/lifecycleActions.test.tsx b/src/__tests__/renderer/hooks/batch/inlineWizard/lifecycleActions.test.tsx new file mode 100644 index 0000000000..2021d803c1 --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/inlineWizard/lifecycleActions.test.tsx @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useInlineWizardLifecycleActions } from '../../../../../renderer/hooks/batch/inlineWizard/lifecycleActions'; +import { useInlineWizardTabState } from '../../../../../renderer/hooks/batch/inlineWizard/useInlineWizardTabState'; +import { endInlineWizardConversation } from '../../../../../renderer/services/inlineWizardConversation'; + +vi.mock('../../../../../renderer/services/inlineWizardConversation', async () => ({ + endInlineWizardConversation: vi.fn().mockResolvedValue(undefined), +})); + +function useLifecycleHarness() { + const tabState = useInlineWizardTabState(); + const lifecycle = useInlineWizardLifecycleActions({ + currentTabId: tabState.currentTabId, + setTabStates: tabState.setTabStates, + previousUIStateRefsMap: tabState.previousUIStateRefsMap, + conversationSessionsMap: tabState.conversationSessionsMap, + }); + + return { + ...tabState, + ...lifecycle, + }; +} + +describe('inline wizard lifecycle actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ends an explicit tab and returns its previous UI state', async () => { + const { result } = renderHook(() => useLifecycleHarness()); + const previousState = { readOnlyMode: true, saveToHistory: false, showThinking: 'on' as const }; + const session = { + sessionId: 'conversation-1', + agentType: 'claude-code' as const, + directoryPath: '/repo', + projectName: 'Repo', + systemPrompt: 'prompt', + isActive: true, + }; + + act(() => { + result.current.setTabState('tab-a', (prev) => ({ ...prev, isActive: true })); + result.current.previousUIStateRefsMap.current.set('tab-a', previousState); + result.current.conversationSessionsMap.current.set('tab-a', session); + }); + + let restored = null as typeof previousState | null; + await act(async () => { + restored = await result.current.endWizard('tab-a'); + }); + + expect(restored).toEqual(previousState); + expect(result.current.getStateForTab('tab-a')).toBeUndefined(); + expect(result.current.conversationSessionsMap.current.has('tab-a')).toBe(false); + expect(endInlineWizardConversation).toHaveBeenCalledWith(session); + }); + + it('resets the current tab without touching another tab', () => { + const { result } = renderHook(() => useLifecycleHarness()); + + act(() => { + result.current.setTabState('tab-a', (prev) => ({ ...prev, isActive: true })); + result.current.setTabState('tab-b', (prev) => ({ ...prev, isActive: true })); + result.current.setCurrentTabId('tab-a'); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.getStateForTab('tab-a')).toBeUndefined(); + expect(result.current.getStateForTab('tab-b')?.isActive).toBe(true); + }); +}); diff --git a/src/__tests__/renderer/hooks/batch/inlineWizard/simpleActions.test.ts b/src/__tests__/renderer/hooks/batch/inlineWizard/simpleActions.test.ts new file mode 100644 index 0000000000..76dbff5a2e --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/inlineWizard/simpleActions.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { clampConfidence } from '../../../../../renderer/hooks/batch/inlineWizard/simpleActions'; + +describe('inline wizard simple action helpers', () => { + it('clamps confidence to the wizard bounds', () => { + expect(clampConfidence(-10)).toBe(0); + expect(clampConfidence(42)).toBe(42); + expect(clampConfidence(150)).toBe(100); + }); +}); diff --git a/src/__tests__/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.test.tsx b/src/__tests__/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.test.tsx new file mode 100644 index 0000000000..536f467400 --- /dev/null +++ b/src/__tests__/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.test.tsx @@ -0,0 +1,88 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useInlineWizardTabState } from '../../../../../renderer/hooks/batch/inlineWizard/useInlineWizardTabState'; + +describe('useInlineWizardTabState', () => { + it('uses default as the effective tab when no current tab is selected', () => { + const { result } = renderHook(() => useInlineWizardTabState()); + + let effectiveTabId = ''; + act(() => { + effectiveTabId = result.current.getEffectiveTabId(); + }); + + expect(effectiveTabId).toBe('default'); + expect(result.current.currentTabId).toBe('default'); + }); + + it('stores independent wizard state per tab', () => { + const { result } = renderHook(() => useInlineWizardTabState()); + + act(() => { + result.current.setTabState('tab-a', (prev) => ({ + ...prev, + isActive: true, + sessionId: 'session-a', + })); + result.current.setTabState('tab-b', (prev) => ({ + ...prev, + isActive: true, + sessionId: 'session-b', + isGeneratingDocs: true, + })); + }); + + expect(result.current.getStateForTab('tab-a')?.sessionId).toBe('session-a'); + expect(result.current.getStateForTab('tab-b')?.isGeneratingDocs).toBe(true); + }); + + it('selects the current wizard tab for backward-compatible state access', () => { + const { result } = renderHook(() => useInlineWizardTabState()); + + act(() => { + result.current.setTabState('tab-a', (prev) => ({ + ...prev, + isActive: true, + goal: 'first', + })); + result.current.setTabState('tab-b', (prev) => ({ + ...prev, + isActive: true, + goal: 'second', + })); + result.current.setCurrentTabId('tab-b'); + }); + + expect(result.current.state.goal).toBe('second'); + expect(result.current.isWizardActiveForTab('tab-a')).toBe(true); + }); + + it('aggregates active sessions and generation state across tabs', () => { + const { result } = renderHook(() => useInlineWizardTabState()); + + act(() => { + result.current.setTabState('tab-a', (prev) => ({ + ...prev, + isActive: true, + sessionId: 'session-1', + isGeneratingDocs: false, + })); + result.current.setTabState('tab-b', (prev) => ({ + ...prev, + isActive: true, + sessionId: 'session-1', + isGeneratingDocs: true, + })); + result.current.setTabState('tab-c', (prev) => ({ + ...prev, + isActive: false, + sessionId: 'session-2', + isGeneratingDocs: true, + })); + }); + + expect(result.current.wizardActiveSessions).toEqual( + new Map([['session-1', { isGeneratingDocs: true }]]) + ); + }); +}); diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView.tsx deleted file mode 100644 index 34546f22a7..0000000000 --- a/src/renderer/components/InlineWizard/DocumentGenerationView.tsx +++ /dev/null @@ -1,625 +0,0 @@ -/** - * DocumentGenerationView.tsx - * - * The main takeover component for document generation in the inline wizard. - * Takes over the AI terminal area (not a modal) when confidence reaches threshold - * and user proceeds. Displays: - * - Document selector dropdown at top - * - Main content area showing streaming preview or final document - * - Austin facts rotating in corner during generation - * - Completion overlay with confetti when generation finishes - * - * This component is extracted/shared with PhaseReviewScreen.tsx to maintain consistency. - */ - -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { ChevronDown, ChevronRight, FileText, Check } from 'lucide-react'; -import type { Theme } from '../../types'; -import type { GeneratedDocument } from '../Wizard/WizardContext'; -import { AustinFactsDisplay } from './AustinFactsDisplay'; -import { formatSize, formatElapsedTime } from '../../../shared/formatters'; -import { DocumentEditor as SharedDocumentEditor } from '../Wizard/shared/DocumentEditor'; -import { DocumentSelector as SharedDocumentSelector } from '../Wizard/shared/DocumentSelector'; - -/** - * Props for DocumentGenerationView - */ -export interface DocumentGenerationViewProps { - /** Theme for styling */ - theme: Theme; - /** Array of generated documents */ - documents: GeneratedDocument[]; - /** Index of the currently selected document */ - currentDocumentIndex: number; - /** Whether documents are still being generated */ - isGenerating: boolean; - /** Streaming content being generated (shown during generation) */ - streamingContent?: string; - /** Called when generation completes and user clicks Done */ - onComplete: () => void; - /** Called when user wants to complete the wizard AND immediately start the Batch Runner for the generated docs */ - onCompleteAndStartAutoRun?: () => void; - /** Called when user selects a different document */ - onDocumentSelect: (index: number) => void; - /** Folder path for Auto Run docs */ - folderPath?: string; - /** Called when document content changes (for editing) */ - onContentChange?: (content: string, docIndex: number) => void; - /** Progress message to show during generation */ - progressMessage?: string; - /** Current document being generated (for progress indicator) */ - currentGeneratingIndex?: number; - /** Total number of documents to generate (for progress indicator) */ - totalDocuments?: number; - /** Called when user wants to cancel generation */ - onCancel?: () => void; - /** Subfolder name where documents are saved (for completion message) */ - subfolderName?: string; - /** Wall-clock timestamp (ms) when generation started; used so elapsed time survives unmount/remount when switching tabs */ - startedAt?: number; -} - -/** - * Document selector dropdown for switching between generated documents. - * Kept as an inline wizard export while delegating behavior to the shared wizard selector. - */ -export function DocumentSelector({ - documents, - selectedIndex, - onSelect, - theme, - disabled, -}: { - documents: GeneratedDocument[]; - selectedIndex: number; - onSelect: (index: number) => void; - theme: Theme; - disabled?: boolean; -}): JSX.Element { - return ( - - ); -} - -/** - * Document editor component with edit/preview modes. - * The inline wizard keeps this export stable and reuses the shared editor internals. - */ -export function DocumentEditor({ - content, - onContentChange, - mode, - onModeChange, - folderPath, - selectedFile, - attachments, - onAddAttachment, - onRemoveAttachment, - theme, - isLocked, - textareaRef, - previewRef, -}: { - content: string; - onContentChange: (content: string) => void; - mode: 'edit' | 'preview'; - onModeChange: (mode: 'edit' | 'preview') => void; - folderPath?: string; - selectedFile?: string; - attachments: Array<{ filename: string; dataUrl: string }>; - onAddAttachment: (filename: string, dataUrl: string) => void; - onRemoveAttachment: (filename: string) => void; - theme: Theme; - isLocked: boolean; - textareaRef: React.RefObject; - previewRef: React.RefObject; -}): JSX.Element { - return ( - {}} - statsText="" - proseClassPrefix="doc-gen-view" - showHeader={false} - /> - ); -} - -/** - * Count tasks in markdown content - */ -function countTasks(content: string): number { - const matches = content.match(/^- \[([ x])\]/gm); - return matches ? matches.length : 0; -} - -/** - * Individual file entry in the created files list - */ -function CreatedFileEntry({ - doc, - isExpanded, - isNewest, - theme, - onToggle, -}: { - doc: GeneratedDocument; - isExpanded: boolean; - isNewest: boolean; - theme: Theme; - onToggle: () => void; -}): JSX.Element { - const taskCount = countTasks(doc.content); - const fileSize = new Blob([doc.content]).size; - - // Extract first paragraph as description - const description = useMemo(() => { - const lines = doc.content.split('\n'); - for (const line of lines) { - const trimmed = line.trim(); - // Skip headers and empty lines - if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue; - // Return first paragraph, truncated - return trimmed.length > 150 ? trimmed.slice(0, 147) + '...' : trimmed; - } - return null; - }, [doc.content]); - - return ( -
- {/* Header row - clickable to expand/collapse */} - - - {/* Description - shown when expanded */} -
- {description && ( -
- {description} -
- )} -
-
- ); -} - -/** - * List of created files during generation - */ -function CreatedFilesList({ - documents, - theme, -}: { - documents: GeneratedDocument[]; - theme: Theme; -}): JSX.Element | null { - const [expandedFiles, setExpandedFiles] = useState>(new Set()); - const userToggledFilesRef = useRef>(new Set()); - const lastAutoExpandedRef = useRef(null); - - // Auto-expand newest file when it's added - const prevFilesCountRef = useRef(documents.length); - useEffect(() => { - if (documents.length > prevFilesCountRef.current && documents.length > 0) { - const newestFile = documents[documents.length - 1]; - - setExpandedFiles((prev) => { - const next = new Set(prev); - - // Collapse the previous auto-expanded file (only if user hasn't touched it) - if ( - lastAutoExpandedRef.current && - !userToggledFilesRef.current.has(lastAutoExpandedRef.current) - ) { - next.delete(lastAutoExpandedRef.current); - } - - // Expand the new file - next.add(newestFile.filename); - return next; - }); - - lastAutoExpandedRef.current = newestFile.filename; - } - prevFilesCountRef.current = documents.length; - }, [documents]); - - const toggleFile = useCallback((filename: string) => { - userToggledFilesRef.current.add(filename); - setExpandedFiles((prev) => { - const next = new Set(prev); - if (next.has(filename)) { - next.delete(filename); - } else { - next.add(filename); - } - return next; - }); - }, []); - - if (documents.length === 0) return null; - - const newestIndex = documents.length - 1; - - return ( -
-
- - - Work Plans Drafted ({documents.length}) - -
-
- {documents.map((doc, index) => ( -
- toggleFile(doc.filename)} - /> -
- ))} -
- - {/* Animation styles */} - -
- ); -} - -/** - * DocumentGenerationView - Main component for document generation takeover - */ -export function DocumentGenerationView({ - theme, - documents, - currentDocumentIndex: _currentDocumentIndex, - isGenerating, - streamingContent: _streamingContent, - onComplete, - onCompleteAndStartAutoRun, - onDocumentSelect: _onDocumentSelect, - folderPath: _folderPath, - onContentChange: _onContentChange, - progressMessage: _progressMessage, - currentGeneratingIndex: _currentGeneratingIndex, - totalDocuments: _totalDocuments, - onCancel, - subfolderName, - startedAt, -}: DocumentGenerationViewProps): JSX.Element { - // Calculate total tasks - const totalTasks = documents.reduce((sum, doc) => sum + countTasks(doc.content), 0); - - // Persisted start timestamp survives tab switches; fall back to a local - // instant so the counter still works if the caller doesn't pass one. - const fallbackStartRef = useRef(Date.now()); - const startTime = startedAt ?? fallbackStartRef.current; - const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startTime)); - - useEffect(() => { - if (!isGenerating) return; - - setElapsedMs(Math.max(0, Date.now() - startTime)); - - const interval = setInterval(() => { - setElapsedMs(Math.max(0, Date.now() - startTime)); - }, 1000); - - return () => clearInterval(interval); - }, [isGenerating, startTime]); - - // Determine if generation is complete - const isComplete = !isGenerating && documents.length > 0; - - // Fallback - no documents and not generating - if (!isGenerating && documents.length === 0) { - return ( -
-

No documents generated yet.

- {onCancel && ( - - )} -
- ); - } - - // Main view - same layout for generating and complete states - // Only difference: Austin Facts vs completion button at bottom - return ( -
- {/* Main content - centered vertically */} -
- {/* Header: Spinner when generating, Checkmark when complete */} - {isComplete ? ( -
- -
- ) : ( -
-
- {/* Inner pulsing circle */} -
-
-
-
- )} - - {/* Title */} -

- {isComplete ? 'Documentation generation complete.' : 'Generating Auto Run Documents...'} -

- - {/* Subtitle: location message when complete, elapsed time during generation */} - {isComplete ? ( -

- Available under{' '} - - {subfolderName || '.maestro/playbooks'}/ - -

- ) : ( - <> -

- This may take a while. We're creating detailed task documents based on your project - requirements. -

- {elapsedMs > 0 && ( -

- Elapsed: {formatElapsedTime(elapsedMs)} -

- )} - - )} - - {/* Total task count */} - {totalTasks > 0 ? ( -
- - {totalTasks} - - - {totalTasks === 1 ? 'Task' : 'Tasks'} Planned - -
- ) : !isComplete ? ( -
- {[0, 1, 2].map((i) => ( -
- ))} -
- ) : null} - - {/* Created files list */} - - - {/* Bottom section: Austin Facts during generation, action buttons when done */} - {isComplete ? ( -
- - {onCompleteAndStartAutoRun && documents.length > 0 && ( - - )} -
- ) : ( - <> - {/* Cancel button */} - {onCancel && ( - - )} - - {/* Austin Facts - shown during generation */} -
- -
- - )} -
- - {/* Animation styles */} - -
- ); -} - -// Re-export standalone components from their files -export { AustinFactsDisplay } from './AustinFactsDisplay'; -export { StreamingDocumentPreview } from './StreamingDocumentPreview'; -export { GenerationCompleteOverlay } from './GenerationCompleteOverlay'; diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.tsx new file mode 100644 index 0000000000..18561ab6a5 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/DocumentGenerationView.tsx @@ -0,0 +1,108 @@ +import { AustinFactsDisplay } from '../AustinFactsDisplay'; +import { + CreatedFilesList, + EmptyGenerationState, + GenerationActions, + GenerationStatus, +} from './components'; +import { useElapsedGenerationTime } from './hooks/useElapsedGenerationTime'; +import type { DocumentGenerationViewProps } from './types'; +import { countTotalTasks } from './utils/documentStats'; + +export function DocumentGenerationView({ + theme, + documents, + currentDocumentIndex: _currentDocumentIndex, + isGenerating, + streamingContent: _streamingContent, + onComplete, + onCompleteAndStartAutoRun, + onDocumentSelect: _onDocumentSelect, + folderPath: _folderPath, + onContentChange: _onContentChange, + progressMessage: _progressMessage, + currentGeneratingIndex: _currentGeneratingIndex, + totalDocuments: _totalDocuments, + onCancel, + subfolderName, + startedAt, +}: DocumentGenerationViewProps): JSX.Element { + const totalTasks = countTotalTasks(documents); + const elapsedMs = useElapsedGenerationTime(isGenerating, startedAt); + const isComplete = !isGenerating && documents.length > 0; + + if (!isGenerating && documents.length === 0) { + return ; + } + + return ( +
+
+ + + + + {isComplete ? ( + + ) : ( + <> + {onCancel && ( + + )} + +
+ +
+ + )} +
+ + +
+ ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFileEntry.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFileEntry.tsx new file mode 100644 index 0000000000..4db5299880 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFileEntry.tsx @@ -0,0 +1,98 @@ +import { useMemo } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import type { Theme } from '../../../../types'; +import type { GeneratedDocument } from '../../../Wizard/WizardContext'; +import { formatSize } from '../../../../../shared/formatters'; +import { countTasks, extractDocumentDescription } from '../utils/documentStats'; + +interface CreatedFileEntryProps { + doc: GeneratedDocument; + isExpanded: boolean; + isNewest: boolean; + theme: Theme; + onToggle: () => void; +} + +export function CreatedFileEntry({ + doc, + isExpanded, + isNewest, + theme, + onToggle, +}: CreatedFileEntryProps): JSX.Element { + const taskCount = countTasks(doc.content); + const fileSize = new Blob([doc.content]).size; + const description = useMemo(() => extractDocumentDescription(doc.content), [doc.content]); + + return ( +
+ + +
+ {description && ( +
+ {description} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFilesList.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFilesList.tsx new file mode 100644 index 0000000000..1db7801c39 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/CreatedFilesList.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FileText } from 'lucide-react'; +import type { Theme } from '../../../../types'; +import type { GeneratedDocument } from '../../../Wizard/WizardContext'; +import { CreatedFileEntry } from './CreatedFileEntry'; + +interface CreatedFilesListProps { + documents: GeneratedDocument[]; + theme: Theme; +} + +export function CreatedFilesList({ documents, theme }: CreatedFilesListProps): JSX.Element | null { + const [expandedFiles, setExpandedFiles] = useState>(new Set()); + const userToggledFilesRef = useRef>(new Set()); + const lastAutoExpandedRef = useRef(null); + + const prevFilesCountRef = useRef(documents.length); + useEffect(() => { + if (documents.length > prevFilesCountRef.current && documents.length > 0) { + const newestFile = documents[documents.length - 1]; + + setExpandedFiles((prev) => { + const next = new Set(prev); + + if ( + lastAutoExpandedRef.current && + !userToggledFilesRef.current.has(lastAutoExpandedRef.current) + ) { + next.delete(lastAutoExpandedRef.current); + } + + next.add(newestFile.filename); + return next; + }); + + lastAutoExpandedRef.current = newestFile.filename; + } + prevFilesCountRef.current = documents.length; + }, [documents]); + + const toggleFile = useCallback((filename: string) => { + userToggledFilesRef.current.add(filename); + setExpandedFiles((prev) => { + const next = new Set(prev); + if (next.has(filename)) { + next.delete(filename); + } else { + next.add(filename); + } + return next; + }); + }, []); + + if (documents.length === 0) return null; + + const newestIndex = documents.length - 1; + + return ( +
+
+ + + Work Plans Drafted ({documents.length}) + +
+
+ {documents.map((doc, index) => ( +
+ toggleFile(doc.filename)} + /> +
+ ))} +
+
+ ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentEditor.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentEditor.tsx new file mode 100644 index 0000000000..c6de4e8b49 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentEditor.tsx @@ -0,0 +1,42 @@ +import { DocumentEditor as SharedDocumentEditor } from '../../../Wizard/shared/DocumentEditor'; +import type { DocumentEditorProps } from '../types'; + +export function DocumentEditor({ + content, + onContentChange, + mode, + onModeChange, + folderPath, + selectedFile, + attachments, + onAddAttachment, + onRemoveAttachment, + theme, + isLocked, + textareaRef, + previewRef, +}: DocumentEditorProps): JSX.Element { + return ( + {}} + statsText="" + proseClassPrefix="doc-gen-view" + showHeader={false} + /> + ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentSelector.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentSelector.tsx new file mode 100644 index 0000000000..01b77c4e6c --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/DocumentSelector.tsx @@ -0,0 +1,22 @@ +import { DocumentSelector as SharedDocumentSelector } from '../../../Wizard/shared/DocumentSelector'; +import type { DocumentSelectorProps } from '../types'; + +export function DocumentSelector({ + documents, + selectedIndex, + onSelect, + theme, + disabled, +}: DocumentSelectorProps): JSX.Element { + return ( + + ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/EmptyGenerationState.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/EmptyGenerationState.tsx new file mode 100644 index 0000000000..110a90103f --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/EmptyGenerationState.tsx @@ -0,0 +1,30 @@ +import type { Theme } from '../../../../types'; + +interface EmptyGenerationStateProps { + theme: Theme; + onCancel?: () => void; +} + +export function EmptyGenerationState({ theme, onCancel }: EmptyGenerationStateProps): JSX.Element { + return ( +
+

No documents generated yet.

+ {onCancel && ( + + )} +
+ ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationActions.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationActions.tsx new file mode 100644 index 0000000000..485c10dd85 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationActions.tsx @@ -0,0 +1,45 @@ +import type { Theme } from '../../../../types'; + +interface GenerationActionsProps { + theme: Theme; + documentsLength: number; + onComplete: () => void; + onCompleteAndStartAutoRun?: () => void; +} + +export function GenerationActions({ + theme, + documentsLength, + onComplete, + onCompleteAndStartAutoRun, +}: GenerationActionsProps): JSX.Element { + return ( +
+ + {onCompleteAndStartAutoRun && documentsLength > 0 && ( + + )} +
+ ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationStatus.tsx b/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationStatus.tsx new file mode 100644 index 0000000000..537d50159a --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/GenerationStatus.tsx @@ -0,0 +1,100 @@ +import { Check } from 'lucide-react'; +import type { Theme } from '../../../../types'; +import { formatElapsedTime } from '../../../../../shared/formatters'; + +interface GenerationStatusProps { + theme: Theme; + isComplete: boolean; + totalTasks: number; + elapsedMs: number; + subfolderName?: string; +} + +export function GenerationStatus({ + theme, + isComplete, + totalTasks, + elapsedMs, + subfolderName, +}: GenerationStatusProps): JSX.Element { + return ( + <> + {isComplete ? ( +
+ +
+ ) : ( +
+
+
+
+
+
+ )} + +

+ {isComplete ? 'Documentation generation complete.' : 'Generating Auto Run Documents...'} +

+ + {isComplete ? ( +

+ Available under{' '} + + {subfolderName || '.maestro/playbooks'}/ + +

+ ) : ( + <> +

+ This may take a while. We're creating detailed task documents based on your project + requirements. +

+ {elapsedMs > 0 && ( +

+ Elapsed: {formatElapsedTime(elapsedMs)} +

+ )} + + )} + + {totalTasks > 0 ? ( +
+ + {totalTasks} + + + {totalTasks === 1 ? 'Task' : 'Tasks'} Planned + +
+ ) : !isComplete ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : null} + + ); +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/components/index.ts b/src/renderer/components/InlineWizard/DocumentGenerationView/components/index.ts new file mode 100644 index 0000000000..365dd64927 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/components/index.ts @@ -0,0 +1,7 @@ +export { CreatedFileEntry } from './CreatedFileEntry'; +export { CreatedFilesList } from './CreatedFilesList'; +export { DocumentEditor } from './DocumentEditor'; +export { DocumentSelector } from './DocumentSelector'; +export { EmptyGenerationState } from './EmptyGenerationState'; +export { GenerationActions } from './GenerationActions'; +export { GenerationStatus } from './GenerationStatus'; diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/hooks/useElapsedGenerationTime.ts b/src/renderer/components/InlineWizard/DocumentGenerationView/hooks/useElapsedGenerationTime.ts new file mode 100644 index 0000000000..edbd110c60 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/hooks/useElapsedGenerationTime.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useElapsedGenerationTime(isGenerating: boolean, startedAt?: number): number { + const fallbackStartRef = useRef(Date.now()); + const startTime = startedAt ?? fallbackStartRef.current; + const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startTime)); + + useEffect(() => { + const updateElapsedMs = () => { + setElapsedMs(Math.max(0, Date.now() - startTime)); + }; + + if (!isGenerating) { + updateElapsedMs(); + return; + } + + updateElapsedMs(); + + const interval = setInterval(() => { + updateElapsedMs(); + }, 1000); + + return () => clearInterval(interval); + }, [isGenerating, startTime]); + + return elapsedMs; +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/index.ts b/src/renderer/components/InlineWizard/DocumentGenerationView/index.ts new file mode 100644 index 0000000000..421a9803dd --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/index.ts @@ -0,0 +1,10 @@ +export { DocumentGenerationView } from './DocumentGenerationView'; +export type { + DocumentEditorProps, + DocumentGenerationViewProps, + DocumentSelectorProps, +} from './types'; +export { DocumentEditor, DocumentSelector } from './components'; +export { AustinFactsDisplay } from '../AustinFactsDisplay'; +export { StreamingDocumentPreview } from '../StreamingDocumentPreview'; +export { GenerationCompleteOverlay } from '../GenerationCompleteOverlay'; diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/types.ts b/src/renderer/components/InlineWizard/DocumentGenerationView/types.ts new file mode 100644 index 0000000000..72004ff516 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/types.ts @@ -0,0 +1,62 @@ +import type { RefObject } from 'react'; +import type { Theme } from '../../../types'; +import type { GeneratedDocument } from '../../Wizard/WizardContext'; + +export interface DocumentGenerationViewProps { + /** Theme for styling */ + theme: Theme; + /** Array of generated documents */ + documents: GeneratedDocument[]; + /** Index of the currently selected document */ + currentDocumentIndex: number; + /** Whether documents are still being generated */ + isGenerating: boolean; + /** Streaming content being generated (shown during generation) */ + streamingContent?: string; + /** Called when generation completes and user clicks Done */ + onComplete: () => void; + /** Called when user wants to complete the wizard AND immediately start the Batch Runner for the generated docs */ + onCompleteAndStartAutoRun?: () => void; + /** Called when user selects a different document */ + onDocumentSelect: (index: number) => void; + /** Folder path for Auto Run docs */ + folderPath?: string; + /** Called when document content changes (for editing) */ + onContentChange?: (content: string, docIndex: number) => void; + /** Progress message to show during generation */ + progressMessage?: string; + /** Current document being generated (for progress indicator) */ + currentGeneratingIndex?: number; + /** Total number of documents to generate (for progress indicator) */ + totalDocuments?: number; + /** Called when user wants to cancel generation */ + onCancel?: () => void; + /** Subfolder name where documents are saved (for completion message) */ + subfolderName?: string; + /** Wall-clock timestamp (ms) when generation started; used so elapsed time survives unmount/remount when switching tabs */ + startedAt?: number; +} + +export interface DocumentSelectorProps { + documents: GeneratedDocument[]; + selectedIndex: number; + onSelect: (index: number) => void; + theme: Theme; + disabled?: boolean; +} + +export interface DocumentEditorProps { + content: string; + onContentChange: (content: string) => void; + mode: 'edit' | 'preview'; + onModeChange: (mode: 'edit' | 'preview') => void; + folderPath?: string; + selectedFile?: string; + attachments: Array<{ filename: string; dataUrl: string }>; + onAddAttachment: (filename: string, dataUrl: string) => void; + onRemoveAttachment: (filename: string) => void; + theme: Theme; + isLocked: boolean; + textareaRef: RefObject; + previewRef: RefObject; +} diff --git a/src/renderer/components/InlineWizard/DocumentGenerationView/utils/documentStats.ts b/src/renderer/components/InlineWizard/DocumentGenerationView/utils/documentStats.ts new file mode 100644 index 0000000000..6f0311fb98 --- /dev/null +++ b/src/renderer/components/InlineWizard/DocumentGenerationView/utils/documentStats.ts @@ -0,0 +1,20 @@ +import type { GeneratedDocument } from '../../../Wizard/WizardContext'; + +export function countTasks(content: string): number { + const matches = content.match(/^- \[([ x])\]/gm); + return matches ? matches.length : 0; +} + +export function countTotalTasks(documents: GeneratedDocument[]): number { + return documents.reduce((sum, doc) => sum + countTasks(doc.content), 0); +} + +export function extractDocumentDescription(content: string): string | null { + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue; + return trimmed.length > 150 ? trimmed.slice(0, 147) + '...' : trimmed; + } + return null; +} diff --git a/src/renderer/hooks/batch/inlineWizard/conversationActions.ts b/src/renderer/hooks/batch/inlineWizard/conversationActions.ts new file mode 100644 index 0000000000..c6e6c2d0dd --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/conversationActions.ts @@ -0,0 +1,490 @@ +import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { parseWizardIntent } from '../../../services/wizardIntentParser'; +import { + startInlineWizardConversation, + sendWizardMessage, + type ConversationCallbacks, + type ExistingDocumentWithContent, + type InlineWizardConversationSession, +} from '../../../services/inlineWizardConversation'; +import { logger } from '../../../utils/logger'; +import { hasCapabilityCached } from '../../agent/useAgentCapabilities'; +import { + fetchHistoryFilePath, + hasExistingDocuments, + listExistingDocuments, + loadDocumentContents, + resolveAutoRunFolderPath, +} from './documents'; +import { generateMessageId, initialInlineWizardState } from './state'; +import type { + InlineWizardMode, + InlineWizardSessionOverrides, + InlineWizardSshRemoteConfig, + InlineWizardState, + PreviousUIState, + SetInlineWizardTabState, +} from './types'; +import type { ToolType } from '../../../types'; +import type { ExistingDocument } from '../../../utils/existingDocsDetector'; + +interface UseInlineWizardConversationActionsParams { + currentTabId: string | null; + setCurrentTabId: Dispatch>; + tabStatesRef: MutableRefObject>; + previousUIStateRefsMap: MutableRefObject>; + conversationSessionsMap: MutableRefObject>; + setTabState: SetInlineWizardTabState; + getEffectiveTabId: () => string; +} + +export function useInlineWizardConversationActions({ + currentTabId, + setCurrentTabId, + tabStatesRef, + previousUIStateRefsMap, + conversationSessionsMap, + setTabState, + getEffectiveTabId, +}: UseInlineWizardConversationActionsParams) { + const startWizard = useCallback( + async ( + naturalLanguageInput?: string, + currentUIState?: PreviousUIState, + projectPath?: string, + agentType?: ToolType, + sessionName?: string, + tabId?: string, + sessionId?: string, + configuredAutoRunFolderPath?: string, + sessionSshRemoteConfig?: InlineWizardSshRemoteConfig, + conductorProfile?: string, + sessionOverrides?: InlineWizardSessionOverrides + ): Promise => { + const effectiveTabId = tabId || 'default'; + const effectiveAutoRunFolderPath = resolveAutoRunFolderPath( + projectPath, + configuredAutoRunFolderPath + ); + + logger.info(`Starting inline wizard on tab ${effectiveTabId}`, '[InlineWizard]', { + projectPath, + agentType, + sessionName, + hasInput: !!naturalLanguageInput, + autoRunFolderPath: effectiveAutoRunFolderPath, + }); + + if (currentUIState) { + previousUIStateRefsMap.current.set(effectiveTabId, currentUIState); + } + + setCurrentTabId(effectiveTabId); + + setTabState(effectiveTabId, () => ({ + ...initialInlineWizardState, + isActive: true, + isInitializing: true, + isWaiting: false, + mode: null, + goal: null, + confidence: 0, + ready: false, + conversationHistory: [], + isGeneratingDocs: false, + generatedDocuments: [], + existingDocuments: [], + previousUIState: currentUIState || null, + error: null, + projectPath: projectPath || null, + agentType: agentType || null, + sessionName: sessionName || null, + tabId: effectiveTabId, + sessionId: sessionId || null, + streamingContent: '', + generationProgress: null, + currentDocumentIndex: 0, + lastUserMessageContent: null, + agentSessionId: null, + subfolderName: null, + subfolderPath: null, + autoRunFolderPath: effectiveAutoRunFolderPath, + sessionSshRemoteConfig, + sessionCustomPath: sessionOverrides?.customPath, + sessionCustomArgs: sessionOverrides?.customArgs, + sessionCustomEnvVars: sessionOverrides?.customEnvVars, + sessionCustomModel: sessionOverrides?.customModel, + conductorProfile, + })); + + try { + const historyFilePath = await fetchHistoryFilePath(sessionId, sessionSshRemoteConfig); + const hasExistingDocs = await hasExistingDocuments(effectiveAutoRunFolderPath); + + let mode: InlineWizardMode; + let goal: string | null = null; + let existingDocs: ExistingDocument[] = []; + + const trimmedInput = naturalLanguageInput?.trim() || ''; + + if (!trimmedInput) { + mode = hasExistingDocs ? 'ask' : 'new'; + } else { + const intentResult = parseWizardIntent(trimmedInput, hasExistingDocs); + mode = intentResult.mode; + goal = intentResult.goal || null; + } + + let docsWithContent: ExistingDocumentWithContent[] = []; + if (mode === 'iterate' && effectiveAutoRunFolderPath) { + existingDocs = await listExistingDocuments(effectiveAutoRunFolderPath); + docsWithContent = await loadDocumentContents(existingDocs, effectiveAutoRunFolderPath); + } + + if ( + (mode === 'new' || mode === 'iterate') && + agentType && + hasCapabilityCached(agentType, 'supportsWizard') && + effectiveAutoRunFolderPath + ) { + const session = startInlineWizardConversation({ + mode, + agentType, + directoryPath: projectPath || effectiveAutoRunFolderPath, + projectName: sessionName || 'Project', + goal: goal || undefined, + existingDocs: docsWithContent.length > 0 ? docsWithContent : undefined, + autoRunFolderPath: effectiveAutoRunFolderPath, + sessionSshRemoteConfig, + sessionCustomPath: sessionOverrides?.customPath, + sessionCustomArgs: sessionOverrides?.customArgs, + sessionCustomEnvVars: sessionOverrides?.customEnvVars, + sessionCustomModel: sessionOverrides?.customModel, + conductorProfile, + historyFilePath, + }); + + conversationSessionsMap.current.set(effectiveTabId, session); + + logger.info(`Wizard conversation started (mode: ${mode})`, '[InlineWizard]', { + sessionId: session.sessionId, + tabId: effectiveTabId, + mode, + goal: goal || null, + existingDocsCount: docsWithContent.length, + autoRunFolderPath: effectiveAutoRunFolderPath, + }); + } else if ( + (mode === 'new' || mode === 'iterate') && + agentType && + !hasCapabilityCached(agentType, 'supportsWizard') + ) { + logger.warn(`Wizard not supported for agent type: ${agentType}`, '[InlineWizard]'); + setTabState(effectiveTabId, (prev) => ({ + ...prev, + isInitializing: false, + error: `The inline wizard is not supported for this agent type.`, + })); + return; + } + + setTabState(effectiveTabId, (prev) => ({ + ...prev, + isInitializing: false, + mode, + goal, + existingDocuments: existingDocs, + historyFilePath, + })); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to initialize wizard'; + logger.error('[useInlineWizard] startWizard error:', undefined, error); + + setTabState(effectiveTabId, (prev) => ({ + ...prev, + isInitializing: false, + mode: 'new', + error: errorMessage, + })); + } + }, + [conversationSessionsMap, previousUIStateRefsMap, setCurrentTabId, setTabState] + ); + + const sendMessage = useCallback( + async ( + content: string, + images?: string[], + callbacks?: ConversationCallbacks, + explicitTabId?: string + ): Promise => { + const tabId = explicitTabId || currentTabId || 'default'; + if (tabId !== currentTabId) { + setCurrentTabId(tabId); + } + + const currentState = tabStatesRef.current.get(tabId); + if (currentState?.isWaiting) { + logger.warn('[useInlineWizard] Already waiting for response, ignoring duplicate send'); + return; + } + + const userMessage = { + id: generateMessageId(), + role: 'user' as const, + content, + timestamp: Date.now(), + ...(images && images.length > 0 ? { images } : {}), + }; + + setTabState(tabId, (prev) => ({ + ...prev, + conversationHistory: [...prev.conversationHistory, userMessage], + lastUserMessageContent: content, + isWaiting: true, + error: null, + })); + + let session = conversationSessionsMap.current.get(tabId); + if (!session) { + const currentState = tabStatesRef.current.get(tabId); + const effectiveAutoRunFolderPath = resolveAutoRunFolderPath( + currentState?.projectPath || undefined, + currentState?.autoRunFolderPath || undefined + ); + + if ( + currentState?.mode === 'ask' && + currentState.agentType && + hasCapabilityCached(currentState.agentType, 'supportsWizard') && + effectiveAutoRunFolderPath + ) { + logger.info('[useInlineWizard] Auto-creating session for direct message in ask mode'); + session = startInlineWizardConversation({ + mode: 'new', + agentType: currentState.agentType, + directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, + projectName: currentState.sessionName || 'Project', + goal: currentState.goal || undefined, + existingDocs: undefined, + autoRunFolderPath: effectiveAutoRunFolderPath, + sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, + sessionCustomPath: currentState.sessionCustomPath, + sessionCustomArgs: currentState.sessionCustomArgs, + sessionCustomEnvVars: currentState.sessionCustomEnvVars, + sessionCustomModel: currentState.sessionCustomModel, + conductorProfile: currentState.conductorProfile, + historyFilePath: currentState.historyFilePath, + }); + conversationSessionsMap.current.set(tabId, session); + setTabState(tabId, (prev) => ({ ...prev, mode: 'new' })); + logger.info('[useInlineWizard] Session created:', undefined, session.sessionId); + } else { + logger.error( + '[useInlineWizard] No active conversation session, currentState:', + undefined, + { + mode: currentState?.mode, + agentType: currentState?.agentType, + projectPath: currentState?.projectPath, + autoRunFolderPath: currentState?.autoRunFolderPath, + } + ); + setTabState(tabId, (prev) => ({ + ...prev, + isWaiting: false, + error: 'No active conversation session. Please restart the wizard.', + })); + callbacks?.onError?.('No active conversation session'); + return; + } + } + + try { + const currentState = tabStatesRef.current.get(tabId); + const currentHistory = currentState?.conversationHistory || []; + + const result = await sendWizardMessage(session, content, currentHistory, callbacks); + + if (result.success && result.response) { + const assistantMessage = { + id: generateMessageId(), + role: 'assistant' as const, + content: result.response.message, + timestamp: Date.now(), + confidence: result.response.confidence, + ready: result.response.ready, + }; + + const incomingProjectName = result.response.projectName?.trim(); + setTabState(tabId, (prev) => ({ + ...prev, + conversationHistory: [...prev.conversationHistory, assistantMessage], + confidence: result.response!.confidence, + ready: result.response!.ready, + extractedProjectName: incomingProjectName || prev.extractedProjectName, + isWaiting: false, + agentSessionId: prev.agentSessionId || result.agentSessionId || null, + })); + + logger.info( + `Wizard response received - confidence: ${result.response.confidence}%, ready: ${result.response.ready}`, + '[InlineWizard]', + { + confidence: result.response.confidence, + ready: result.response.ready, + agentSessionId: result.agentSessionId || null, + } + ); + } else { + const errorMessage = result.error || 'Failed to get response from AI'; + logger.error('[useInlineWizard] sendWizardMessage error:', undefined, errorMessage); + + setTabState(tabId, (prev) => ({ + ...prev, + isWaiting: false, + error: errorMessage, + })); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + logger.error('[useInlineWizard] sendMessage error:', undefined, error); + + setTabState(tabId, (prev) => ({ + ...prev, + isWaiting: false, + error: errorMessage, + })); + + callbacks?.onError?.(errorMessage); + } + }, + [conversationSessionsMap, currentTabId, setCurrentTabId, setTabState, tabStatesRef] + ); + + const addAssistantMessage = useCallback( + (content: string, confidence?: number, ready?: boolean) => { + const tabId = currentTabId || 'default'; + if (tabId !== currentTabId) { + setCurrentTabId(tabId); + } + const message = { + id: generateMessageId(), + role: 'assistant' as const, + content, + timestamp: Date.now(), + confidence, + ready, + }; + + setTabState(tabId, (prev) => ({ + ...prev, + conversationHistory: [...prev.conversationHistory, message], + confidence: confidence !== undefined ? confidence : prev.confidence, + ready: ready !== undefined ? ready : prev.ready, + })); + }, + [currentTabId, setCurrentTabId, setTabState] + ); + + const setMode = useCallback( + (newMode: InlineWizardMode) => { + const tabId = getEffectiveTabId(); + const currentState = tabStatesRef.current.get(tabId); + + if ( + currentState?.mode === 'ask' && + (newMode === 'new' || newMode === 'iterate') && + !conversationSessionsMap.current.has(tabId) + ) { + const effectiveAutoRunFolderPath = resolveAutoRunFolderPath( + currentState.projectPath || undefined, + currentState.autoRunFolderPath || undefined + ); + + if ( + currentState.agentType && + hasCapabilityCached(currentState.agentType, 'supportsWizard') && + effectiveAutoRunFolderPath + ) { + const session = startInlineWizardConversation({ + mode: newMode, + agentType: currentState.agentType, + directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, + projectName: currentState.sessionName || 'Project', + goal: currentState.goal || undefined, + existingDocs: undefined, + autoRunFolderPath: effectiveAutoRunFolderPath, + sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, + sessionCustomPath: currentState.sessionCustomPath, + sessionCustomArgs: currentState.sessionCustomArgs, + sessionCustomEnvVars: currentState.sessionCustomEnvVars, + sessionCustomModel: currentState.sessionCustomModel, + conductorProfile: currentState.conductorProfile, + historyFilePath: currentState.historyFilePath, + }); + + conversationSessionsMap.current.set(tabId, session); + logger.info( + '[useInlineWizard] Conversation session started after mode selection:', + undefined, + session.sessionId + ); + } + } + + setTabState(tabId, (prev) => ({ + ...prev, + mode: newMode, + })); + }, + [conversationSessionsMap, getEffectiveTabId, setTabState, tabStatesRef] + ); + + const retryLastMessage = useCallback( + async (callbacks?: ConversationCallbacks): Promise => { + const tabId = currentTabId || 'default'; + const currentState = tabStatesRef.current.get(tabId); + const lastContent = currentState?.lastUserMessageContent; + + if (!lastContent || !currentState?.error) { + logger.warn('[useInlineWizard] Cannot retry: no last message or no error'); + return; + } + + const historyWithoutLastUser = [...(currentState.conversationHistory || [])]; + for (let i = historyWithoutLastUser.length - 1; i >= 0; i--) { + if (historyWithoutLastUser[i].role === 'user') { + historyWithoutLastUser.splice(i, 1); + break; + } + } + + setTabState(tabId, (prev) => ({ + ...prev, + conversationHistory: historyWithoutLastUser, + error: null, + })); + + await sendMessage(lastContent, undefined, callbacks); + }, + [currentTabId, sendMessage, setTabState, tabStatesRef] + ); + + const clearConversation = useCallback(() => { + const tabId = currentTabId || 'default'; + setTabState(tabId, (prev) => ({ + ...prev, + conversationHistory: [], + })); + }, [currentTabId, setTabState]); + + return { + startWizard, + sendMessage, + addAssistantMessage, + setMode, + retryLastMessage, + clearConversation, + }; +} diff --git a/src/renderer/hooks/batch/inlineWizard/documents.ts b/src/renderer/hooks/batch/inlineWizard/documents.ts new file mode 100644 index 0000000000..a3a2e0d5ef --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/documents.ts @@ -0,0 +1,92 @@ +import { logger } from '../../../utils/logger'; +import { getAutoRunFolderPath, type ExistingDocument } from '../../../utils/existingDocsDetector'; +import type { ExistingDocumentWithContent } from '../../../services/inlineWizardConversation'; +import type { InlineWizardSshRemoteConfig } from './types'; + +export function resolveAutoRunFolderPath( + projectPath?: string, + configuredAutoRunFolderPath?: string +): string | null { + return configuredAutoRunFolderPath || (projectPath ? getAutoRunFolderPath(projectPath) : null); +} + +export async function hasExistingDocuments(autoRunFolderPath: string | null): Promise { + if (!autoRunFolderPath) return false; + + try { + const result = await window.maestro.autorun.listDocs(autoRunFolderPath); + return result.success && result.files && result.files.length > 0; + } catch { + return false; + } +} + +export async function listExistingDocuments( + autoRunFolderPath: string +): Promise { + try { + const result = await window.maestro.autorun.listDocs(autoRunFolderPath); + if (result.success && result.files) { + return result.files.map((name: string) => ({ + name, + filename: `${name}.md`, + path: `${autoRunFolderPath}/${name}.md`, + })); + } + } catch { + // Folder doesn't exist or can't be read - no existing docs. + } + + return []; +} + +/** + * Load document contents for existing documents. + * Converts ExistingDocument[] to ExistingDocumentWithContent[]. + */ +export async function loadDocumentContents( + docs: ExistingDocument[], + autoRunFolderPath: string +): Promise { + const docsWithContent: ExistingDocumentWithContent[] = []; + + for (const doc of docs) { + try { + const result = await window.maestro.autorun.readDoc(autoRunFolderPath, doc.name); + if (result.success && result.content !== null && result.content !== undefined) { + docsWithContent.push({ + ...doc, + content: result.content, + }); + } else { + docsWithContent.push({ + ...doc, + content: '(Failed to load content)', + }); + } + } catch (error) { + logger.warn(`[useInlineWizard] Failed to load ${doc.filename}:`, undefined, error); + docsWithContent.push({ + ...doc, + content: '(Failed to load content)', + }); + } + } + + return docsWithContent; +} + +export async function fetchHistoryFilePath( + sessionId?: string, + sessionSshRemoteConfig?: InlineWizardSshRemoteConfig +): Promise { + if (!sessionId || sessionSshRemoteConfig?.enabled) return undefined; + + try { + const fetchedPath = await window.maestro.history.getFilePath(sessionId); + return fetchedPath ?? undefined; + } catch { + logger.debug('Could not fetch history file path', '[InlineWizard]', { sessionId }); + return undefined; + } +} diff --git a/src/renderer/hooks/batch/inlineWizard/generationActions.ts b/src/renderer/hooks/batch/inlineWizard/generationActions.ts new file mode 100644 index 0000000000..893a5bc05a --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/generationActions.ts @@ -0,0 +1,218 @@ +import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { + extractDisplayTextFromChunk, + generateInlineDocuments, + type DocumentGenerationCallbacks, +} from '../../../services/inlineWizardDocumentGeneration'; +import { logger } from '../../../utils/logger'; +import { resolveAutoRunFolderPath } from './documents'; +import type { GenerationProgress, InlineWizardState, SetInlineWizardTabState } from './types'; +import type { ToolType } from '../../../types'; + +interface UseInlineWizardGenerationActionsParams { + currentTabId: string | null; + setCurrentTabId: Dispatch>; + tabStatesRef: MutableRefObject>; + setTabState: SetInlineWizardTabState; +} + +export function parseGenerationProgress(message: string): GenerationProgress | null { + const progressMatch = message.match(/(\d+)\s+(?:of|\/)\s+(\d+)/); + if (!progressMatch) return null; + + return { + current: parseInt(progressMatch[1], 10), + total: parseInt(progressMatch[2], 10), + }; +} + +export function getProjectNameForGeneration(state: InlineWizardState): string { + return state.extractedProjectName?.trim() || state.sessionName || 'Project'; +} + +export function useInlineWizardGenerationActions({ + currentTabId, + setCurrentTabId, + tabStatesRef, + setTabState, +}: UseInlineWizardGenerationActionsParams) { + const generateDocuments = useCallback( + async (callbacks?: DocumentGenerationCallbacks, explicitTabId?: string): Promise => { + const tabId = explicitTabId || currentTabId || 'default'; + const currentState = tabStatesRef.current.get(tabId); + + logger.info('Starting Playbook document generation', '[InlineWizard]', { + tabId, + agentType: currentState?.agentType, + mode: currentState?.mode, + conversationLength: currentState?.conversationHistory?.length || 0, + }); + + if (tabId !== currentTabId) { + setCurrentTabId(tabId); + } + + const effectiveAutoRunFolderPath = resolveAutoRunFolderPath( + currentState?.projectPath || undefined, + currentState?.autoRunFolderPath || undefined + ); + + if (!currentState?.agentType || !effectiveAutoRunFolderPath) { + const errorMsg = 'Cannot generate documents: missing agent type or Auto Run folder path'; + logger.error('[useInlineWizard]', undefined, errorMsg); + setTabState(tabId, (prev) => ({ ...prev, error: errorMsg })); + callbacks?.onError?.(errorMsg); + return; + } + + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: true, + docGenerationStartedAt: Date.now(), + generatedDocuments: [], + error: null, + streamingContent: '', + generationProgress: null, + currentDocumentIndex: 0, + })); + + try { + const projectNameForGeneration = getProjectNameForGeneration(currentState); + const result = await generateInlineDocuments({ + agentType: currentState.agentType, + directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, + projectName: projectNameForGeneration, + conversationHistory: currentState.conversationHistory, + existingDocuments: currentState.existingDocuments, + mode: currentState.mode === 'iterate' ? 'iterate' : 'new', + goal: currentState.goal || undefined, + autoRunFolderPath: effectiveAutoRunFolderPath, + sessionId: currentState.sessionId || undefined, + sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, + sessionCustomPath: currentState.sessionCustomPath, + sessionCustomArgs: currentState.sessionCustomArgs, + sessionCustomEnvVars: currentState.sessionCustomEnvVars, + sessionCustomModel: currentState.sessionCustomModel, + conductorProfile: currentState.conductorProfile, + callbacks: { + onStart: () => { + logger.info('[useInlineWizard] Document generation started'); + callbacks?.onStart?.(); + }, + onProgress: (message) => { + logger.info('[useInlineWizard] Progress:', undefined, message); + const progress = parseGenerationProgress(message); + if (progress) { + setTabState(tabId, (prev) => ({ + ...prev, + generationProgress: progress, + })); + } + callbacks?.onProgress?.(message); + }, + onChunk: (chunk) => { + const displayText = extractDisplayTextFromChunk( + chunk, + currentState.agentType as ToolType + ); + + if (displayText) { + setTabState(tabId, (prev) => ({ + ...prev, + streamingContent: prev.streamingContent + displayText, + })); + } + callbacks?.onChunk?.(chunk); + }, + onDocumentComplete: (doc) => { + logger.info('[useInlineWizard] Document saved:', undefined, doc.filename); + setTabState(tabId, (prev) => { + const newDocs = [...prev.generatedDocuments, doc]; + const newTotal = prev.generationProgress?.total || newDocs.length; + return { + ...prev, + generatedDocuments: newDocs, + currentDocumentIndex: newDocs.length - 1, + generationProgress: { + current: newDocs.length, + total: newTotal, + }, + }; + }); + callbacks?.onDocumentComplete?.(doc); + }, + onComplete: (allDocs) => { + logger.info('[useInlineWizard] All documents complete:', undefined, allDocs.length); + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: false, + generatedDocuments: allDocs, + generationProgress: { + current: allDocs.length, + total: allDocs.length, + }, + })); + callbacks?.onComplete?.(allDocs); + }, + onError: (error) => { + logger.error('[useInlineWizard] Generation error:', undefined, error); + callbacks?.onError?.(error); + }, + }, + }); + + if (result.success) { + const finalDocs = result.documents || []; + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: false, + generatedDocuments: finalDocs, + generationProgress: { + current: finalDocs.length, + total: finalDocs.length, + }, + subfolderName: result.subfolderName || null, + subfolderPath: result.subfolderPath || null, + })); + + logger.info( + `Playbook generation complete - ${finalDocs.length} document(s) created`, + '[InlineWizard]', + { + documentCount: finalDocs.length, + subfolderName: result.subfolderName, + filenames: finalDocs.map((d) => d.filename), + } + ); + } else { + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: false, + error: result.error || 'Document generation failed', + streamingContent: '', + generationProgress: null, + })); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error during document generation'; + logger.error('[useInlineWizard] generateDocuments error:', undefined, error); + + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: false, + error: errorMessage, + streamingContent: '', + generationProgress: null, + })); + + callbacks?.onError?.(errorMessage); + } + }, + [currentTabId, setCurrentTabId, setTabState, tabStatesRef] + ); + + return { + generateDocuments, + }; +} diff --git a/src/renderer/hooks/batch/inlineWizard/lifecycleActions.ts b/src/renderer/hooks/batch/inlineWizard/lifecycleActions.ts new file mode 100644 index 0000000000..bc442905d2 --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/lifecycleActions.ts @@ -0,0 +1,96 @@ +import { useCallback, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; +import { + endInlineWizardConversation, + type InlineWizardConversationSession, +} from '../../../services/inlineWizardConversation'; +import { logger } from '../../../utils/logger'; +import { captureException } from '../../../utils/sentry'; +import type { InlineWizardState, PreviousUIState } from './types'; + +interface UseInlineWizardLifecycleActionsParams { + currentTabId: string | null; + setTabStates: Dispatch>>; + previousUIStateRefsMap: MutableRefObject>; + conversationSessionsMap: MutableRefObject>; +} + +export function useInlineWizardLifecycleActions({ + currentTabId, + setTabStates, + previousUIStateRefsMap, + conversationSessionsMap, +}: UseInlineWizardLifecycleActionsParams) { + const endWizard = useCallback( + async (explicitTabId?: string): Promise => { + // Prefer an explicit tab id from the caller because currentTabId tracks the last-touched wizard. + const tabId = explicitTabId || currentTabId || 'default'; + + const previousState = previousUIStateRefsMap.current.get(tabId) || null; + previousUIStateRefsMap.current.delete(tabId); + + // Drop wizard state synchronously before awaiting process cleanup. + setTabStates((prevMap) => { + if (!prevMap.has(tabId)) return prevMap; + const newMap = new Map(prevMap); + newMap.delete(tabId); + return newMap; + }); + + const session = conversationSessionsMap.current.get(tabId); + if (session) { + try { + await endInlineWizardConversation(session); + logger.info(`Wizard conversation ended`, '[InlineWizard]', { + tabId, + sessionId: session.sessionId, + }); + } catch (error) { + logger.warn('[useInlineWizard] Failed to end conversation session:', undefined, error); + captureException(error, { + extra: { + context: 'inlineWizard.endWizard.cleanup', + tabId, + sessionId: session.sessionId, + }, + }); + } + conversationSessionsMap.current.delete(tabId); + } + + return previousState; + }, + [currentTabId, conversationSessionsMap, previousUIStateRefsMap, setTabStates] + ); + + const reset = useCallback(() => { + const tabId = currentTabId || 'default'; + + const session = conversationSessionsMap.current.get(tabId); + if (session) { + endInlineWizardConversation(session).catch((error) => { + logger.warn('[useInlineWizard] Failed to reset conversation session:', undefined, error); + captureException(error, { + extra: { + context: 'inlineWizard.reset.cleanup', + tabId, + sessionId: session.sessionId, + }, + }); + }); + conversationSessionsMap.current.delete(tabId); + } + + previousUIStateRefsMap.current.delete(tabId); + + setTabStates((prevMap) => { + const newMap = new Map(prevMap); + newMap.delete(tabId); + return newMap; + }); + }, [conversationSessionsMap, currentTabId, previousUIStateRefsMap, setTabStates]); + + return { + endWizard, + reset, + }; +} diff --git a/src/renderer/hooks/batch/inlineWizard/simpleActions.ts b/src/renderer/hooks/batch/inlineWizard/simpleActions.ts new file mode 100644 index 0000000000..046f051aa4 --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/simpleActions.ts @@ -0,0 +1,105 @@ +import { useCallback } from 'react'; +import type { ExistingDocument } from '../../../utils/existingDocsDetector'; +import type { InlineGeneratedDocument, SetInlineWizardTabState } from './types'; + +interface UseInlineWizardSimpleActionsParams { + getEffectiveTabId: () => string; + setTabState: SetInlineWizardTabState; +} + +export function clampConfidence(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +export function useInlineWizardSimpleActions({ + getEffectiveTabId, + setTabState, +}: UseInlineWizardSimpleActionsParams) { + const setConfidence = useCallback( + (value: number) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + confidence: clampConfidence(value), + })); + }, + [getEffectiveTabId, setTabState] + ); + + const setGoal = useCallback( + (goal: string | null) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + goal, + })); + }, + [getEffectiveTabId, setTabState] + ); + + const setGeneratingDocs = useCallback( + (generating: boolean) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + isGeneratingDocs: generating, + docGenerationStartedAt: generating + ? (prev.docGenerationStartedAt ?? Date.now()) + : prev.docGenerationStartedAt, + })); + }, + [getEffectiveTabId, setTabState] + ); + + const setGeneratedDocuments = useCallback( + (docs: InlineGeneratedDocument[]) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + generatedDocuments: docs, + isGeneratingDocs: false, + })); + }, + [getEffectiveTabId, setTabState] + ); + + const setExistingDocuments = useCallback( + (docs: ExistingDocument[]) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + existingDocuments: docs, + })); + }, + [getEffectiveTabId, setTabState] + ); + + const setError = useCallback( + (error: string | null) => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + error, + })); + }, + [getEffectiveTabId, setTabState] + ); + + const clearError = useCallback(() => { + const tabId = getEffectiveTabId(); + setTabState(tabId, (prev) => ({ + ...prev, + error: null, + })); + }, [getEffectiveTabId, setTabState]); + + return { + setConfidence, + setGoal, + setGeneratingDocs, + setGeneratedDocuments, + setExistingDocuments, + setError, + clearError, + }; +} diff --git a/src/renderer/hooks/batch/inlineWizard/state.ts b/src/renderer/hooks/batch/inlineWizard/state.ts new file mode 100644 index 0000000000..da06cf95b5 --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/state.ts @@ -0,0 +1,42 @@ +import { generateId } from '../../../utils/ids'; +import type { InlineWizardState } from './types'; + +/** + * Generate a unique message ID. + */ +export function generateMessageId(): string { + return `iwm-${generateId()}`; +} + +/** + * Initial wizard state. + */ +export const initialInlineWizardState: InlineWizardState = { + isActive: false, + isInitializing: false, + isWaiting: false, + mode: null, + goal: null, + confidence: 0, + ready: false, + extractedProjectName: null, + conversationHistory: [], + isGeneratingDocs: false, + generatedDocuments: [], + existingDocuments: [], + previousUIState: null, + error: null, + lastUserMessageContent: null, + projectPath: null, + agentType: null, + sessionName: null, + tabId: null, + sessionId: null, + streamingContent: '', + generationProgress: null, + currentDocumentIndex: 0, + agentSessionId: null, + subfolderName: null, + subfolderPath: null, + autoRunFolderPath: null, +}; diff --git a/src/renderer/hooks/batch/inlineWizard/types.ts b/src/renderer/hooks/batch/inlineWizard/types.ts new file mode 100644 index 0000000000..36da9e354f --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/types.ts @@ -0,0 +1,276 @@ +import type { ExistingDocument } from '../../../utils/existingDocsDetector'; +import type { ToolType, ThinkingMode } from '../../../types'; +import type { ConversationCallbacks } from '../../../services/inlineWizardConversation'; +import type { DocumentGenerationCallbacks } from '../../../services/inlineWizardDocumentGeneration'; + +/** + * Wizard mode determines whether the user wants to create new documents + * or iterate on existing ones. + */ +export type InlineWizardMode = 'new' | 'iterate' | 'ask' | null; + +/** + * Message in the wizard conversation. + * Simplified version of WizardMessage from onboarding wizard. + */ +export interface InlineWizardMessage { + 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; + /** Base64-encoded image data URLs attached to this message */ + images?: string[]; +} + +/** + * UI state to restore when wizard ends. + * These settings are temporarily overridden during wizard mode. + */ +export interface PreviousUIState { + readOnlyMode: boolean; + saveToHistory: boolean; + showThinking: ThinkingMode; +} + +/** + * Generated document from the wizard. + */ +export interface InlineGeneratedDocument { + filename: string; + content: string; + taskCount: number; + /** Absolute path after saving */ + savedPath?: string; +} + +/** + * Progress tracking for document generation. + * Used to display "Generating Phase 1 of 3..." during generation. + */ +export interface GenerationProgress { + /** Current document being generated (1-indexed for display) */ + current: number; + /** Total number of documents to generate */ + total: number; +} + +export interface InlineWizardSshRemoteConfig { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; +} + +export interface InlineWizardSessionOverrides { + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; +} + +/** + * State shape for the inline wizard. + */ +export interface InlineWizardState { + /** Whether wizard is currently active */ + isActive: boolean; + /** Whether wizard is initializing (checking for existing docs, parsing intent) */ + isInitializing: boolean; + /** Whether waiting for AI response */ + isWaiting: boolean; + /** Current wizard mode */ + mode: InlineWizardMode; + /** Goal for iterate mode (what the user wants to add/change) */ + goal: string | null; + /** Confidence level from agent responses (0-100) */ + confidence: number; + /** Whether the AI is ready to proceed with document generation */ + ready: boolean; + /** + * Short human-readable name for the playbook, extracted from the wizard + * conversation (e.g. "HTML Chat Interface"). Updated as the AI refines its + * understanding. Used to name the playbook subfolder; falls back to + * sessionName when absent so we never block generation on a missing field. + */ + extractedProjectName: string | null; + /** Conversation history for this wizard session */ + conversationHistory: InlineWizardMessage[]; + /** Whether documents are being generated */ + isGeneratingDocs: boolean; + /** Wall-clock timestamp (ms) when document generation started; persisted so elapsed time survives tab switches */ + docGenerationStartedAt?: number; + /** Generated documents (if any) */ + generatedDocuments: InlineGeneratedDocument[]; + /** Existing Auto Run documents loaded for iterate mode context */ + existingDocuments: ExistingDocument[]; + /** Previous UI state to restore when wizard ends */ + previousUIState: PreviousUIState | null; + /** Error message if something goes wrong */ + error: string | null; + /** Last user message content (for retry functionality) */ + lastUserMessageContent: string | null; + /** Project path used for document detection */ + projectPath: string | null; + /** Agent type for the session */ + agentType: ToolType | null; + /** Session name/project name */ + sessionName: string | null; + /** Tab ID the wizard was started on (for per-tab isolation) */ + tabId: string | null; + /** Session ID for playbook creation */ + sessionId: string | null; + /** Streaming content being generated (accumulates as AI outputs) */ + streamingContent: string; + /** Progress tracking for document generation */ + generationProgress: GenerationProgress | null; + /** Currently selected document index (for DocumentGenerationView) */ + currentDocumentIndex: number; + /** The Claude agent session ID (from session_id in output) - used to switch tab after wizard completes */ + agentSessionId: string | null; + /** Subfolder name where documents were saved (e.g., "Maestro-Marketing") - used for tab naming after wizard completes */ + subfolderName: string | null; + /** Full path to the subfolder where documents are saved (e.g., "/path/Auto Run Docs/Maestro-Marketing") */ + subfolderPath: string | null; + /** User-configured Auto Run folder path (overrides default projectPath/Auto Run Docs) */ + autoRunFolderPath: string | null; + /** SSH remote configuration (for remote execution) */ + sessionSshRemoteConfig?: InlineWizardSshRemoteConfig; + /** Custom path to agent binary */ + sessionCustomPath?: string; + /** Custom CLI arguments */ + sessionCustomArgs?: string; + /** Custom environment variables */ + sessionCustomEnvVars?: Record; + /** Custom model ID */ + sessionCustomModel?: string; + /** Conductor profile (user's About Me from settings) */ + conductorProfile?: string; + /** History file path for task recall (fetched once during startWizard) */ + historyFilePath?: string; +} + +export type SetInlineWizardTabState = ( + tabId: string, + updater: (prev: InlineWizardState) => InlineWizardState +) => void; + +/** + * Return type for useInlineWizard hook. + */ +export interface UseInlineWizardReturn { + /** Whether the wizard is currently active (for the current active tab) */ + isWizardActive: boolean; + /** Whether the wizard is initializing (checking for existing docs, parsing intent) */ + isInitializing: boolean; + /** Whether waiting for AI response */ + isWaiting: boolean; + /** Current wizard mode */ + wizardMode: InlineWizardMode; + /** Goal for iterate mode */ + wizardGoal: string | null; + /** Current confidence level (0-100) */ + confidence: number; + /** Whether the AI is ready to proceed with document generation */ + ready: boolean; + /** Whether the wizard is ready to generate documents (ready=true && confidence >= threshold) */ + readyToGenerate: boolean; + /** Conversation history */ + conversationHistory: InlineWizardMessage[]; + /** Whether documents are being generated */ + isGeneratingDocs: boolean; + /** Generated documents */ + generatedDocuments: InlineGeneratedDocument[]; + /** Existing documents loaded for iterate mode */ + existingDocuments: ExistingDocument[]; + /** Error message if any */ + error: string | null; + /** Streaming content being generated (accumulates as AI outputs) */ + streamingContent: string; + /** Progress tracking for document generation (e.g., "Phase 1 of 3") */ + generationProgress: GenerationProgress | null; + /** Tab ID the wizard was started on (for per-tab isolation) */ + wizardTabId: string | null; + /** The Claude agent session ID (from session_id in output) - used to switch tab after wizard completes */ + agentSessionId: string | null; + /** Full wizard state (for the current active tab) */ + state: InlineWizardState; + /** Get wizard state for a specific tab (returns undefined if no wizard on that tab) */ + getStateForTab: (tabId: string) => InlineWizardState | undefined; + /** Check if a specific tab has an active wizard */ + isWizardActiveForTab: (tabId: string) => boolean; + /** + * Map of session IDs (Session.id, not provider session) that have at least one + * tab with the inline wizard active. Value carries an `isGeneratingDocs` flag + * that's true when any such tab is in the Auto Run doc generation phase, so + * the Left Bar indicator can pulse during generation. + */ + wizardActiveSessions: Map; + /** + * Start the wizard with intent parsing flow. + */ + startWizard: ( + naturalLanguageInput?: string, + currentUIState?: PreviousUIState, + projectPath?: string, + agentType?: ToolType, + sessionName?: string, + tabId?: string, + sessionId?: string, + autoRunFolderPath?: string, + sessionSshRemoteConfig?: InlineWizardSshRemoteConfig, + conductorProfile?: string, + sessionOverrides?: InlineWizardSessionOverrides + ) => Promise; + /** + * End the wizard and restore previous UI state. + */ + endWizard: (explicitTabId?: string) => Promise; + /** + * Send a message to the wizard conversation. + */ + sendMessage: ( + content: string, + images?: string[], + callbacks?: ConversationCallbacks, + explicitTabId?: string + ) => Promise; + /** + * Mark the given tab as the "current" wizard. + */ + selectWizardTab: (tabId: string) => void; + /** + * Set the confidence level. + */ + setConfidence: (value: number) => void; + /** Set the wizard mode */ + setMode: (mode: InlineWizardMode) => void; + /** Set the goal for iterate mode */ + setGoal: (goal: string | null) => void; + /** Set whether documents are being generated */ + setGeneratingDocs: (generating: boolean) => void; + /** Set generated documents */ + setGeneratedDocuments: (docs: InlineGeneratedDocument[]) => void; + /** Set existing documents (for iterate mode context) */ + setExistingDocuments: (docs: ExistingDocument[]) => void; + /** Set error message */ + setError: (error: string | null) => void; + /** Clear the current error */ + clearError: () => void; + /** + * Retry sending the last user message that failed. + */ + retryLastMessage: (callbacks?: ConversationCallbacks) => Promise; + /** Add an assistant response to the conversation */ + addAssistantMessage: (content: string, confidence?: number, ready?: boolean) => void; + /** Clear conversation history */ + clearConversation: () => void; + /** Reset the wizard to initial state */ + reset: () => void; + /** + * Generate Auto Run documents based on the conversation. + */ + generateDocuments: (callbacks?: DocumentGenerationCallbacks, tabId?: string) => Promise; +} diff --git a/src/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.ts b/src/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.ts new file mode 100644 index 0000000000..914edff0e8 --- /dev/null +++ b/src/renderer/hooks/batch/inlineWizard/useInlineWizardTabState.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { InlineWizardState, PreviousUIState, SetInlineWizardTabState } from './types'; +import { initialInlineWizardState } from './state'; +import type { InlineWizardConversationSession } from '../../../services/inlineWizardConversation'; + +export function useInlineWizardTabState() { + // Per-tab wizard states - Map from tabId to wizard state. + const [tabStates, setTabStates] = useState>(new Map()); + + // Track the "current" tab for backward compatibility with existing return values. + const [currentTabId, setCurrentTabId] = useState(null); + + const state = currentTabId + ? (tabStates.get(currentTabId) ?? initialInlineWizardState) + : initialInlineWizardState; + + const tabStatesRef = useRef>(tabStates); + useEffect(() => { + tabStatesRef.current = tabStates; + }, [tabStates]); + + const previousUIStateRefsMap = useRef>(new Map()); + const conversationSessionsMap = useRef>(new Map()); + + const setTabState: SetInlineWizardTabState = useCallback((tabId, updater) => { + setTabStates((prevMap) => { + const newMap = new Map(prevMap); + const prevState = newMap.get(tabId) ?? initialInlineWizardState; + newMap.set(tabId, updater(prevState)); + return newMap; + }); + }, []); + + const getStateForTab = useCallback( + (tabId: string): InlineWizardState | undefined => { + return tabStates.get(tabId); + }, + [tabStates] + ); + + const isWizardActiveForTab = useCallback( + (tabId: string): boolean => { + const tabState = tabStates.get(tabId); + return tabState?.isActive ?? false; + }, + [tabStates] + ); + + const getEffectiveTabId = useCallback(() => { + const tabId = currentTabId || 'default'; + if (tabId !== currentTabId) { + setCurrentTabId(tabId); + } + return tabId; + }, [currentTabId]); + + const wizardActiveSessions = useMemo(() => { + const map = new Map(); + for (const tabState of tabStates.values()) { + if (!tabState.isActive || !tabState.sessionId) continue; + const existing = map.get(tabState.sessionId); + map.set(tabState.sessionId, { + isGeneratingDocs: (existing?.isGeneratingDocs ?? false) || tabState.isGeneratingDocs, + }); + } + return map; + }, [tabStates]); + + return { + tabStates, + setTabStates, + currentTabId, + setCurrentTabId, + state, + tabStatesRef, + previousUIStateRefsMap, + conversationSessionsMap, + setTabState, + getStateForTab, + isWizardActiveForTab, + getEffectiveTabId, + wizardActiveSessions, + }; +} diff --git a/src/renderer/hooks/batch/useInlineWizard.ts b/src/renderer/hooks/batch/useInlineWizard.ts index 5982786f00..cedfa5dbb7 100644 --- a/src/renderer/hooks/batch/useInlineWizard.ts +++ b/src/renderer/hooks/batch/useInlineWizard.ts @@ -1,1504 +1,94 @@ /** * useInlineWizard.ts * - * Hook for managing inline wizard state within a session. - * The inline wizard allows users to create new Auto Run documents or iterate - * on existing ones through a conversational interface triggered by `/wizard`. - * - * Unlike the full-screen onboarding wizard (MaestroWizard.tsx), this wizard - * runs inline within the existing AI conversation interface. - */ - -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import { logger } from '../../utils/logger'; -import { parseWizardIntent } from '../../services/wizardIntentParser'; -import { getAutoRunFolderPath, type ExistingDocument } from '../../utils/existingDocsDetector'; -import { - startInlineWizardConversation, - sendWizardMessage, - endInlineWizardConversation, - READY_CONFIDENCE_THRESHOLD, - type InlineWizardConversationSession, - type ExistingDocumentWithContent, - type ConversationCallbacks, -} from '../../services/inlineWizardConversation'; -import { - generateInlineDocuments, - extractDisplayTextFromChunk, - type DocumentGenerationCallbacks, -} from '../../services/inlineWizardDocumentGeneration'; -import type { ToolType } from '../../types'; -import { hasCapabilityCached } from '../agent/useAgentCapabilities'; - -/** - * Wizard mode determines whether the user wants to create new documents - * or iterate on existing ones. - */ -export type InlineWizardMode = 'new' | 'iterate' | 'ask' | null; - -/** - * Message in the wizard conversation. - * Simplified version of WizardMessage from onboarding wizard. - */ -export interface InlineWizardMessage { - 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; - /** Base64-encoded image data URLs attached to this message */ - images?: string[]; -} - -import type { ThinkingMode } from '../../types'; - -/** - * UI state to restore when wizard ends. - * These settings are temporarily overridden during wizard mode. - */ -export interface PreviousUIState { - readOnlyMode: boolean; - saveToHistory: boolean; - showThinking: ThinkingMode; -} - -/** - * Generated document from the wizard. - */ -export interface InlineGeneratedDocument { - filename: string; - content: string; - taskCount: number; - /** Absolute path after saving */ - savedPath?: string; -} - -/** - * Progress tracking for document generation. - * Used to display "Generating Phase 1 of 3..." during generation. + * Public composer for inline wizard state within an agent tab. + * Internals live in ./inlineWizard so state storage, lifecycle, conversation, + * simple setters, and document generation stay independently testable. */ -export interface GenerationProgress { - /** Current document being generated (1-indexed for display) */ - current: number; - /** Total number of documents to generate */ - total: number; -} -/** - * State shape for the inline wizard. - */ -export interface InlineWizardState { - /** Whether wizard is currently active */ - isActive: boolean; - /** Whether wizard is initializing (checking for existing docs, parsing intent) */ - isInitializing: boolean; - /** Whether waiting for AI response */ - isWaiting: boolean; - /** Current wizard mode */ - mode: InlineWizardMode; - /** Goal for iterate mode (what the user wants to add/change) */ - goal: string | null; - /** Confidence level from agent responses (0-100) */ - confidence: number; - /** Whether the AI is ready to proceed with document generation */ - ready: boolean; - /** - * Short human-readable name for the playbook, extracted from the wizard - * conversation (e.g. "HTML Chat Interface"). Updated as the AI refines its - * understanding. Used to name the playbook subfolder; falls back to - * sessionName when absent so we never block generation on a missing field. - */ - extractedProjectName: string | null; - /** Conversation history for this wizard session */ - conversationHistory: InlineWizardMessage[]; - /** Whether documents are being generated */ - isGeneratingDocs: boolean; - /** Wall-clock timestamp (ms) when document generation started; persisted so elapsed time survives tab switches */ - docGenerationStartedAt?: number; - /** Generated documents (if any) */ - generatedDocuments: InlineGeneratedDocument[]; - /** Existing Auto Run documents loaded for iterate mode context */ - existingDocuments: ExistingDocument[]; - /** Previous UI state to restore when wizard ends */ - previousUIState: PreviousUIState | null; - /** Error message if something goes wrong */ - error: string | null; - /** Last user message content (for retry functionality) */ - lastUserMessageContent: string | null; - /** Project path used for document detection */ - projectPath: string | null; - /** Agent type for the session */ - agentType: ToolType | null; - /** Session name/project name */ - sessionName: string | null; - /** Tab ID the wizard was started on (for per-tab isolation) */ - tabId: string | null; - /** Session ID for playbook creation */ - sessionId: string | null; - /** Streaming content being generated (accumulates as AI outputs) */ - streamingContent: string; - /** Progress tracking for document generation */ - generationProgress: GenerationProgress | null; - /** Currently selected document index (for DocumentGenerationView) */ - currentDocumentIndex: number; - /** The Claude agent session ID (from session_id in output) - used to switch tab after wizard completes */ - agentSessionId: string | null; - /** Subfolder name where documents were saved (e.g., "Maestro-Marketing") - used for tab naming after wizard completes */ - subfolderName: string | null; - /** Full path to the subfolder where documents are saved (e.g., "/path/Auto Run Docs/Maestro-Marketing") */ - subfolderPath: string | null; - /** User-configured Auto Run folder path (overrides default projectPath/Auto Run Docs) */ - autoRunFolderPath: string | null; - /** SSH remote configuration (for remote execution) */ - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }; - /** Custom path to agent binary */ - sessionCustomPath?: string; - /** Custom CLI arguments */ - sessionCustomArgs?: string; - /** Custom environment variables */ - sessionCustomEnvVars?: Record; - /** Custom model ID */ - sessionCustomModel?: string; - /** Conductor profile (user's About Me from settings) */ - conductorProfile?: string; - /** History file path for task recall (fetched once during startWizard) */ - historyFilePath?: string; -} +import { READY_CONFIDENCE_THRESHOLD } from '../../services/inlineWizardConversation'; +import { useInlineWizardConversationActions } from './inlineWizard/conversationActions'; +import { useInlineWizardGenerationActions } from './inlineWizard/generationActions'; +import { useInlineWizardLifecycleActions } from './inlineWizard/lifecycleActions'; +import { useInlineWizardSimpleActions } from './inlineWizard/simpleActions'; +import { useInlineWizardTabState } from './inlineWizard/useInlineWizardTabState'; +import type { UseInlineWizardReturn } from './inlineWizard/types'; + +export type { + GenerationProgress, + InlineGeneratedDocument, + InlineWizardMessage, + InlineWizardMode, + InlineWizardSessionOverrides, + InlineWizardSshRemoteConfig, + InlineWizardState, + PreviousUIState, + UseInlineWizardReturn, +} from './inlineWizard/types'; -/** - * Return type for useInlineWizard hook. - */ -export interface UseInlineWizardReturn { - /** Whether the wizard is currently active (for the current active tab) */ - isWizardActive: boolean; - /** Whether the wizard is initializing (checking for existing docs, parsing intent) */ - isInitializing: boolean; - /** Whether waiting for AI response */ - isWaiting: boolean; - /** Current wizard mode */ - wizardMode: InlineWizardMode; - /** Goal for iterate mode */ - wizardGoal: string | null; - /** Current confidence level (0-100) */ - confidence: number; - /** Whether the AI is ready to proceed with document generation */ - ready: boolean; - /** Whether the wizard is ready to generate documents (ready=true && confidence >= threshold) */ - readyToGenerate: boolean; - /** Conversation history */ - conversationHistory: InlineWizardMessage[]; - /** Whether documents are being generated */ - isGeneratingDocs: boolean; - /** Generated documents */ - generatedDocuments: InlineGeneratedDocument[]; - /** Existing documents loaded for iterate mode */ - existingDocuments: ExistingDocument[]; - /** Error message if any */ - error: string | null; - /** Streaming content being generated (accumulates as AI outputs) */ - streamingContent: string; - /** Progress tracking for document generation (e.g., "Phase 1 of 3") */ - generationProgress: GenerationProgress | null; - /** Tab ID the wizard was started on (for per-tab isolation) */ - wizardTabId: string | null; - /** The Claude agent session ID (from session_id in output) - used to switch tab after wizard completes */ - agentSessionId: string | null; - /** Full wizard state (for the current active tab) */ - state: InlineWizardState; - /** Get wizard state for a specific tab (returns undefined if no wizard on that tab) */ - getStateForTab: (tabId: string) => InlineWizardState | undefined; - /** Check if a specific tab has an active wizard */ - isWizardActiveForTab: (tabId: string) => boolean; - /** - * Map of session IDs (Session.id, not provider session) that have at least one - * tab with the inline wizard active. Value carries an `isGeneratingDocs` flag - * that's true when any such tab is in the Auto Run doc generation phase, so - * the Left Bar indicator can pulse during generation. - */ - wizardActiveSessions: Map; - /** - * Start the wizard with intent parsing flow. - * @param naturalLanguageInput - Optional input from `/wizard ` command - * @param currentUIState - Current UI state to restore when wizard ends - * @param projectPath - Project path to check for existing Auto Run documents - * @param agentType - The AI agent type to use for conversation - * @param sessionName - The session name (used as project name) - * @param tabId - The tab ID to associate the wizard with - * @param sessionId - The session ID for playbook creation - * @param autoRunFolderPath - User-configured Auto Run folder path (if set, overrides default projectPath/Auto Run Docs) - * @param sessionSshRemoteConfig - SSH remote configuration (for remote execution) - * @param conductorProfile - Conductor profile (user's About Me from settings) - */ - startWizard: ( - naturalLanguageInput?: string, - currentUIState?: PreviousUIState, - projectPath?: string, - agentType?: ToolType, - sessionName?: string, - tabId?: string, - sessionId?: string, - autoRunFolderPath?: string, - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }, - conductorProfile?: string, - sessionOverrides?: { - customPath?: string; - customArgs?: string; - customEnvVars?: Record; - customModel?: string; - } - ) => Promise; - /** - * End the wizard and restore previous UI state. - * @param explicitTabId - Optional tab ID to end. Pass when the caller knows which tab to evict — - * the hook's internal currentTabId only tracks the last-touched wizard, so closing a non-active - * wizard tab (e.g. via the tab strip's X button) without this leaves a stale tabStates entry - * that keeps the Left Bar wand indicator stuck on. - */ - endWizard: (explicitTabId?: string) => Promise; - /** - * Send a message to the wizard conversation. - * @param content - Message content - * @param images - Optional base64-encoded image data URLs to attach - * @param callbacks - Optional callbacks for streaming progress - * @param explicitTabId - Optional tab ID to send to. Pass when multiple wizards may be active - * concurrently and the caller knows which tab is in focus — the hook's internal currentTabId - * only tracks the last-touched wizard, so without this the message can land on the wrong tab. - */ - sendMessage: ( - content: string, - images?: string[], - callbacks?: ConversationCallbacks, - explicitTabId?: string - ) => Promise; - /** - * Mark the given tab as the "current" wizard. Used to keep currentTabId in sync with the - * UI's active tab so per-tab setters route correctly when multiple concurrent wizards are open. - */ - selectWizardTab: (tabId: string) => void; - /** - * Set the confidence level. - * @param value - Confidence value (0-100) - */ - setConfidence: (value: number) => void; - /** Set the wizard mode */ - setMode: (mode: InlineWizardMode) => void; - /** Set the goal for iterate mode */ - setGoal: (goal: string | null) => void; - /** Set whether documents are being generated */ - setGeneratingDocs: (generating: boolean) => void; - /** Set generated documents */ - setGeneratedDocuments: (docs: InlineGeneratedDocument[]) => void; - /** Set existing documents (for iterate mode context) */ - setExistingDocuments: (docs: ExistingDocument[]) => void; - /** Set error message */ - setError: (error: string | null) => void; - /** Clear the current error */ - clearError: () => void; - /** - * Retry sending the last user message that failed. - * Only works if there was a previous user message and an error occurred. - * @param callbacks - Optional callbacks for streaming progress - */ - retryLastMessage: (callbacks?: ConversationCallbacks) => Promise; - /** Add an assistant response to the conversation */ - addAssistantMessage: (content: string, confidence?: number, ready?: boolean) => void; - /** Clear conversation history */ - clearConversation: () => void; - /** Reset the wizard to initial state */ - reset: () => void; - /** - * Generate Auto Run documents based on the conversation. - * Sets isGeneratingDocs to true, streams AI response, parses documents, - * and saves them to the Auto Run folder. - * @param callbacks - Optional callbacks for generation progress - * @param tabId - Optional tab ID to generate for (defaults to currentTabId) - */ - generateDocuments: (callbacks?: DocumentGenerationCallbacks, tabId?: string) => Promise; -} - -/** - * Generate a unique message ID. - */ -function generateMessageId(): string { - return `iwm-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; -} - -/** - * Initial wizard state. - */ -const initialState: InlineWizardState = { - isActive: false, - isInitializing: false, - isWaiting: false, - mode: null, - goal: null, - confidence: 0, - ready: false, - extractedProjectName: null, - conversationHistory: [], - isGeneratingDocs: false, - generatedDocuments: [], - existingDocuments: [], - previousUIState: null, - error: null, - lastUserMessageContent: null, - projectPath: null, - agentType: null, - sessionName: null, - tabId: null, - sessionId: null, - streamingContent: '', - generationProgress: null, - currentDocumentIndex: 0, - agentSessionId: null, - subfolderName: null, - subfolderPath: null, - autoRunFolderPath: null, -}; - -/** - * Hook for managing inline wizard state. - * - * The inline wizard is triggered by the `/wizard` slash command and allows - * users to create or iterate on Auto Run documents within their existing - * session context. - * - * @example - * ```tsx - * function MyComponent() { - * const { - * isWizardActive, - * wizardMode, - * startWizard, - * endWizard, - * sendMessage, - * } = useInlineWizard(); - * - * // Start wizard when user types /wizard - * const handleSlashCommand = (cmd: string, args: string) => { - * if (cmd === '/wizard') { - * startWizard(args, { readOnlyMode: true, saveToHistory: false, showThinking: false }); - * } - * }; - * - * // Render wizard UI when active - * if (isWizardActive) { - * return ; - * } - * } - * ``` - */ export function useInlineWizard(): UseInlineWizardReturn { - // Per-tab wizard states - Map from tabId to wizard state - // This allows multiple independent wizards to run on different tabs - const [tabStates, setTabStates] = useState>(new Map()); - - // Track the "current" tab for backward compatibility with existing return values - // This gets updated whenever startWizard is called with a tabId - const [currentTabId, setCurrentTabId] = useState(null); - - // Derive the "current" state for backward compatibility - // If no wizard is active on the current tab, return initialState - const state = currentTabId ? (tabStates.get(currentTabId) ?? initialState) : initialState; - - // Use ref to hold current state map for access in callbacks without stale closures - const tabStatesRef = useRef>(tabStates); - useEffect(() => { - tabStatesRef.current = tabStates; - }, [tabStates]); - - // Per-tab previous UI state refs - Map from tabId to previous UI state - const previousUIStateRefsMap = useRef>(new Map()); - - // Per-tab conversation sessions - Map from tabId to conversation session - const conversationSessionsMap = useRef>(new Map()); - - /** - * Helper to update state for a specific tab - */ - const setTabState = useCallback( - (tabId: string, updater: (prev: InlineWizardState) => InlineWizardState) => { - setTabStates((prevMap) => { - const newMap = new Map(prevMap); - const prevState = newMap.get(tabId) ?? initialState; - newMap.set(tabId, updater(prevState)); - return newMap; - }); - }, - [] - ); - - /** - * Get state for a specific tab - */ - const getStateForTab = useCallback( - (tabId: string): InlineWizardState | undefined => { - return tabStates.get(tabId); - }, - [tabStates] - ); - - /** - * Check if a specific tab has an active wizard - */ - const isWizardActiveForTab = useCallback( - (tabId: string): boolean => { - const tabState = tabStates.get(tabId); - return tabState?.isActive ?? false; - }, - [tabStates] - ); - - /** - * Load document contents for existing documents. - * Converts ExistingDocument[] to ExistingDocumentWithContent[]. - */ - const loadDocumentContents = useCallback( - async ( - docs: ExistingDocument[], - autoRunFolderPath: string - ): Promise => { - const docsWithContent: ExistingDocumentWithContent[] = []; - - for (const doc of docs) { - try { - const result = await window.maestro.autorun.readDoc(autoRunFolderPath, doc.name); - if (result.success && result.content) { - docsWithContent.push({ - ...doc, - content: result.content, - }); - } else { - // Include doc without content if read failed - docsWithContent.push({ - ...doc, - content: '(Failed to load content)', - }); - } - } catch (error) { - logger.warn(`[useInlineWizard] Failed to load ${doc.filename}:`, undefined, error); - docsWithContent.push({ - ...doc, - content: '(Failed to load content)', - }); - } - } - - return docsWithContent; - }, - [] - ); - - /** - * Start the wizard with intent parsing flow. - * - * Flow: - * 1. Check if project has existing Auto Run documents - * 2. If no input provided and docs exist → 'ask' mode (prompt user) - * 3. If input provided → parse intent to determine mode - * 4. If mode is 'iterate' → load existing docs with content for context - * 5. Initialize conversation session with appropriate prompt - */ - const startWizard = useCallback( - async ( - naturalLanguageInput?: string, - currentUIState?: PreviousUIState, - projectPath?: string, - agentType?: ToolType, - sessionName?: string, - tabId?: string, - sessionId?: string, - configuredAutoRunFolderPath?: string, - sessionSshRemoteConfig?: { - enabled: boolean; - remoteId: string | null; - workingDirOverride?: string; - }, - conductorProfile?: string, - sessionOverrides?: { - customPath?: string; - customArgs?: string; - customEnvVars?: Record; - customModel?: string; - } - ): Promise => { - // Tab ID is required for per-tab wizard management - const effectiveTabId = tabId || 'default'; - - // Determine the Auto Run folder path to use: - // 1. If user has configured a specific path (configuredAutoRunFolderPath), use it - // 2. Otherwise, fall back to the default: projectPath/Auto Run Docs - const effectiveAutoRunFolderPath = - configuredAutoRunFolderPath || (projectPath ? getAutoRunFolderPath(projectPath) : null); - - logger.info(`Starting inline wizard on tab ${effectiveTabId}`, '[InlineWizard]', { - projectPath, - agentType, - sessionName, - hasInput: !!naturalLanguageInput, - autoRunFolderPath: effectiveAutoRunFolderPath, - }); - - // Store current UI state for later restoration (per-tab) - if (currentUIState) { - previousUIStateRefsMap.current.set(effectiveTabId, currentUIState); - } - - // Update current tab ID for backward-compatible return values - setCurrentTabId(effectiveTabId); - - // Set initializing state immediately for this tab - setTabState(effectiveTabId, () => ({ - ...initialState, - isActive: true, - isInitializing: true, - isWaiting: false, - mode: null, - goal: null, - confidence: 0, - ready: false, - conversationHistory: [], - isGeneratingDocs: false, - generatedDocuments: [], - existingDocuments: [], - previousUIState: currentUIState || null, - error: null, - projectPath: projectPath || null, - agentType: agentType || null, - sessionName: sessionName || null, - tabId: effectiveTabId, - sessionId: sessionId || null, - streamingContent: '', - generationProgress: null, - currentDocumentIndex: 0, - lastUserMessageContent: null, - agentSessionId: null, - subfolderName: null, - subfolderPath: null, - autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig, - sessionCustomPath: sessionOverrides?.customPath, - sessionCustomArgs: sessionOverrides?.customArgs, - sessionCustomEnvVars: sessionOverrides?.customEnvVars, - sessionCustomModel: sessionOverrides?.customModel, - conductorProfile, - })); - - try { - // Step 0: Fetch history file path for task recall (if session ID is available) - // Skip for SSH sessions — the local path is unreachable from the remote host - let historyFilePath: string | undefined; - const isSSH = sessionSshRemoteConfig?.enabled; - if (sessionId && !isSSH) { - try { - const fetchedPath = await window.maestro.history.getFilePath(sessionId); - historyFilePath = fetchedPath ?? undefined; // Convert null to undefined - } catch { - // History file path not available - continue without it - logger.debug('Could not fetch history file path', '[InlineWizard]', { sessionId }); - } - } - - // Step 1: Check for existing Auto Run documents in the configured folder - // Use the effective Auto Run folder path (user-configured or default) - let hasExistingDocs = false; - if (effectiveAutoRunFolderPath) { - try { - const result = await window.maestro.autorun.listDocs(effectiveAutoRunFolderPath); - hasExistingDocs = result.success && result.files && result.files.length > 0; - } catch { - // Folder doesn't exist or can't be read - no existing docs - hasExistingDocs = false; - } - } - - // Step 2: Determine mode based on input and existing docs - let mode: InlineWizardMode; - let goal: string | null = null; - let existingDocs: ExistingDocument[] = []; - - const trimmedInput = naturalLanguageInput?.trim() || ''; - - if (!trimmedInput) { - // No input provided - if (hasExistingDocs) { - // Docs exist - ask user what they want to do - mode = 'ask'; - } else { - // No docs - default to new mode - mode = 'new'; - } - } else { - // Input provided - parse intent - const intentResult = parseWizardIntent(trimmedInput, hasExistingDocs); - mode = intentResult.mode; - goal = intentResult.goal || null; - } - - // Step 3: If iterate mode, load existing docs with content for context - let docsWithContent: ExistingDocumentWithContent[] = []; - if (mode === 'iterate' && effectiveAutoRunFolderPath) { - // List docs from the configured Auto Run folder - try { - const result = await window.maestro.autorun.listDocs(effectiveAutoRunFolderPath); - if (result.success && result.files) { - existingDocs = result.files.map((name: string) => ({ - name, - filename: `${name}.md`, - path: `${effectiveAutoRunFolderPath}/${name}.md`, - })); - } - } catch { - existingDocs = []; - } - docsWithContent = await loadDocumentContents(existingDocs, effectiveAutoRunFolderPath); - } - - // Step 4: Initialize conversation session (only for 'new' or 'iterate' modes) - // Only allow wizard for agents that support structured output - if ( - (mode === 'new' || mode === 'iterate') && - agentType && - hasCapabilityCached(agentType, 'supportsWizard') && - effectiveAutoRunFolderPath - ) { - // historyFilePath was fetched in Step 0 above - const session = startInlineWizardConversation({ - mode, - agentType, - directoryPath: projectPath || effectiveAutoRunFolderPath, - projectName: sessionName || 'Project', - goal: goal || undefined, - existingDocs: docsWithContent.length > 0 ? docsWithContent : undefined, - autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig, - sessionCustomPath: sessionOverrides?.customPath, - sessionCustomArgs: sessionOverrides?.customArgs, - sessionCustomEnvVars: sessionOverrides?.customEnvVars, - sessionCustomModel: sessionOverrides?.customModel, - conductorProfile, - historyFilePath, - }); - - // Store conversation session per-tab - conversationSessionsMap.current.set(effectiveTabId, session); - - logger.info(`Wizard conversation started (mode: ${mode})`, '[InlineWizard]', { - sessionId: session.sessionId, - tabId: effectiveTabId, - mode, - goal: goal || null, - existingDocsCount: docsWithContent.length, - autoRunFolderPath: effectiveAutoRunFolderPath, - }); - } else if ( - (mode === 'new' || mode === 'iterate') && - agentType && - !hasCapabilityCached(agentType, 'supportsWizard') - ) { - // Agent not supported for wizard - logger.warn(`Wizard not supported for agent type: ${agentType}`, '[InlineWizard]'); - setTabState(effectiveTabId, (prev) => ({ - ...prev, - isInitializing: false, - error: `The inline wizard is not supported for this agent type.`, - })); - return; // Don't update state with parsed results - } - - // Update state with parsed results - // Store historyFilePath so it's available for setMode if user is in 'ask' mode - setTabState(effectiveTabId, (prev) => ({ - ...prev, - isInitializing: false, - mode, - goal, - existingDocuments: existingDocs, - historyFilePath, - })); - } catch (error) { - // Handle any errors during initialization - const errorMessage = error instanceof Error ? error.message : 'Failed to initialize wizard'; - logger.error('[useInlineWizard] startWizard error:', undefined, error); - - setTabState(effectiveTabId, (prev) => ({ - ...prev, - isInitializing: false, - mode: 'new', // Default to new mode on error - error: errorMessage, - })); - } - }, - [loadDocumentContents, setTabState] - ); - - /** - * End the wizard and return the previous UI state for restoration. - * Uses the current tab ID to determine which wizard to end. - */ - const endWizard = useCallback( - async (explicitTabId?: string): Promise => { - // Prefer an explicit tab id from the caller — currentTabId tracks the last-touched wizard - // and can point at the wrong tab when a non-active wizard is being closed (tab strip X). - const tabId = explicitTabId || currentTabId || 'default'; - - // Get previous UI state for this tab - const previousState = previousUIStateRefsMap.current.get(tabId) || null; - previousUIStateRefsMap.current.delete(tabId); - - // Drop the wizard state synchronously BEFORE awaiting any async cleanup. - // The wizard sync effect in useWizardHandlers re-runs after the caller - // clears `tab.wizardState`; if this delete is delayed past an await, the - // effect sees `isActive: true` here and resurrects the cleared state, - // trapping the user on the completion screen. - setTabStates((prevMap) => { - if (!prevMap.has(tabId)) return prevMap; - const newMap = new Map(prevMap); - newMap.delete(tabId); - return newMap; - }); - - // Clean up conversation session for this tab (async — kills underlying process) - const session = conversationSessionsMap.current.get(tabId); - if (session) { - try { - await endInlineWizardConversation(session); - logger.info(`Wizard conversation ended`, '[InlineWizard]', { - tabId, - sessionId: session.sessionId, - }); - } catch (error) { - logger.warn('[useInlineWizard] Failed to end conversation session:', undefined, error); - } - conversationSessionsMap.current.delete(tabId); - } - - return previousState; - }, - [currentTabId] - ); - - /** - * Send a user message to the wizard conversation. - * Adds the message to history, calls the AI service, and updates state with response. - * Uses the current tab ID to determine which wizard to send to. - */ - const sendMessage = useCallback( - async ( - content: string, - images?: string[], - callbacks?: ConversationCallbacks, - explicitTabId?: string - ): Promise => { - // Prefer the caller's explicit tabId — currentTabId only tracks the last-touched wizard - // and goes stale when multiple wizards run concurrently across tabs. - const tabId = explicitTabId || currentTabId || 'default'; - if (tabId !== currentTabId) { - setCurrentTabId(tabId); - } - - // Guard against concurrent calls - prevents race conditions - const currentState = tabStatesRef.current.get(tabId); - if (currentState?.isWaiting) { - logger.warn('[useInlineWizard] Already waiting for response, ignoring duplicate send'); - return; - } - - // Create user message (with images if provided) - const userMessage: InlineWizardMessage = { - id: generateMessageId(), - role: 'user', - content, - timestamp: Date.now(), - ...(images && images.length > 0 ? { images } : {}), - }; - - // Add user message to history, track it for retry, and set waiting state - setTabState(tabId, (prev) => ({ - ...prev, - conversationHistory: [...prev.conversationHistory, userMessage], - lastUserMessageContent: content, - isWaiting: true, - error: null, - })); - - // Check if we have an active conversation session for this tab - let session = conversationSessionsMap.current.get(tabId); - if (!session) { - // If we're in 'ask' mode and don't have a session, auto-create one with 'new' mode - // This happens when user types directly instead of using the mode selection modal - const currentState = tabStatesRef.current.get(tabId); - // Use stored autoRunFolderPath from state (configured by user or default) - const effectiveAutoRunFolderPath = - currentState?.autoRunFolderPath || - (currentState?.projectPath ? getAutoRunFolderPath(currentState.projectPath) : null); - - if ( - currentState?.mode === 'ask' && - currentState.agentType && - hasCapabilityCached(currentState.agentType, 'supportsWizard') && - effectiveAutoRunFolderPath - ) { - logger.info('[useInlineWizard] Auto-creating session for direct message in ask mode'); - // Use historyFilePath from state (fetched during startWizard) - session = startInlineWizardConversation({ - mode: 'new', - agentType: currentState.agentType, - directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, - projectName: currentState.sessionName || 'Project', - goal: currentState.goal || undefined, - existingDocs: undefined, - autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, - sessionCustomPath: currentState.sessionCustomPath, - sessionCustomArgs: currentState.sessionCustomArgs, - sessionCustomEnvVars: currentState.sessionCustomEnvVars, - sessionCustomModel: currentState.sessionCustomModel, - conductorProfile: currentState.conductorProfile, - historyFilePath: currentState.historyFilePath, - }); - conversationSessionsMap.current.set(tabId, session); - // Update mode to 'new' since we're proceeding with a new plan - setTabState(tabId, (prev) => ({ ...prev, mode: 'new' })); - logger.info('[useInlineWizard] Session created:', undefined, session.sessionId); - } else { - logger.error( - '[useInlineWizard] No active conversation session, currentState:', - undefined, - { - mode: currentState?.mode, - agentType: currentState?.agentType, - projectPath: currentState?.projectPath, - autoRunFolderPath: currentState?.autoRunFolderPath, - } - ); - setTabState(tabId, (prev) => ({ - ...prev, - isWaiting: false, - error: 'No active conversation session. Please restart the wizard.', - })); - callbacks?.onError?.('No active conversation session'); - return; - } - } - - try { - // Get current conversation history for this tab - const currentState = tabStatesRef.current.get(tabId); - const currentHistory = currentState?.conversationHistory || []; - - // Call the AI service - const result = await sendWizardMessage(session, content, currentHistory, callbacks); - - if (result.success && result.response) { - // Create assistant message from response - const assistantMessage: InlineWizardMessage = { - id: generateMessageId(), - role: 'assistant', - content: result.response.message, - timestamp: Date.now(), - confidence: result.response.confidence, - ready: result.response.ready, - }; - - // Update state with response and capture agent session ID if available - const incomingProjectName = result.response.projectName?.trim(); - setTabState(tabId, (prev) => ({ - ...prev, - conversationHistory: [...prev.conversationHistory, assistantMessage], - confidence: result.response!.confidence, - ready: result.response!.ready, - // Latest non-empty projectName from the AI wins; keep the previous - // value if this turn didn't emit one so we never regress to null. - extractedProjectName: incomingProjectName || prev.extractedProjectName, - isWaiting: false, - // Capture the first agentSessionId we receive (subsequent messages may not have it) - agentSessionId: prev.agentSessionId || result.agentSessionId || null, - })); - - logger.info( - `Wizard response received - confidence: ${result.response.confidence}%, ready: ${result.response.ready}`, - '[InlineWizard]', - { - confidence: result.response.confidence, - ready: result.response.ready, - agentSessionId: result.agentSessionId || null, - } - ); - } else { - // Handle error response - const errorMessage = result.error || 'Failed to get response from AI'; - logger.error('[useInlineWizard] sendWizardMessage error:', undefined, errorMessage); - - setTabState(tabId, (prev) => ({ - ...prev, - isWaiting: false, - error: errorMessage, - })); - - callbacks?.onError?.(errorMessage); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - logger.error('[useInlineWizard] sendMessage error:', undefined, error); - - setTabState(tabId, (prev) => ({ - ...prev, - isWaiting: false, - error: errorMessage, - })); - - callbacks?.onError?.(errorMessage); - } - }, - [currentTabId, setTabState] // Depend on currentTabId and setTabState - ); - - /** - * Add an assistant response to the conversation. - * Uses the current tab ID to determine which wizard to update. - */ - const addAssistantMessage = useCallback( - (content: string, confidence?: number, ready?: boolean) => { - // Get the tab ID from the current state, ensure currentTabId is set for visibility - const tabId = currentTabId || 'default'; - if (tabId !== currentTabId) { - setCurrentTabId(tabId); - } - const message: InlineWizardMessage = { - id: generateMessageId(), - role: 'assistant', - content, - timestamp: Date.now(), - confidence, - ready, - }; - - setTabState(tabId, (prev) => ({ - ...prev, - conversationHistory: [...prev.conversationHistory, message], - // Update confidence and ready if provided - confidence: confidence !== undefined ? confidence : prev.confidence, - ready: ready !== undefined ? ready : prev.ready, - })); - }, - [currentTabId, setTabState] - ); - - /** - * Helper to get the effective tab ID and ensure currentTabId is set. - * This is used by setters to ensure state changes are visible via the hook's return values. - */ - const getEffectiveTabId = useCallback(() => { - const tabId = currentTabId || 'default'; - if (tabId !== currentTabId) { - setCurrentTabId(tabId); - } - return tabId; - }, [currentTabId]); - - /** - * Set the confidence level. - * Uses the current tab ID to determine which wizard to update. - */ - const setConfidence = useCallback( - (value: number) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - confidence: Math.max(0, Math.min(100, value)), - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set the wizard mode. - * If transitioning from 'ask' mode to 'new' or 'iterate', this will also - * initialize the conversation session (since it wasn't created during startWizard). - * Uses the current tab ID to determine which wizard to update. - */ - const setMode = useCallback( - (newMode: InlineWizardMode) => { - const tabId = getEffectiveTabId(); - const currentState = tabStatesRef.current.get(tabId); - - // If transitioning from 'ask' to 'new' or 'iterate', we need to create the conversation session - if ( - currentState?.mode === 'ask' && - (newMode === 'new' || newMode === 'iterate') && - !conversationSessionsMap.current.has(tabId) - ) { - // Create conversation session if we have the required info - // Use the stored autoRunFolderPath from state (configured by user or default) - const effectiveAutoRunFolderPath = - currentState.autoRunFolderPath || - (currentState.projectPath ? getAutoRunFolderPath(currentState.projectPath) : null); - - if ( - currentState.agentType && - hasCapabilityCached(currentState.agentType, 'supportsWizard') && - effectiveAutoRunFolderPath - ) { - // Use historyFilePath from state (fetched during startWizard) - const session = startInlineWizardConversation({ - mode: newMode, - agentType: currentState.agentType, - directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, - projectName: currentState.sessionName || 'Project', - goal: currentState.goal || undefined, - existingDocs: undefined, // Will be loaded separately if needed - autoRunFolderPath: effectiveAutoRunFolderPath, - sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, - sessionCustomPath: currentState.sessionCustomPath, - sessionCustomArgs: currentState.sessionCustomArgs, - sessionCustomEnvVars: currentState.sessionCustomEnvVars, - sessionCustomModel: currentState.sessionCustomModel, - conductorProfile: currentState.conductorProfile, - historyFilePath: currentState.historyFilePath, - }); - - conversationSessionsMap.current.set(tabId, session); - logger.info( - '[useInlineWizard] Conversation session started after mode selection:', - undefined, - session.sessionId - ); - } - } - - setTabState(tabId, (prev) => ({ - ...prev, - mode: newMode, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set the goal for iterate mode. - * Uses the current tab ID to determine which wizard to update. - */ - const setGoal = useCallback( - (goal: string | null) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - goal, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set whether documents are being generated. - * Uses the current tab ID to determine which wizard to update. - */ - const setGeneratingDocs = useCallback( - (generating: boolean) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: generating, - docGenerationStartedAt: generating - ? (prev.docGenerationStartedAt ?? Date.now()) - : prev.docGenerationStartedAt, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set generated documents. - * Uses the current tab ID to determine which wizard to update. - */ - const setGeneratedDocuments = useCallback( - (docs: InlineGeneratedDocument[]) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - generatedDocuments: docs, - isGeneratingDocs: false, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set existing documents (for iterate mode context). - * Uses the current tab ID to determine which wizard to update. - */ - const setExistingDocuments = useCallback( - (docs: ExistingDocument[]) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - existingDocuments: docs, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Set error message. - * Uses the current tab ID to determine which wizard to update. - */ - const setError = useCallback( - (error: string | null) => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - error, - })); - }, - [getEffectiveTabId, setTabState] - ); - - /** - * Clear the current error. - * Uses the current tab ID to determine which wizard to update. - */ - const clearError = useCallback(() => { - const tabId = getEffectiveTabId(); - setTabState(tabId, (prev) => ({ - ...prev, - error: null, - })); - }, [getEffectiveTabId, setTabState]); - - /** - * Retry sending the last user message that failed. - * Removes the failed user message from history and re-sends it. - * Uses the current tab ID to determine which wizard to update. - */ - const retryLastMessage = useCallback( - async (callbacks?: ConversationCallbacks): Promise => { - const tabId = currentTabId || 'default'; - const currentState = tabStatesRef.current.get(tabId); - const lastContent = currentState?.lastUserMessageContent; - - // Only retry if we have a last message and there's an error - if (!lastContent || !currentState?.error) { - logger.warn('[useInlineWizard] Cannot retry: no last message or no error'); - return; - } - - // Remove the last user message from history (it failed, so we'll re-add it) - // Find the last user message in history - const historyWithoutLastUser = [...(currentState.conversationHistory || [])]; - for (let i = historyWithoutLastUser.length - 1; i >= 0; i--) { - if (historyWithoutLastUser[i].role === 'user') { - historyWithoutLastUser.splice(i, 1); - break; - } - } - - // Clear error and update history - setTabState(tabId, (prev) => ({ - ...prev, - conversationHistory: historyWithoutLastUser, - error: null, - })); - - // Re-send the message (images not retained on retry) - await sendMessage(lastContent, undefined, callbacks); - }, - [currentTabId, setTabState, sendMessage] - ); - - /** - * Clear conversation history. - * Uses the current tab ID to determine which wizard to update. - */ - const clearConversation = useCallback(() => { - const tabId = currentTabId || 'default'; - setTabState(tabId, (prev) => ({ - ...prev, - conversationHistory: [], - })); - }, [currentTabId, setTabState]); - - /** - * Reset the wizard to initial state. - * Uses the current tab ID to determine which wizard to reset. - */ - const reset = useCallback(() => { - const tabId = currentTabId || 'default'; - - // Clean up conversation session for this tab - const session = conversationSessionsMap.current.get(tabId); - if (session) { - endInlineWizardConversation(session).catch(() => { - // Ignore cleanup errors during reset - }); - conversationSessionsMap.current.delete(tabId); - } - - // Clean up previous UI state ref for this tab - previousUIStateRefsMap.current.delete(tabId); - - // Remove the wizard state for this tab - setTabStates((prevMap) => { - const newMap = new Map(prevMap); - newMap.delete(tabId); - return newMap; - }); - }, [currentTabId]); - - /** - * Generate Auto Run documents based on the conversation. - * Uses the current tab ID to determine which wizard to update. - * - * This function: - * 1. Sets isGeneratingDocs to true - * 2. Constructs prompt using wizard-document-generation.md with conversation summary - * 3. Streams AI response - * 4. Parses document markers (---BEGIN DOCUMENT--- / ---END DOCUMENT---) - * 5. Saves documents via window.maestro.autorun.writeDoc() - * 6. Updates generatedDocuments array as each completes - */ - const generateDocuments = useCallback( - async (callbacks?: DocumentGenerationCallbacks, explicitTabId?: string): Promise => { - // Use explicit tabId if provided, otherwise fall back to currentTabId - const tabId = explicitTabId || currentTabId || 'default'; - const currentState = tabStatesRef.current.get(tabId); - - logger.info('Starting Playbook document generation', '[InlineWizard]', { - tabId, - agentType: currentState?.agentType, - mode: currentState?.mode, - conversationLength: currentState?.conversationHistory?.length || 0, - }); - - // If we're using a different tabId than currentTabId, update currentTabId - // so that errors and state changes are visible via the hook's return values - if (tabId !== currentTabId) { - setCurrentTabId(tabId); - } - - // Get the effective Auto Run folder path (stored in state from startWizard) - const effectiveAutoRunFolderPath = - currentState?.autoRunFolderPath || - (currentState?.projectPath ? getAutoRunFolderPath(currentState.projectPath) : null); - - // Validate we have the required state - if (!currentState?.agentType || !effectiveAutoRunFolderPath) { - const errorMsg = 'Cannot generate documents: missing agent type or Auto Run folder path'; - logger.error('[useInlineWizard]', undefined, errorMsg); - setTabState(tabId, (prev) => ({ ...prev, error: errorMsg })); - callbacks?.onError?.(errorMsg); - return; - } - - // Set generating state - reset streaming content and progress - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: true, - docGenerationStartedAt: Date.now(), - generatedDocuments: [], - error: null, - streamingContent: '', - generationProgress: null, - currentDocumentIndex: 0, - })); - - try { - // Call the document generation service with the effective Auto Run folder path. - // Prefer the AI-extracted project name from the conversation so the playbook - // folder reflects the feature (e.g. "HTML Chat Interface") rather than the - // agent's tab name (e.g. "rc" — typically a worktree/branch identifier). - const projectNameForGeneration = - currentState.extractedProjectName?.trim() || currentState.sessionName || 'Project'; - const result = await generateInlineDocuments({ - agentType: currentState.agentType, - directoryPath: currentState.projectPath || effectiveAutoRunFolderPath, - projectName: projectNameForGeneration, - conversationHistory: currentState.conversationHistory, - existingDocuments: currentState.existingDocuments, - mode: currentState.mode === 'iterate' ? 'iterate' : 'new', - goal: currentState.goal || undefined, - autoRunFolderPath: effectiveAutoRunFolderPath, - sessionId: currentState.sessionId || undefined, - sessionSshRemoteConfig: currentState.sessionSshRemoteConfig, - sessionCustomPath: currentState.sessionCustomPath, - sessionCustomArgs: currentState.sessionCustomArgs, - sessionCustomEnvVars: currentState.sessionCustomEnvVars, - sessionCustomModel: currentState.sessionCustomModel, - conductorProfile: currentState.conductorProfile, - callbacks: { - onStart: () => { - logger.info('[useInlineWizard] Document generation started'); - callbacks?.onStart?.(); - }, - onProgress: (message) => { - logger.info('[useInlineWizard] Progress:', undefined, message); - // Try to extract progress info from message (e.g., "Saving 1 of 3 document(s)...") - const progressMatch = message.match(/(\d+)\s+(?:of|\/)\s+(\d+)/); - if (progressMatch) { - const current = parseInt(progressMatch[1], 10); - const total = parseInt(progressMatch[2], 10); - setTabState(tabId, (prev) => ({ - ...prev, - generationProgress: { current, total }, - })); - } - callbacks?.onProgress?.(message); - }, - onChunk: (chunk) => { - // Parse the chunk to extract displayable text from JSON - // (Claude outputs stream-json format with content_block_delta events) - const displayText = extractDisplayTextFromChunk( - chunk, - currentState.agentType as ToolType - ); - - // Accumulate parsed streaming content for display - if (displayText) { - setTabState(tabId, (prev) => ({ - ...prev, - streamingContent: prev.streamingContent + displayText, - })); - } - callbacks?.onChunk?.(chunk); - }, - onDocumentComplete: (doc) => { - logger.info('[useInlineWizard] Document saved:', undefined, doc.filename); - // Add document to the list as it completes - // Update progress and select the newly created document - setTabState(tabId, (prev) => { - const newDocs = [...prev.generatedDocuments, doc]; - const newTotal = prev.generationProgress?.total || newDocs.length; - return { - ...prev, - generatedDocuments: newDocs, - // Select the newly created document in the UI - currentDocumentIndex: newDocs.length - 1, - // Update generationProgress - this syncs to SessionWizardState UI fields - generationProgress: { - current: newDocs.length, - total: newTotal, - }, - }; - }); - callbacks?.onDocumentComplete?.(doc); - }, - onComplete: (allDocs) => { - logger.info('[useInlineWizard] All documents complete:', undefined, allDocs.length); - // Set final state - mark generation as complete so UI shows Continue button - // Don't wait for the service function to return (it may be doing cleanup) - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: false, - generatedDocuments: allDocs, - generationProgress: { - current: allDocs.length, - total: allDocs.length, - }, - })); - callbacks?.onComplete?.(allDocs); - }, - onError: (error) => { - logger.error('[useInlineWizard] Generation error:', undefined, error); - callbacks?.onError?.(error); - }, - }, - }); - - if (result.success) { - // Update state with final documents - streaming content preserved for review - // Also capture subfolderName and subfolderPath for tab naming after wizard completes - const finalDocs = result.documents || []; - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: false, - generatedDocuments: finalDocs, - generationProgress: { - current: finalDocs.length, - total: finalDocs.length, - }, - // Store the subfolder name for tab naming (e.g., "Maestro-Marketing") - subfolderName: result.subfolderName || null, - // Store the full subfolder path for document loading (e.g., "/path/Auto Run Docs/Maestro-Marketing") - subfolderPath: result.subfolderPath || null, - })); - - logger.info( - `Playbook generation complete - ${finalDocs.length} document(s) created`, - '[InlineWizard]', - { - documentCount: finalDocs.length, - subfolderName: result.subfolderName, - filenames: finalDocs.map((d) => d.filename), - } - ); - } else { - // Handle error - clear streaming state - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: false, - error: result.error || 'Document generation failed', - streamingContent: '', - generationProgress: null, - })); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error during document generation'; - logger.error('[useInlineWizard] generateDocuments error:', undefined, error); - - // Clear streaming state on error - setTabState(tabId, (prev) => ({ - ...prev, - isGeneratingDocs: false, - error: errorMessage, - streamingContent: '', - generationProgress: null, - })); + const { + setTabStates, + currentTabId, + setCurrentTabId, + state, + tabStatesRef, + previousUIStateRefsMap, + conversationSessionsMap, + setTabState, + getStateForTab, + isWizardActiveForTab, + getEffectiveTabId, + wizardActiveSessions, + } = useInlineWizardTabState(); - callbacks?.onError?.(errorMessage); - } - }, - [currentTabId, setTabState] - ); + const { + setConfidence, + setGoal, + setGeneratingDocs, + setGeneratedDocuments, + setExistingDocuments, + setError, + clearError, + } = useInlineWizardSimpleActions({ + getEffectiveTabId, + setTabState, + }); + + const { endWizard, reset } = useInlineWizardLifecycleActions({ + currentTabId, + setTabStates, + previousUIStateRefsMap, + conversationSessionsMap, + }); + + const { + startWizard, + sendMessage, + addAssistantMessage, + setMode, + retryLastMessage, + clearConversation, + } = useInlineWizardConversationActions({ + currentTabId, + setCurrentTabId, + tabStatesRef, + previousUIStateRefsMap, + conversationSessionsMap, + setTabState, + getEffectiveTabId, + }); + + const { generateDocuments } = useInlineWizardGenerationActions({ + currentTabId, + setCurrentTabId, + tabStatesRef, + setTabState, + }); - // Compute readyToGenerate based on ready flag and confidence threshold const readyToGenerate = state.ready && state.confidence >= READY_CONFIDENCE_THRESHOLD; - // Derived: sessions with at least one active-wizard tab, plus an OR-aggregate - // of `isGeneratingDocs` across that session's wizard tabs. Consumed by the - // Left Bar to render a wand glyph on agent rows and group headers without - // having to crack open per-tab state. - const wizardActiveSessions = useMemo(() => { - const map = new Map(); - for (const tabState of tabStates.values()) { - if (!tabState.isActive || !tabState.sessionId) continue; - const existing = map.get(tabState.sessionId); - map.set(tabState.sessionId, { - isGeneratingDocs: (existing?.isGeneratingDocs ?? false) || tabState.isGeneratingDocs, - }); - } - return map; - }, [tabStates]); - - // NOTE: We intentionally do NOT auto-send an initial greeting anymore. - // The user should always see the static welcome screen first and choose - // to start the conversation by typing their first message. - // This was previously auto-sending "Hello! I want to create a new Playbook." - // which was confusing when users expected to see the welcome screen. - return { - // Convenience accessors (for current/active tab) isWizardActive: state.isActive, isInitializing: state.isInitializing, isWaiting: state.isWaiting, @@ -1516,16 +106,10 @@ export function useInlineWizard(): UseInlineWizardReturn { generationProgress: state.generationProgress, wizardTabId: state.tabId, agentSessionId: state.agentSessionId, - - // Full state (for current/active tab) state, - - // Per-tab state accessors getStateForTab, isWizardActiveForTab, wizardActiveSessions, - - // Actions startWizard, endWizard, sendMessage, diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index ef1ef8ba3d..520ff270bb 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -10,7 +10,7 @@ */ import type { ToolType, ProcessConfig } from '../types'; -import type { InlineWizardMessage } from '../hooks/batch/useInlineWizard'; +import type { InlineWizardMessage } from '../hooks/batch/inlineWizard/types'; import type { ExistingDocument as BaseExistingDocument } from '../utils/existingDocsDetector'; import { logger } from '../utils/logger'; import { getStdinFlags } from '../utils/spawnHelpers'; diff --git a/src/renderer/services/inlineWizardDocumentGeneration.ts b/src/renderer/services/inlineWizardDocumentGeneration.ts index 9475ebd202..9ded5132bf 100644 --- a/src/renderer/services/inlineWizardDocumentGeneration.ts +++ b/src/renderer/services/inlineWizardDocumentGeneration.ts @@ -10,7 +10,10 @@ */ import type { ToolType } from '../types'; -import type { InlineWizardMessage, InlineGeneratedDocument } from '../hooks/batch/useInlineWizard'; +import type { + InlineGeneratedDocument, + InlineWizardMessage, +} from '../hooks/batch/inlineWizard/types'; import type { ExistingDocument } from '../utils/existingDocsDetector'; import { logger } from '../utils/logger'; import { getStdinFlags } from '../utils/spawnHelpers';