diff --git a/src/routes/chat/components/header-actions/HeaderButton.tsx b/src/routes/chat/components/header-actions/HeaderButton.tsx
new file mode 100644
index 00000000..41a79470
--- /dev/null
+++ b/src/routes/chat/components/header-actions/HeaderButton.tsx
@@ -0,0 +1,44 @@
+import type { LucideIcon } from 'lucide-react';
+
+interface HeaderButtonProps {
+ icon: LucideIcon;
+ label?: string;
+ onClick: () => void;
+ title?: string;
+ iconOnly?: boolean;
+}
+
+export function HeaderButton({
+ icon: Icon,
+ label,
+ onClick,
+ title,
+ iconOnly = false,
+}: HeaderButtonProps) {
+ if (iconOnly) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/routes/chat/components/header-actions/HeaderDivider.tsx b/src/routes/chat/components/header-actions/HeaderDivider.tsx
new file mode 100644
index 00000000..69ea543d
--- /dev/null
+++ b/src/routes/chat/components/header-actions/HeaderDivider.tsx
@@ -0,0 +1,3 @@
+export function HeaderDivider() {
+ return
;
+}
diff --git a/src/routes/chat/components/header-actions/HeaderToggleButton.tsx b/src/routes/chat/components/header-actions/HeaderToggleButton.tsx
new file mode 100644
index 00000000..230e1aa1
--- /dev/null
+++ b/src/routes/chat/components/header-actions/HeaderToggleButton.tsx
@@ -0,0 +1,43 @@
+import type { LucideIcon } from 'lucide-react';
+import clsx from 'clsx';
+
+interface HeaderToggleButtonProps {
+ icon: LucideIcon;
+ label?: string;
+ onClick: () => void;
+ title?: string;
+ active?: boolean;
+}
+
+export function HeaderToggleButton({
+ icon: Icon,
+ label,
+ onClick,
+ title,
+ active = false,
+}: HeaderToggleButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/routes/chat/components/header-actions/index.ts b/src/routes/chat/components/header-actions/index.ts
new file mode 100644
index 00000000..548edfd3
--- /dev/null
+++ b/src/routes/chat/components/header-actions/index.ts
@@ -0,0 +1,3 @@
+export { HeaderButton } from './HeaderButton';
+export { HeaderToggleButton } from './HeaderToggleButton';
+export { HeaderDivider } from './HeaderDivider';
diff --git a/src/routes/chat/components/main-content-panel.tsx b/src/routes/chat/components/main-content-panel.tsx
new file mode 100644
index 00000000..da5d8fb3
--- /dev/null
+++ b/src/routes/chat/components/main-content-panel.tsx
@@ -0,0 +1,326 @@
+import { type RefObject, type ReactNode } from 'react';
+import { WebSocket } from 'partysocket';
+import { MonacoEditor } from '../../../components/monaco-editor/monaco-editor';
+import { motion } from 'framer-motion';
+import { RefreshCw } from 'lucide-react';
+import { Blueprint } from './blueprint';
+import { FileExplorer } from './file-explorer';
+import { PreviewIframe } from './preview-iframe';
+import { MarkdownDocsPreview } from './markdown-docs-preview';
+import { ViewContainer } from './view-container';
+import { ViewHeader } from './view-header';
+import { PreviewHeaderActions } from './preview-header-actions';
+import { EditorHeaderActions } from './editor-header-actions';
+import { Copy } from './copy';
+import { PresentationPreview } from './presentation-preview';
+import type { FileType, BlueprintType, BehaviorType, ModelConfigsInfo, TemplateDetails } from '@/api-types';
+import type { ContentDetectionResult } from '../utils/content-detector';
+import type { GitHubExportHook } from '@/hooks/use-github-export';
+import type { Edit } from '../hooks/use-chat';
+
+interface MainContentPanelProps {
+ // View state
+ view: 'editor' | 'preview' | 'docs' | 'blueprint' | 'terminal' | 'presentation';
+ onViewChange: (mode: 'preview' | 'editor' | 'docs' | 'blueprint' | 'presentation') => void;
+
+ // Content detection
+ hasDocumentation: boolean;
+ contentDetection: ContentDetectionResult;
+
+ // Preview state
+ projectType: string;
+ previewUrl?: string;
+ previewAvailable: boolean;
+ showTooltip: boolean;
+ shouldRefreshPreview: boolean;
+ manualRefreshTrigger: number;
+ onManualRefresh: () => void;
+
+ // Blueprint
+ blueprint?: BlueprintType | null;
+
+ // Editor state
+ activeFile?: FileType;
+ allFiles: FileType[];
+ edit?: Edit | null;
+ onFileClick: (file: FileType) => void;
+
+ // Generation state
+ isGenerating: boolean;
+ isGeneratingBlueprint: boolean;
+
+ // Model configs
+ modelConfigs?: ModelConfigsInfo;
+ loadingConfigs: boolean;
+ onRequestConfigs: () => void;
+
+ // Git/GitHub actions
+ onGitCloneClick: () => void;
+ isGitHubExportReady: boolean;
+ githubExport: GitHubExportHook;
+
+ // Presentation controls
+ presentationSpeakerMode?: boolean;
+ presentationPreviewMode?: boolean;
+ onToggleSpeakerMode?: () => void;
+ onTogglePreviewMode?: () => void;
+ onExportPdf?: () => void;
+
+ // Template metadata
+ templateDetails?: TemplateDetails | null;
+
+ // Other
+ behaviorType?: BehaviorType;
+ urlChatId?: string;
+ isPhase1Complete: boolean;
+ websocket?: WebSocket;
+
+ // Refs
+ previewRef: RefObject
;
+ editorRef: RefObject;
+}
+
+export function MainContentPanel(props: MainContentPanelProps) {
+ const {
+ view,
+ onViewChange,
+ hasDocumentation,
+ contentDetection,
+ projectType,
+ previewUrl,
+ previewAvailable,
+ showTooltip,
+ shouldRefreshPreview,
+ manualRefreshTrigger,
+ onManualRefresh,
+ blueprint,
+ activeFile,
+ allFiles,
+ edit,
+ onFileClick,
+ isGenerating,
+ isGeneratingBlueprint,
+ modelConfigs,
+ loadingConfigs,
+ onRequestConfigs,
+ onGitCloneClick,
+ isGitHubExportReady,
+ githubExport,
+ presentationSpeakerMode,
+ presentationPreviewMode,
+ onToggleSpeakerMode,
+ onTogglePreviewMode,
+ onExportPdf,
+ behaviorType,
+ urlChatId,
+ isPhase1Complete,
+ websocket,
+ previewRef,
+ editorRef,
+ templateDetails,
+ } = props;
+
+ const commonHeaderProps = {
+ view: view as 'preview' | 'editor' | 'docs' | 'blueprint' | 'presentation',
+ onViewChange,
+ previewAvailable,
+ showTooltip,
+ hasDocumentation,
+ previewUrl,
+ projectType,
+ };
+
+ const renderViewWithHeader = (
+ centerContent: ReactNode,
+ viewContent: ReactNode,
+ rightActions?: ReactNode,
+ headerOverrides?: Partial
+ ) => (
+
+
+ {viewContent}
+
+ );
+
+ const renderDocsView = () => {
+ if (!hasDocumentation) return null;
+
+ const markdownFiles = Object.values(contentDetection.Contents)
+ .filter(bundle => bundle.type === 'markdown')
+ .flatMap(bundle => bundle.files);
+
+ if (markdownFiles.length === 0) return null;
+
+ return renderViewWithHeader(
+ Documentation,
+
+ );
+ };
+
+ const renderPreviewView = () => {
+ if (!previewUrl) {
+ console.log('Preview not available');
+ return null;
+ }
+
+ // Use PresentationPreview for presentations, regular PreviewIframe for apps
+ const isPresentation = projectType === 'presentation';
+
+ return renderViewWithHeader(
+
+
+ {blueprint?.title ?? (isPresentation ? 'Presentation' : 'Preview')}
+
+
+ {!isPresentation && (
+
+ )}
+
,
+ isPresentation ? (
+
+ ) : (
+
+ ),
+
+ );
+ };
+
+ const renderBlueprintView = () =>
+ renderViewWithHeader(
+
+ Blueprint.md
+ {previewUrl && }
+
,
+
+ );
+
+ const renderEditorView = () => {
+ if (!activeFile) return null;
+
+ return renderViewWithHeader(
+
+ {activeFile.filePath}
+ {previewUrl && }
+
,
+ ,
+
+ );
+ };
+
+ const renderView = () => {
+ switch (view) {
+ case 'docs':
+ return renderDocsView();
+ case 'preview':
+ case 'presentation': // Presentations now use preview view
+ return renderPreviewView();
+ case 'blueprint':
+ return renderBlueprintView();
+ case 'editor':
+ return renderEditorView();
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {renderView()}
+
+ );
+}
diff --git a/src/routes/chat/components/markdown-docs-preview.css b/src/routes/chat/components/markdown-docs-preview.css
new file mode 100644
index 00000000..9c800ca8
--- /dev/null
+++ b/src/routes/chat/components/markdown-docs-preview.css
@@ -0,0 +1,156 @@
+@media print {
+ /* Hide navigation and UI elements */
+ .docs-sidebar,
+ .export-buttons,
+ button,
+ [role="navigation"],
+ [role="toolbar"],
+ .Toastier {
+ display: none !important;
+ }
+
+ /* Optimize main content */
+ .prose {
+ max-width: 100% !important;
+ color: #000 !important;
+ background: #fff !important;
+ }
+
+ /* Typography optimization */
+ body {
+ background: white !important;
+ color: black !important;
+ }
+
+ article {
+ width: 100% !important;
+ }
+
+ /* Heading page breaks */
+ h1 {
+ page-break-before: always;
+ page-break-after: avoid;
+ margin-top: 0 !important;
+ }
+
+ h2,
+ h3 {
+ page-break-inside: avoid;
+ page-break-after: avoid;
+ }
+
+ /* Paragraph widow/orphan control */
+ p {
+ widows: 3 !important;
+ orphans: 3 !important;
+ page-break-inside: avoid;
+ }
+
+ /* Code blocks */
+ pre {
+ page-break-inside: avoid;
+ border: 1px solid #ccc !important;
+ background: #f5f5f5 !important;
+ padding: 12px !important;
+ overflow-x: auto;
+ }
+
+ code {
+ color: #000 !important;
+ background: transparent !important;
+ }
+
+ /* Tables */
+ table {
+ page-break-inside: avoid;
+ border-collapse: collapse;
+ width: 100%;
+ }
+
+ th,
+ td {
+ border: 1px solid #ddd !important;
+ padding: 8px !important;
+ text-align: left;
+ }
+
+ th {
+ background: #f0f0f0 !important;
+ font-weight: bold;
+ }
+
+ /* List formatting */
+ ul,
+ ol {
+ page-break-inside: avoid;
+ }
+
+ li {
+ page-break-inside: avoid;
+ }
+
+ /* Links - make URLs visible */
+ a {
+ color: #0066cc !important;
+ text-decoration: underline;
+ }
+
+ a[href]::after {
+ content: " (" attr(href) ")";
+ font-size: 0.85em;
+ color: #666;
+ word-break: break-all;
+ }
+
+ /* Blockquotes */
+ blockquote {
+ page-break-inside: avoid;
+ border-left: 4px solid #ccc;
+ padding-left: 12px;
+ color: #333 !important;
+ background: #f9f9f9 !important;
+ }
+
+ /* Images */
+ img {
+ max-width: 100%;
+ height: auto;
+ page-break-inside: avoid;
+ }
+
+ /* Margins for print */
+ body {
+ margin: 0;
+ padding: 0;
+ }
+
+ article {
+ margin: 0;
+ padding: 0;
+ }
+
+ /* Prevent color printing issues */
+ * {
+ -webkit-print-color-adjust: exact !important;
+ print-color-adjust: exact !important;
+ color-adjust: exact !important;
+ }
+
+ /* Page size and margins */
+ @page {
+ margin: 2cm;
+ size: A4;
+ }
+
+ /* Hide empty pages */
+ .no-print {
+ display: none !important;
+ }
+}
+
+/* Print button styling (hide only during print) */
+.export-button-container {
+ print {
+ display: none !important;
+ }
+}
diff --git a/src/routes/chat/components/markdown-docs-preview.tsx b/src/routes/chat/components/markdown-docs-preview.tsx
new file mode 100644
index 00000000..455233c6
--- /dev/null
+++ b/src/routes/chat/components/markdown-docs-preview.tsx
@@ -0,0 +1,229 @@
+import { useState, useEffect, useMemo, useRef, useCallback, type ReactNode } from 'react';
+import { Loader, FileText, FileDown } from 'lucide-react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import rehypeExternalLinks from 'rehype-external-links';
+import { DocsSidebar } from './docs-sidebar';
+import { ExportButton } from './export-button';
+import { exportMarkdownAsFile } from '@/utils/markdown-export';
+import type { FileType } from '@/api-types';
+import './markdown-docs-preview.css';
+
+interface MarkdownDocsPreviewProps {
+ files: FileType[];
+ isGenerating: boolean;
+}
+
+export function MarkdownDocsPreview({
+ files,
+ isGenerating: _isGenerating,
+}: MarkdownDocsPreviewProps) {
+ // Prioritize README as default, otherwise first file
+ const defaultFile = useMemo(() => {
+ const readmeFile = files.find((f) =>
+ f.filePath.toLowerCase().includes('readme')
+ );
+ return readmeFile || files[0];
+ }, [files]);
+
+ const [activeFilePath, setActiveFilePath] = useState(
+ defaultFile?.filePath || ''
+ );
+
+ // Update active file if default changes
+ useEffect(() => {
+ if (defaultFile && !activeFilePath) {
+ setActiveFilePath(defaultFile.filePath);
+ }
+ }, [defaultFile, activeFilePath]);
+
+ const activeFile = files.find((f) => f.filePath === activeFilePath);
+
+ // Ref for print export
+ const contentRef = useRef(null);
+
+ // Export handlers
+ const handleExportMarkdown = useCallback(() => {
+ if (!activeFile) return;
+ const filename = activeFile.filePath.split('/').pop() || 'documentation.md';
+ exportMarkdownAsFile(activeFile.fileContents || '', filename);
+ }, [activeFile]);
+
+ const handlePrint = useCallback(() => {
+ window.print();
+ }, []);
+
+ // Extract table of contents from markdown headings
+ const tableOfContents = useMemo(() => {
+ if (!activeFile?.fileContents) return [];
+
+ const headingRegex = /^(#{1,3})\s+(.+)$/gm;
+ const headings: { level: number; text: string; id: string }[] = [];
+ let match;
+
+ while ((match = headingRegex.exec(activeFile.fileContents)) !== null) {
+ const level = match[1].length;
+ const text = match[2];
+ const id = text
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-');
+
+ headings.push({ level, text, id });
+ }
+
+ return headings;
+ }, [activeFile?.fileContents]);
+
+ const handleFileSelect = (filePath: string) => {
+ setActiveFilePath(filePath);
+ };
+
+ const markdownContent = useMemo(() => {
+ if (!activeFile) return '';
+
+ let content = activeFile.fileContents || '';
+
+ // Add generating indicator if still streaming
+ if (activeFile.isGenerating && content) {
+ content += '\n\n_Generating..._';
+ }
+
+ return content;
+ }, [activeFile]);
+
+ return (
+
+ {/* Sidebar */}
+
+
+ {/* Main content area */}
+
+ {/* Header */}
+
+ {/* Left: File name and status */}
+
+
+ {activeFile?.filePath || 'Documentation'}
+
+ {activeFile?.isGenerating && (
+
+
+ Generating...
+
+ )}
+
+
+ {/* Right: Export buttons */}
+
+
+
+
+
+
+ {/* Content with TOC */}
+
+ {/* Main markdown content */}
+
+ {!activeFile ? (
+
+
No documentation file selected
+
+ ) : !markdownContent ? (
+
+
+
Waiting for content...
+
+ ) : (
+
+ (
+
+ ),
+ h2: ({ node, ...props }) => (
+
+ ),
+ h3: ({ node, ...props }) => (
+
+ ),
+ }}
+ >
+ {markdownContent}
+
+
+ )}
+
+
+ {/* Table of contents (if headings exist) */}
+ {tableOfContents.length > 0 && (
+
+
+ On This Page
+
+
+
+ )}
+
+
+
+ );
+}
+
+/**
+ * Create ID from heading text for anchor links
+ */
+function createId(children: ReactNode): string {
+ const text = extractText(children);
+ return text
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-');
+}
+
+/**
+ * Extract text from React children
+ */
+function extractText(children: ReactNode): string {
+ if (typeof children === 'string') return children;
+ if (Array.isArray(children)) return children.map(extractText).join('');
+ if (children && typeof children === 'object' && 'props' in children) {
+ const element = children as { props: { children?: ReactNode } };
+ return extractText(element.props.children);
+ }
+ return '';
+}
diff --git a/src/routes/chat/components/model-config-info.tsx b/src/routes/chat/components/model-config-info.tsx
index 4dda644d..0b74a35a 100644
--- a/src/routes/chat/components/model-config-info.tsx
+++ b/src/routes/chat/components/model-config-info.tsx
@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import type { AgentDisplayConfig, ModelConfig, UserModelConfigWithMetadata } from '@/api-types';
+import type { AgentDisplayConfig, ModelConfigsInfo } from '@/api-types';
// Reuse workflow tabs from settings (DRY principle)
const WORKFLOW_TABS = {
@@ -116,14 +116,14 @@ const getProviderInfo = (modelValue?: string) => {
};
// Simplified config card for read-only display
-function ConfigInfoCard({
- agent,
- userConfig,
- defaultConfig
-}: {
- agent: AgentDisplayConfig;
- userConfig?: UserModelConfigWithMetadata;
- defaultConfig?: ModelConfig;
+function ConfigInfoCard({
+ agent,
+ userConfig,
+ defaultConfig
+}: {
+ agent: AgentDisplayConfig;
+ userConfig?: ModelConfigsInfo['userConfigs'][string];
+ defaultConfig?: ModelConfigsInfo['defaultConfigs'][string];
}) {
const isCustomized = userConfig?.isUserOverride || false;
const currentModel = userConfig?.name || defaultConfig?.name;
@@ -189,11 +189,7 @@ function ConfigInfoCard({
}
interface ModelConfigInfoProps {
- configs?: {
- agents: AgentDisplayConfig[];
- userConfigs: Record;
- defaultConfigs: Record;
- };
+ configs?: ModelConfigsInfo;
onRequestConfigs: () => void;
loading?: boolean;
}
diff --git a/src/routes/chat/components/phase-timeline.tsx b/src/routes/chat/components/phase-timeline.tsx
index 563d596f..54f6f5c3 100644
--- a/src/routes/chat/components/phase-timeline.tsx
+++ b/src/routes/chat/components/phase-timeline.tsx
@@ -3,7 +3,8 @@ import { Loader, Check, AlertCircle, ChevronDown, ChevronRight, ArrowUp, Zap, XC
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import type { RefObject } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
-import type { PhaseTimelineItem, FileType } from '../hooks/use-chat';
+import type { PhaseTimelineItem } from '../hooks/use-chat';
+import type { FileType } from '@/api-types';
import { ThinkingIndicator } from './thinking-indicator';
import type { ProjectStage } from '../utils/project-stage-helpers';
diff --git a/src/routes/chat/components/presentation-header-actions.tsx b/src/routes/chat/components/presentation-header-actions.tsx
new file mode 100644
index 00000000..726b239f
--- /dev/null
+++ b/src/routes/chat/components/presentation-header-actions.tsx
@@ -0,0 +1,81 @@
+import { FileDown, User, Monitor, Maximize2 } from 'lucide-react';
+
+interface PresentationHeaderActionsProps {
+ onExportPdf: () => void;
+ onToggleSpeakerMode?: () => void;
+ onTogglePreviewMode?: () => void;
+ onToggleFullscreen?: () => void;
+ speakerMode?: boolean;
+ previewMode?: boolean;
+ fullscreen?: boolean;
+}
+
+export function PresentationHeaderActions({
+ onExportPdf,
+ onToggleSpeakerMode,
+ onTogglePreviewMode,
+ onToggleFullscreen,
+ speakerMode,
+ previewMode,
+ fullscreen,
+}: PresentationHeaderActionsProps) {
+ return (
+
+ {onToggleSpeakerMode && (
+
+ )}
+
+ {onTogglePreviewMode && (
+
+ )}
+
+ {onToggleFullscreen && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/routes/chat/components/presentation-hooks/index.ts b/src/routes/chat/components/presentation-hooks/index.ts
new file mode 100644
index 00000000..d5f21599
--- /dev/null
+++ b/src/routes/chat/components/presentation-hooks/index.ts
@@ -0,0 +1,10 @@
+export { usePresentationFiles } from './use-presentation-files';
+export { usePresentationSync } from './use-presentation-sync';
+export { useIframeMessaging } from './use-iframe-messaging';
+export { useThumbnailObserver } from './use-thumbnail-observer';
+export type {
+ SlideInfo,
+ PresentationPreviewProps,
+ PresentationTimestamps,
+ PresentationState
+} from './types';
diff --git a/src/routes/chat/components/presentation-hooks/types.ts b/src/routes/chat/components/presentation-hooks/types.ts
new file mode 100644
index 00000000..a31f9254
--- /dev/null
+++ b/src/routes/chat/components/presentation-hooks/types.ts
@@ -0,0 +1,35 @@
+import type { FileType, TemplateDetails } from '@/api-types';
+import { WebSocket } from 'partysocket';
+
+export interface SlideInfo {
+ index: number;
+ fileName: string;
+ filePath: string;
+}
+
+export interface PresentationPreviewProps {
+ previewUrl: string;
+ className?: string;
+ shouldRefreshPreview?: boolean;
+ manualRefreshTrigger?: number;
+ webSocket?: WebSocket | null;
+ speakerMode?: boolean;
+ previewMode?: boolean;
+ allFiles?: FileType[];
+ templateDetails?: TemplateDetails | null;
+}
+
+export interface PresentationTimestamps {
+ global: number;
+ main: number;
+ slides: Record;
+}
+
+export interface PresentationState {
+ currentSlideIndex: number;
+ setCurrentSlideIndex: (index: number) => void;
+ timestamps: PresentationTimestamps;
+ generatingSlides: Set;
+ failedIframes: Set;
+ setFailedIframes: React.Dispatch>>;
+}
diff --git a/src/routes/chat/components/presentation-hooks/use-iframe-messaging.ts b/src/routes/chat/components/presentation-hooks/use-iframe-messaging.ts
new file mode 100644
index 00000000..41176275
--- /dev/null
+++ b/src/routes/chat/components/presentation-hooks/use-iframe-messaging.ts
@@ -0,0 +1,99 @@
+import { useEffect, useCallback } from 'react';
+import type { SlideInfo } from './types';
+
+export function useIframeMessaging(
+ iframeRef: React.RefObject,
+ thumbnailRefs: React.RefObject