diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index dfe820f7..64ba94bb 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -50,6 +50,9 @@ module.exports = { 'style', 'aria-describedby', ], + 'jsx-components': { + exclude: ['code'], + }, }, ] : 'off', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95885565..25d94156 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { import { ThemeProvider } from '@/components/theme-provider'; import { SearchProvider } from '@/contexts/search-context'; import { KeyboardShortcutsProvider } from '@/contexts/keyboard-shortcuts-context'; + import { ShortcutsHelp } from '@/components/shortcuts-help'; import { HotkeysProvider } from 'react-hotkeys-hook'; @@ -34,6 +35,7 @@ import { WebviewContextMenu } from '@/vscode/ContextMenu'; import { DevBanner } from '@/components/DevBanner'; import NiceModal from '@ebay/nice-modal-react'; import { OnboardingResult } from './components/dialogs/global/OnboardingDialog'; +import { ClickedElementsProvider } from './contexts/ClickedElementsProvider'; const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes); @@ -204,15 +206,17 @@ function App() { return ( - - - - - - - - - + + + + + + + + + + + ); diff --git a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx index 68bf45c1..fe5d6887 100644 --- a/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx +++ b/frontend/src/components/dialogs/projects/ProjectFormDialog.tsx @@ -12,9 +12,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { TaskTemplateManager } from '@/components/TaskTemplateManager'; import { ProjectFormFields } from '@/components/projects/project-form-fields'; import { CreateProject, Project, UpdateProject } from 'shared/types'; -import { projectsApi } from '@/lib/api'; import { generateProjectNameFromPath } from '@/utils/string'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; +import { useProjectMutations } from '@/hooks/useProjectMutations'; export interface ProjectFormDialogProps { project?: Project | null; @@ -35,7 +35,6 @@ export const ProjectFormDialog = NiceModal.create( project?.cleanup_script ?? '' ); const [copyFiles, setCopyFiles] = useState(project?.copy_files ?? ''); - const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing'); const [parentPath, setParentPath] = useState(''); @@ -43,6 +42,23 @@ export const ProjectFormDialog = NiceModal.create( const isEditing = !!project; + const { createProject, updateProject } = useProjectMutations({ + onCreateSuccess: () => { + modal.resolve('saved' as ProjectFormDialogResult); + modal.hide(); + }, + onUpdateSuccess: () => { + modal.resolve('saved' as ProjectFormDialogResult); + modal.hide(); + }, + onCreateError: (err) => { + setError(err instanceof Error ? err.message : 'An error occurred'); + }, + onUpdateError: (err) => { + setError(err instanceof Error ? err.message : 'An error occurred'); + }, + }); + // Update form fields when project prop changes useEffect(() => { if (project) { @@ -76,79 +92,60 @@ export const ProjectFormDialog = NiceModal.create( // Handle direct project creation from repo selection const handleDirectCreate = async (path: string, suggestedName: string) => { setError(''); - setLoading(true); - try { + const createData: CreateProject = { + name: suggestedName, + git_repo_path: path, + use_existing_repo: true, + setup_script: null, + dev_script: null, + cleanup_script: null, + copy_files: null, + }; + + createProject.mutate(createData); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + let finalGitRepoPath = gitRepoPath; + if (repoMode === 'new') { + const effectiveParentPath = parentPath.trim(); + const cleanFolderName = folderName.trim(); + finalGitRepoPath = effectiveParentPath + ? `${effectiveParentPath}/${cleanFolderName}`.replace(/\/+/g, '/') + : cleanFolderName; + } + // Auto-populate name from git repo path if not provided + const finalName = + name.trim() || generateProjectNameFromPath(finalGitRepoPath); + + if (isEditing && project) { + const updateData: UpdateProject = { + name: finalName, + git_repo_path: finalGitRepoPath, + setup_script: setupScript.trim() || null, + dev_script: devScript.trim() || null, + cleanup_script: cleanupScript.trim() || null, + copy_files: copyFiles.trim() || null, + }; + + updateProject.mutate({ projectId: project.id, data: updateData }); + } else { + // Creating new project const createData: CreateProject = { - name: suggestedName, - git_repo_path: path, - use_existing_repo: true, + name: finalName, + git_repo_path: finalGitRepoPath, + use_existing_repo: repoMode === 'existing', setup_script: null, dev_script: null, cleanup_script: null, copy_files: null, }; - await projectsApi.create(createData); - modal.resolve('saved' as ProjectFormDialogResult); - modal.hide(); - } catch (error) { - setError(error instanceof Error ? error.message : 'An error occurred'); - } finally { - setLoading(false); - } - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(''); - setLoading(true); - - try { - let finalGitRepoPath = gitRepoPath; - if (repoMode === 'new') { - const effectiveParentPath = parentPath.trim(); - const cleanFolderName = folderName.trim(); - finalGitRepoPath = effectiveParentPath - ? `${effectiveParentPath}/${cleanFolderName}`.replace(/\/+/g, '/') - : cleanFolderName; - } - // Auto-populate name from git repo path if not provided - const finalName = - name.trim() || generateProjectNameFromPath(finalGitRepoPath); - - if (isEditing) { - const updateData: UpdateProject = { - name: finalName, - git_repo_path: finalGitRepoPath, - setup_script: setupScript.trim() || null, - dev_script: devScript.trim() || null, - cleanup_script: cleanupScript.trim() || null, - copy_files: copyFiles.trim() || null, - }; - - await projectsApi.update(project!.id, updateData); - } else { - // Creating new project - const createData: CreateProject = { - name: finalName, - git_repo_path: finalGitRepoPath, - use_existing_repo: repoMode === 'existing', - setup_script: null, - dev_script: null, - cleanup_script: null, - copy_files: null, - }; - - await projectsApi.create(createData); - } - - modal.resolve('saved' as ProjectFormDialogResult); - modal.hide(); - } catch (error) { - setError(error instanceof Error ? error.message : 'An error occurred'); - } finally { - setLoading(false); + createProject.mutate(createData); } }; @@ -230,9 +227,11 @@ export const ProjectFormDialog = NiceModal.create( @@ -273,9 +272,11 @@ export const ProjectFormDialog = NiceModal.create( )} diff --git a/frontend/src/components/tasks/ClickedElementsBanner.tsx b/frontend/src/components/tasks/ClickedElementsBanner.tsx new file mode 100644 index 00000000..25eea8e9 --- /dev/null +++ b/frontend/src/components/tasks/ClickedElementsBanner.tsx @@ -0,0 +1,163 @@ +import { + MousePointerClick, + Trash2, + ArrowBigLeft, + MoreHorizontal, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import type { ClickedEntry } from '@/contexts/ClickedElementsProvider'; +import { useState, useMemo } from 'react'; +import { Badge } from '../ui/badge'; +import { useClickedElements } from '@/contexts/ClickedElementsProvider'; + +export type Props = Readonly<{ + isEditable: boolean; + appendInstructions?: (text: string) => void; +}>; + +const MAX_VISIBLE_ELEMENTS = 5; +const MAX_BADGES = 6; + +// Build component chain from inner-most to outer-most for banner display +function buildChainInnerToOuterForBanner(entry: ClickedEntry) { + const comps = entry.payload.components ?? []; + const s = entry.payload.selected; + + // Start with selected as innermost, cast to ComponentInfo for uniform handling + const innerToOuter = [s as any]; + + // Add components that aren't duplicates + const selectedKey = `${s.name}|${s.pathToSource}|${s.source?.lineNumber}|${s.source?.columnNumber}`; + comps.forEach((c) => { + const compKey = `${c.name}|${c.pathToSource}|${c.source?.lineNumber}|${c.source?.columnNumber}`; + if (compKey !== selectedKey) { + innerToOuter.push(c); + } + }); + + return innerToOuter; +} + +function getVisibleElements( + elements: ClickedEntry[], + max = MAX_VISIBLE_ELEMENTS +): { visible: ClickedEntry[]; total: number; hasMore: boolean } { + // Show most recent elements first + const reversed = [...elements].reverse(); + const visible = reversed.slice(0, max); + return { + visible, + total: elements.length, + hasMore: elements.length > visible.length, + }; +} + +export function ClickedElementsBanner() { + const [isExpanded] = useState(false); + const { elements, removeElement } = useClickedElements(); + + // Early return if no elements + if (elements.length === 0) return null; + + const { visible: visibleElements } = getVisibleElements( + elements, + isExpanded ? elements.length : MAX_VISIBLE_ELEMENTS + ); + + return ( +
+ {visibleElements.map((element) => { + return ( + removeElement(element.id)} + /> + ); + })} +
+ ); +} + +const ClickedEntryCard = ({ + element, + onDelete, +}: { + element: ClickedEntry; + onDelete: () => void; +}) => { + const { selectComponent } = useClickedElements(); + const chain = useMemo( + () => buildChainInnerToOuterForBanner(element), + [element] + ); + const selectedDepth = element.selectedDepth ?? 0; + + // Truncate from the right side (outermost components), keep leftmost (innermost) + const overflowRight = Math.max(0, chain.length - MAX_BADGES); + const display = chain.slice(0, MAX_BADGES); + + const handleSelect = (visibleIdx: number) => { + // Since we kept the leftmost items as-is, visibleIdx === depthFromInner + selectComponent(element.id, visibleIdx); + }; + + return ( +
+ + +
+ {display.map((component, i) => { + const depthFromInner = i; // Simple mapping since we keep left side + const isDownstream = depthFromInner < selectedDepth; + const isSelected = depthFromInner === selectedDepth; + + return ( +
+ {i > 0 && ( + + )} + +
+ ); + })} + + {overflowRight > 0 && ( +
+ + + + {overflowRight} + +
+ )} +
+ + +
+ ); +}; diff --git a/frontend/src/components/tasks/TaskDetails/PreviewTab.tsx b/frontend/src/components/tasks/TaskDetails/PreviewTab.tsx new file mode 100644 index 00000000..90806cc1 --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/PreviewTab.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useDevserverPreview } from '@/hooks/useDevserverPreview'; +import { useDevServer } from '@/hooks/useDevServer'; +import { ClickToComponentListener } from '@/utils/previewBridge'; +import { useClickedElements } from '@/contexts/ClickedElementsProvider'; +import { TaskAttempt } from 'shared/types'; +import { Alert } from '@/components/ui/alert'; +import { useProject } from '@/contexts/project-context'; +import { DevServerLogsView } from './preview/DevServerLogsView'; +import { PreviewToolbar } from './preview/PreviewToolbar'; +import { NoServerContent } from './preview/NoServerContent'; +import { ReadyContent } from './preview/ReadyContent'; + +interface PreviewTabProps { + selectedAttempt: TaskAttempt; + projectId: string; + projectHasDevScript: boolean; +} + +export default function PreviewTab({ + selectedAttempt, + projectId, + projectHasDevScript, +}: PreviewTabProps) { + const [iframeError, setIframeError] = useState(false); + const [isReady, setIsReady] = useState(false); + const [loadingTimeFinished, setLoadingTimeFinished] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + const [showLogs, setShowLogs] = useState(false); + const listenerRef = useRef(null); + + // Hooks + const { t } = useTranslation('tasks'); + const { project } = useProject(); + + const previewState = useDevserverPreview(selectedAttempt.id, { + projectHasDevScript, + projectId, + }); + + const { + start: startDevServer, + stop: stopDevServer, + isStarting: isStartingDevServer, + isStopping: isStoppingDevServer, + runningDevServer, + latestDevServerProcess, + } = useDevServer(selectedAttempt.id); + + const handleRefresh = () => { + setIframeError(false); + setRefreshKey((prev) => prev + 1); + }; + const handleIframeError = () => { + setIframeError(true); + }; + + const { addElement } = useClickedElements(); + + const handleCopyUrl = async () => { + if (previewState.url) { + await navigator.clipboard.writeText(previewState.url); + } + }; + + // Set up message listener when iframe is ready + useEffect(() => { + if (previewState.status !== 'ready' || !previewState.url || !addElement) { + return; + } + + const listener = new ClickToComponentListener({ + onOpenInEditor: (payload) => { + addElement(payload); + }, + onReady: () => { + setIsReady(true); + setShowLogs(false); + setShowHelp(false); + }, + }); + + listener.start(); + listenerRef.current = listener; + + return () => { + listener.stop(); + listenerRef.current = null; + }; + }, [previewState.status, previewState.url, addElement]); + + function startTimer() { + setLoadingTimeFinished(false); + setTimeout(() => { + setLoadingTimeFinished(true); + }, 5000); + } + + useEffect(() => { + startTimer(); + }, []); + + // Auto-show help alert when having trouble loading preview + useEffect(() => { + if ( + loadingTimeFinished && + !isReady && + latestDevServerProcess && + runningDevServer + ) { + setShowHelp(true); + setShowLogs(true); + setLoadingTimeFinished(false); + } + }, [ + loadingTimeFinished, + isReady, + latestDevServerProcess?.id, + runningDevServer, + ]); + + // Compute mode and unified logs handling + const mode = !runningDevServer ? 'noServer' : iframeError ? 'error' : 'ready'; + const toggleLogs = () => { + setShowLogs((v) => !v); + }; + + const handleStartDevServer = () => { + setLoadingTimeFinished(false); + startDevServer(); + startTimer(); + setShowHelp(false); + setIsReady(false); + }; + + const handleStopAndEdit = () => { + stopDevServer(undefined, { + onSuccess: () => { + setShowHelp(false); + }, + }); + }; + + return ( +
+
+ {mode === 'ready' ? ( + <> + + + + ) : ( + + )} + + {showHelp && ( + +

{t('preview.troubleAlert.title')}

+
    +
  1. {t('preview.troubleAlert.item1')}
  2. +
  3. + {t('preview.troubleAlert.item2')}{' '} + http://localhost:3000 + {t('preview.troubleAlert.item2Suffix')} +
  4. +
  5. + {t('preview.troubleAlert.item3')}{' '} + + {t('preview.troubleAlert.item3Link')} + + . +
  6. +
+ +
+ )} + +
+
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx index 7103c73e..03fcc10c 100644 --- a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx +++ b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx @@ -1,5 +1,4 @@ -import { GitCompare, MessageSquare, Cog } from 'lucide-react'; -import { useAttemptExecution } from '@/hooks/useAttemptExecution'; +import { GitCompare, MessageSquare, Cog, Monitor } from 'lucide-react'; import type { TabType } from '@/types/tabs'; import type { TaskAttempt } from 'shared/types'; @@ -8,20 +7,16 @@ type Props = { setActiveTab: (tab: TabType) => void; rightContent?: React.ReactNode; selectedAttempt: TaskAttempt | null; + showPreview?: boolean; + previewStatus?: 'idle' | 'searching' | 'ready' | 'error'; }; -function TabNavigation({ - activeTab, - setActiveTab, - rightContent, - selectedAttempt, -}: Props) { - const { attemptData } = useAttemptExecution(selectedAttempt?.id); - +function TabNavigation({ activeTab, setActiveTab, rightContent }: Props) { const tabs = [ { id: 'logs' as TabType, label: 'Logs', icon: MessageSquare }, { id: 'diffs' as TabType, label: 'Diffs', icon: GitCompare }, { id: 'processes' as TabType, label: 'Processes', icon: Cog }, + { id: 'preview' as TabType, label: 'Preview', icon: Monitor }, ]; const getTabClassName = (tabId: TabType) => { @@ -44,13 +39,6 @@ function TabNavigation({ > {label} - {id === 'processes' && - attemptData.processes && - attemptData.processes.length > 0 && ( - - {attemptData.processes.length} - - )} ))}
{rightContent}
diff --git a/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx b/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx new file mode 100644 index 00000000..10f88d71 --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/preview/DevServerLogsView.tsx @@ -0,0 +1,58 @@ +import { useTranslation } from 'react-i18next'; +import { Terminal, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import ProcessLogsViewer from '../ProcessLogsViewer'; +import { ExecutionProcess } from 'shared/types'; + +interface DevServerLogsViewProps { + latestDevServerProcess: ExecutionProcess | undefined; + showLogs: boolean; + onToggle: () => void; + height?: string; + showToggleText?: boolean; +} + +export function DevServerLogsView({ + latestDevServerProcess, + showLogs, + onToggle, + height = 'h-60', + showToggleText = true, +}: DevServerLogsViewProps) { + const { t } = useTranslation('tasks'); + + if (!latestDevServerProcess) { + return null; + } + + return ( +
+ {/* Logs toolbar */} +
+
+ + + {t('preview.logs.title')} + +
+ +
+ + {/* Logs viewer */} + {showLogs && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx new file mode 100644 index 00000000..5484de7c --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/preview/NoServerContent.tsx @@ -0,0 +1,290 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Play, + Edit3, + SquareTerminal, + Save, + X, + ExternalLink, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ExecutionProcess, Project } from 'shared/types'; +import { + createScriptPlaceholderStrategy, + ScriptPlaceholderContext, +} from '@/utils/script-placeholders'; +import { useUserSystem } from '@/components/config-provider'; +import { useProjectMutations } from '@/hooks/useProjectMutations'; +import { useTaskMutations } from '@/hooks/useTaskMutations'; +import { + COMPANION_INSTALL_TASK_TITLE, + COMPANION_INSTALL_TASK_DESCRIPTION, +} from '@/utils/companion-install-task'; + +interface NoServerContentProps { + projectHasDevScript: boolean; + runningDevServer: ExecutionProcess | undefined; + isStartingDevServer: boolean; + startDevServer: () => void; + stopDevServer: () => void; + project: Project | undefined; +} + +export function NoServerContent({ + projectHasDevScript, + runningDevServer, + isStartingDevServer, + startDevServer, + stopDevServer, + project, +}: NoServerContentProps) { + const { t } = useTranslation('tasks'); + const [devScriptInput, setDevScriptInput] = useState(''); + const [saveError, setSaveError] = useState(null); + const [isEditingExistingScript, setIsEditingExistingScript] = useState(false); + const { system, config } = useUserSystem(); + + const { updateProject } = useProjectMutations({ + onUpdateSuccess: () => { + setIsEditingExistingScript(false); + }, + onUpdateError: (err) => { + setSaveError((err as Error)?.message || 'Failed to save dev script'); + }, + }); + + const { createAndStart } = useTaskMutations(project?.id); + + // Create strategy-based placeholders + const placeholders = system.environment + ? new ScriptPlaceholderContext( + createScriptPlaceholderStrategy(system.environment.os_type) + ).getPlaceholders() + : { + setup: '#!/bin/bash\nnpm install\n# Add any setup commands here...', + dev: '#!/bin/bash\nnpm run dev\n# Add dev server start command here...', + cleanup: + '#!/bin/bash\n# Add cleanup commands here...\n# This runs after coding agent execution', + }; + + const handleSaveDevScript = async (startAfterSave?: boolean) => { + setSaveError(null); + if (!project) { + setSaveError(t('preview.devScript.errors.notLoaded')); + return; + } + + const script = devScriptInput.trim(); + if (!script) { + setSaveError(t('preview.devScript.errors.empty')); + return; + } + + updateProject.mutate( + { + projectId: project.id, + data: { + name: project.name, + git_repo_path: project.git_repo_path, + setup_script: project.setup_script ?? null, + dev_script: script, + cleanup_script: project.cleanup_script ?? null, + copy_files: project.copy_files ?? null, + }, + }, + { + onSuccess: () => { + if (startAfterSave) { + startDevServer(); + } + }, + } + ); + }; + + const handleEditExistingScript = () => { + if (project?.dev_script) { + setDevScriptInput(project.dev_script); + } + setIsEditingExistingScript(true); + setSaveError(null); + }; + + const handleCancelEdit = () => { + setIsEditingExistingScript(false); + setDevScriptInput(''); + setSaveError(null); + }; + + const handleInstallCompanion = () => { + if (!project || !config) return; + + createAndStart.mutate({ + task: { + project_id: project.id, + title: COMPANION_INSTALL_TASK_TITLE, + description: COMPANION_INSTALL_TASK_DESCRIPTION, + parent_task_attempt: null, + image_ids: null, + }, + executor_profile_id: config.executor_profile, + base_branch: 'main', + }); + }; + + return ( +
+
+
+ +
+ +
+
+

+ {t('preview.noServer.title')} +

+

+ {projectHasDevScript + ? t('preview.noServer.startPrompt') + : t('preview.noServer.setupPrompt')} +

+
+ + {!isEditingExistingScript ? ( +
+ + + {!runningDevServer && ( + + )} +
+ ) : ( +
+
+