Add preview tab + point and click editor (vibe-kanban) (#870)
* Perfect! The preview tab implementation is complete and all type checks pass. Let me provide a summary of what was implemented: 1. **Updated `TabType`** - Added 'preview' to the union type 2. **Created `useDevserverPreview` hook** - Intelligent devserver URL detection with: - Real-time log parsing using the regex `(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)[^\d]*` - Three states: idle/searching/ready - URL normalization (0.0.0.0 → localhost) - Process monitoring for setup scripts and dev servers 3. **Built `PreviewTab` component** - Full-featured iframe preview with: - Proper sandbox settings for security - Toolbar with refresh, copy URL, and open external buttons - Smart error handling for mixed content/CORS issues - Clear empty states and loading indicators 4. **Enhanced `TabNavigation`** - Conditionally shows preview tab with: - Monitor icon and spinner when searching - Only appears when devserver is detected or likely 5. **Integrated in `TaskDetailsPanel`** - Wires everything together in fullscreen mode - **Smart Detection**: Parses logs in real-time to find devserver URLs - **Robust Error Handling**: Graceful fallbacks for iframe loading issues - **Security**: Proper iframe sandboxing with necessary permissions - **Performance**: Efficient log processing with deduplication - **UX**: Clear states (searching/ready/error) with helpful messaging The implementation follows the Oracle's architectural recommendations with proper separation of concerns and real-time detection capabilities. * Cleanup script changes for task attempt 4f59c374-1cc5-4b3c-a79a-5f63756a366b * Perfect! Now the preview tab will use the **first** dev server URL it detects rather than being overwritten by subsequent matches. This means: - If both backend (e.g., `:3001`) and frontend (e.g., `:5173`) servers start - The preview tab will show whichever URL appears first in the logs - Subsequent URLs will be ignored to avoid switching the preview unexpectedly This provides a more stable preview experience when multiple dev servers are running. * Cleanup script changes for task attempt 4f59c374-1cc5-4b3c-a79a-5f63756a366b * add parent lib * fmt * Listen for communication from the preview tab (vibe-kanban ace46045) In frontend/src/components/tasks/TaskDetails/PreviewTab.tsx We should expect that the iframe will communicate via frontend/src/utils/previewBridge.ts When a message is received, we should add some details about the clicked element to the follow up textarea * Component to view clicked element (vibe-kanban e3b90cc1) frontend/src/components/tasks/TaskDetails/PreviewTab.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx When a user clicks on an element, we should display a box in the follow up section similar to how we show reviews or conflicts. The section should display a summary of each of the elements, the name of the component and the file location. When the user sends a follow up, a markdown equivalent of the summary should be appended to the top of the follow up message. * Component to view clicked element (vibe-kanban e3b90cc1) frontend/src/components/tasks/TaskDetails/PreviewTab.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx When a user clicks on an element, we should display a box in the follow up section similar to how we show reviews or conflicts. The section should display a summary of each of the elements, the name of the component and the file location. When the user sends a follow up, a markdown equivalent of the summary should be appended to the top of the follow up message. * Tweaks to component click (vibe-kanban 756e1212) Preview tab frontend/src/components/tasks/TaskDetails/PreviewTab.tsx - Preview should remember which URL you were on - Auto select the follow up box after point and click, so you can type feedback Clicked elements: frontend/src/components/tasks/ClickedElementsBanner.tsx, frontend/src/contexts/ClickedElementsProvider.tsx - The list of components should not overflow horizontally, instead we should truncate, omiting components from the left first - If the user clicks on a component, it should omit the downstream components from the list, they should be displayed disabled and the prompt should start from the selected component * strip ansi when parsing dev server URL * cleanup * cleanup * improve help copy * start dev server from preview page * dev server wip * restructure * instructions * fix * restructur * fmt * i18n * i18n fix * config fix * wip cleanup * minor cleanup * Preview tab feedback (vibe-kanban d531fff8) In the PreviewToolbar, each icon button should have a tooltip * fix + fmt * move dev script textarea * improve when help is shown * i18n * improve URL matching * fix close logs * auto install companion * cleanup notices * Copy tweak
This commit is contained in:
committed by
GitHub
parent
0ace01b55f
commit
2781e3651b
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<UserSystemProvider>
|
||||
<ProjectProvider>
|
||||
<HotkeysProvider initiallyActiveScopes={['*', 'global', 'kanban']}>
|
||||
<KeyboardShortcutsProvider>
|
||||
<NiceModal.Provider>
|
||||
<AppContent />
|
||||
</NiceModal.Provider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</HotkeysProvider>
|
||||
</ProjectProvider>
|
||||
<ClickedElementsProvider>
|
||||
<ProjectProvider>
|
||||
<HotkeysProvider initiallyActiveScopes={['*', 'global', 'kanban']}>
|
||||
<KeyboardShortcutsProvider>
|
||||
<NiceModal.Provider>
|
||||
<AppContent />
|
||||
</NiceModal.Provider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</HotkeysProvider>
|
||||
</ProjectProvider>
|
||||
</ClickedElementsProvider>
|
||||
</UserSystemProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
@@ -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<ProjectFormDialogProps>(
|
||||
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<ProjectFormDialogProps>(
|
||||
|
||||
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<ProjectFormDialogProps>(
|
||||
// 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<ProjectFormDialogProps>(
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !gitRepoPath.trim()}
|
||||
disabled={
|
||||
updateProject.isPending || !gitRepoPath.trim()
|
||||
}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
{updateProject.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -273,9 +272,11 @@ export const ProjectFormDialog = NiceModal.create<ProjectFormDialogProps>(
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !folderName.trim()}
|
||||
disabled={createProject.isPending || !folderName.trim()}
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Project'}
|
||||
{createProject.isPending
|
||||
? 'Creating...'
|
||||
: 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
|
||||
163
frontend/src/components/tasks/ClickedElementsBanner.tsx
Normal file
163
frontend/src/components/tasks/ClickedElementsBanner.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-bg flex flex-col gap-2 py-2">
|
||||
{visibleElements.map((element) => {
|
||||
return (
|
||||
<ClickedEntryCard
|
||||
key={element.id}
|
||||
element={element}
|
||||
onDelete={() => removeElement(element.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex gap-2 items-center min-w-0">
|
||||
<MousePointerClick className="h-4 w-4 text-info shrink-0" aria-hidden />
|
||||
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
{display.map((component, i) => {
|
||||
const depthFromInner = i; // Simple mapping since we keep left side
|
||||
const isDownstream = depthFromInner < selectedDepth;
|
||||
const isSelected = depthFromInner === selectedDepth;
|
||||
|
||||
return (
|
||||
<div className="flex items-center" key={`${component.name}-${i}`}>
|
||||
{i > 0 && (
|
||||
<ArrowBigLeft className="h-4 w-4 opacity-60" aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(i)}
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-sm transition ${
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:opacity-90'
|
||||
} ${isDownstream ? 'opacity-50 cursor-pointer' : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
title={component.name}
|
||||
>
|
||||
<{component.name}/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{overflowRight > 0 && (
|
||||
<div className="flex items-center">
|
||||
<ArrowBigLeft className="h-4 w-4 opacity-60" aria-hidden />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs opacity-70 select-none"
|
||||
title={`${overflowRight} more outer components`}
|
||||
>
|
||||
<MoreHorizontal className="h-3 w-3" />
|
||||
<span className="ml-1">{overflowRight}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="px-0 ml-auto"
|
||||
onClick={onDelete}
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
217
frontend/src/components/tasks/TaskDetails/PreviewTab.tsx
Normal file
217
frontend/src/components/tasks/TaskDetails/PreviewTab.tsx
Normal file
@@ -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<ClickToComponentListener | null>(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 (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className={`flex-1 flex flex-col min-h-0`}>
|
||||
{mode === 'ready' ? (
|
||||
<>
|
||||
<PreviewToolbar
|
||||
mode={mode}
|
||||
url={previewState.url}
|
||||
onRefresh={handleRefresh}
|
||||
onCopyUrl={handleCopyUrl}
|
||||
/>
|
||||
<ReadyContent
|
||||
url={previewState.url}
|
||||
iframeKey={`${previewState.url}-${refreshKey}`}
|
||||
onIframeError={handleIframeError}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<NoServerContent
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
runningDevServer={runningDevServer}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
startDevServer={handleStartDevServer}
|
||||
stopDevServer={stopDevServer}
|
||||
project={project}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<Alert variant="destructive" className="space-y-2">
|
||||
<p className="font-bold">{t('preview.troubleAlert.title')}</p>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
<li>{t('preview.troubleAlert.item1')}</li>
|
||||
<li>
|
||||
{t('preview.troubleAlert.item2')}{' '}
|
||||
<code>http://localhost:3000</code>
|
||||
{t('preview.troubleAlert.item2Suffix')}
|
||||
</li>
|
||||
<li>
|
||||
{t('preview.troubleAlert.item3')}{' '}
|
||||
<a
|
||||
href="https://github.com/BloopAI/vibe-kanban-web-companion"
|
||||
target="_blank"
|
||||
className="underline font-bold"
|
||||
>
|
||||
{t('preview.troubleAlert.item3Link')}
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ol>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleStopAndEdit}
|
||||
disabled={isStoppingDevServer}
|
||||
>
|
||||
{isStoppingDevServer && <Loader2 className="mr-2 animate-spin" />}
|
||||
{t('preview.noServer.stopAndEditButton')}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
<DevServerLogsView
|
||||
latestDevServerProcess={latestDevServerProcess}
|
||||
showLogs={showLogs}
|
||||
onToggle={toggleLogs}
|
||||
showToggleText
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{label}
|
||||
{id === 'processes' &&
|
||||
attemptData.processes &&
|
||||
attemptData.processes.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary/10 text-primary rounded-full">
|
||||
{attemptData.processes.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center">{rightContent}</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-t bg-background">
|
||||
{/* Logs toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t('preview.logs.title')}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={onToggle}>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 mr-1 ${showToggleText ? 'transition-transform' : ''} ${showLogs ? '' : 'rotate-180'}`}
|
||||
/>
|
||||
{showToggleText
|
||||
? showLogs
|
||||
? t('preview.logs.hide')
|
||||
: t('preview.logs.show')
|
||||
: t('preview.logs.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logs viewer */}
|
||||
{showLogs && (
|
||||
<div className={height}>
|
||||
<ProcessLogsViewer processId={latestDevServerProcess.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-6 max-w-md mx-auto p-6">
|
||||
<div className="flex items-center justify-center">
|
||||
<SquareTerminal className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{t('preview.noServer.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectHasDevScript
|
||||
? t('preview.noServer.startPrompt')
|
||||
: t('preview.noServer.setupPrompt')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isEditingExistingScript ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant={runningDevServer ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (runningDevServer) {
|
||||
stopDevServer();
|
||||
} else {
|
||||
startDevServer();
|
||||
}
|
||||
}}
|
||||
disabled={isStartingDevServer || !projectHasDevScript}
|
||||
className="gap-1"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('preview.noServer.startButton')}
|
||||
</Button>
|
||||
|
||||
{!runningDevServer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleEditExistingScript}
|
||||
className="gap-1"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
{t('preview.noServer.editButton')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-left">
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
id="devScript"
|
||||
placeholder={placeholders.dev}
|
||||
value={devScriptInput}
|
||||
onChange={(e) => setDevScriptInput(e.target.value)}
|
||||
className="min-h-[120px] font-mono text-sm"
|
||||
disabled={updateProject.isPending}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
{isEditingExistingScript ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveDevScript(false)}
|
||||
disabled={updateProject.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{t('preview.devScript.saveChanges')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCancelEdit}
|
||||
disabled={updateProject.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t('preview.devScript.cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSaveDevScript(true)}
|
||||
disabled={updateProject.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{t('preview.devScript.saveAndStart')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleSaveDevScript(false)}
|
||||
disabled={updateProject.isPending}
|
||||
className="gap-1"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{t('preview.devScript.saveOnly')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 pt-6 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('preview.noServer.companionPrompt')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstallCompanion}
|
||||
disabled={!project || !config || createAndStart.isPending}
|
||||
className="gap-1"
|
||||
variant="outline"
|
||||
>
|
||||
{createAndStart.isPending
|
||||
? 'Creating task…'
|
||||
: 'Install companion automatically'}
|
||||
</Button>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/BloopAI/vibe-kanban-web-companion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t('preview.noServer.companionLink')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ExternalLink, RefreshCw, Copy, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface PreviewToolbarProps {
|
||||
mode: 'noServer' | 'error' | 'ready';
|
||||
url?: string;
|
||||
onRefresh: () => void;
|
||||
onCopyUrl: () => void;
|
||||
}
|
||||
|
||||
export function PreviewToolbar({
|
||||
mode,
|
||||
url,
|
||||
onRefresh,
|
||||
onCopyUrl,
|
||||
}: PreviewToolbarProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border-b bg-muted/50 shrink-0">
|
||||
<span className="text-sm text-muted-foreground font-mono truncate flex-1">
|
||||
{url || <Loader2 className="animate-spin" />}
|
||||
</span>
|
||||
|
||||
{mode !== 'noServer' && (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" onClick={onRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('preview.toolbar.refresh')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onCopyUrl}
|
||||
disabled={!url}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('preview.toolbar.copyUrl')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size="sm" variant="outline" asChild disabled={!url}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('preview.toolbar.openInTab')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ReadyContentProps {
|
||||
url?: string;
|
||||
iframeKey: string;
|
||||
onIframeError: () => void;
|
||||
}
|
||||
|
||||
export function ReadyContent({
|
||||
url,
|
||||
iframeKey,
|
||||
onIframeError,
|
||||
}: ReadyContentProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
src={url}
|
||||
title={t('preview.iframe.title')}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={onIframeError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import TaskDetailsHeader from './TaskDetailsHeader';
|
||||
import { TaskFollowUpSection } from './TaskFollowUpSection';
|
||||
import { TaskTitleDescription } from './TaskDetails/TaskTitleDescription';
|
||||
import type { TaskAttempt } from 'shared/types';
|
||||
import {
|
||||
getBackdropClasses,
|
||||
getTaskPanelClasses,
|
||||
@@ -13,22 +12,25 @@ import type { TabType } from '@/types/tabs';
|
||||
import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx';
|
||||
import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx';
|
||||
import ProcessesTab from '@/components/tasks/TaskDetails/ProcessesTab.tsx';
|
||||
import PreviewTab from '@/components/tasks/TaskDetails/PreviewTab.tsx';
|
||||
import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx';
|
||||
import TaskDetailsToolbar from './TaskDetailsToolbar.tsx';
|
||||
import TodoPanel from '@/components/tasks/TodoPanel';
|
||||
import { TabNavContext } from '@/contexts/TabNavigationContext';
|
||||
import { ProcessSelectionProvider } from '@/contexts/ProcessSelectionContext';
|
||||
import { ReviewProvider } from '@/contexts/ReviewProvider';
|
||||
import { ClickedElementsProvider } from '@/contexts/ClickedElementsProvider';
|
||||
import { EntriesProvider } from '@/contexts/EntriesContext';
|
||||
import { RetryUiProvider } from '@/contexts/RetryUiContext';
|
||||
import { AttemptHeaderCard } from './AttemptHeaderCard';
|
||||
import { inIframe } from '@/vscode/bridge';
|
||||
import { TaskRelationshipViewer } from './TaskRelationshipViewer';
|
||||
import { useTaskViewManager } from '@/hooks/useTaskViewManager.ts';
|
||||
import type { TaskAttempt } from 'shared/types';
|
||||
|
||||
interface TaskDetailsPanelProps {
|
||||
task: TaskWithAttemptStatus | null;
|
||||
projectHasDevScript?: boolean;
|
||||
projectHasDevScript: boolean;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onEditTask?: (task: TaskWithAttemptStatus) => void;
|
||||
@@ -98,156 +100,162 @@ export function TaskDetailsPanel({
|
||||
<TabNavContext.Provider value={{ activeTab, setActiveTab }}>
|
||||
<ProcessSelectionProvider>
|
||||
<ReviewProvider>
|
||||
<EntriesProvider key={selectedAttempt?.id}>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
{!hideBackdrop && (
|
||||
<ClickedElementsProvider attempt={selectedAttempt}>
|
||||
<EntriesProvider key={selectedAttempt?.id}>
|
||||
{/* Backdrop - only on smaller screens (overlay mode) */}
|
||||
{!hideBackdrop && (
|
||||
<div
|
||||
className={getBackdropClasses(isFullScreen || false)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={getBackdropClasses(isFullScreen || false)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
className={
|
||||
className || getTaskPanelClasses(isFullScreen || false)
|
||||
}
|
||||
>
|
||||
<div className={getTaskPanelInnerClasses()}>
|
||||
{!inIframe() && (
|
||||
<TaskDetailsHeader
|
||||
task={task}
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
hideCloseButton={hideBackdrop}
|
||||
isFullScreen={isFullScreen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={
|
||||
className || getTaskPanelClasses(isFullScreen || false)
|
||||
}
|
||||
>
|
||||
<div className={getTaskPanelInnerClasses()}>
|
||||
{!inIframe() && (
|
||||
<TaskDetailsHeader
|
||||
task={task}
|
||||
onClose={onClose}
|
||||
onEditTask={onEditTask}
|
||||
onDeleteTask={onDeleteTask}
|
||||
hideCloseButton={hideBackdrop}
|
||||
isFullScreen={isFullScreen}
|
||||
/>
|
||||
)}
|
||||
{isFullScreen ? (
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* Fullscreen sidebar shows title and description above edit/delete */}
|
||||
<div className="space-y-2 p-3">
|
||||
<TaskTitleDescription task={task} />
|
||||
</div>
|
||||
|
||||
{isFullScreen ? (
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`w-[28rem] shrink-0 border-r overflow-y-auto ${inIframe() ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* Fullscreen sidebar shows title and description above edit/delete */}
|
||||
<div className="space-y-2 p-3">
|
||||
<TaskTitleDescription task={task} />
|
||||
</div>
|
||||
|
||||
{/* Current Attempt / Actions */}
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={
|
||||
onLeaveForceCreateAttempt
|
||||
}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
|
||||
{/* Task Breakdown (TODOs) */}
|
||||
<TodoPanel />
|
||||
|
||||
{/* Task Relationships */}
|
||||
<TaskRelationshipViewer
|
||||
selectedAttempt={selectedAttempt}
|
||||
onNavigateToTask={onNavigateToTask}
|
||||
task={task}
|
||||
tasksById={tasksById}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
{selectedAttempt && (
|
||||
<RetryUiProvider attemptId={selectedAttempt.id}>
|
||||
<>
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab
|
||||
attemptId={selectedAttempt?.id}
|
||||
/>
|
||||
) : (
|
||||
<LogsTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
</RetryUiProvider>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attempts.length === 0 ? (
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={
|
||||
onLeaveForceCreateAttempt
|
||||
}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AttemptHeaderCard
|
||||
attemptNumber={attemptNumber}
|
||||
totalAttempts={attempts.length}
|
||||
selectedAttempt={selectedAttempt}
|
||||
{/* Current Attempt / Actions */}
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
// onCreateNewAttempt={() => {
|
||||
// // TODO: Implement create new attempt
|
||||
// console.log('Create new attempt');
|
||||
// }}
|
||||
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={
|
||||
onLeaveForceCreateAttempt
|
||||
}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
|
||||
{/* Task Breakdown (TODOs) */}
|
||||
<TodoPanel />
|
||||
|
||||
{/* Task Relationships */}
|
||||
<TaskRelationshipViewer
|
||||
selectedAttempt={selectedAttempt}
|
||||
onNavigateToTask={onNavigateToTask}
|
||||
task={task}
|
||||
tasksById={tasksById}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||
{selectedAttempt && (
|
||||
<RetryUiProvider attemptId={selectedAttempt.id}>
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
<>
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{activeTab === 'diffs' ? (
|
||||
<DiffTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
) : activeTab === 'processes' ? (
|
||||
<ProcessesTab
|
||||
attemptId={selectedAttempt?.id}
|
||||
/>
|
||||
) : activeTab === 'preview' ? (
|
||||
<PreviewTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={
|
||||
projectHasDevScript
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<LogsTab
|
||||
selectedAttempt={selectedAttempt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</>
|
||||
</RetryUiProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attempts.length === 0 ? (
|
||||
<TaskDetailsToolbar
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
projectHasDevScript={projectHasDevScript}
|
||||
forceCreateAttempt={forceCreateAttempt}
|
||||
onLeaveForceCreateAttempt={
|
||||
onLeaveForceCreateAttempt
|
||||
}
|
||||
attempts={attempts}
|
||||
selectedAttempt={selectedAttempt}
|
||||
setSelectedAttempt={setSelectedAttempt}
|
||||
// hide actions in sidebar; moved to header in fullscreen
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AttemptHeaderCard
|
||||
attemptNumber={attemptNumber}
|
||||
totalAttempts={attempts.length}
|
||||
selectedAttempt={selectedAttempt}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
onJumpToDiffFullScreen={jumpToDiffFullScreen}
|
||||
/>
|
||||
|
||||
{selectedAttempt && (
|
||||
<RetryUiProvider attemptId={selectedAttempt.id}>
|
||||
<LogsTab selectedAttempt={selectedAttempt} />
|
||||
<TaskFollowUpSection
|
||||
task={task}
|
||||
selectedAttemptId={selectedAttempt?.id}
|
||||
jumpToLogsTab={jumpToLogsTab}
|
||||
/>
|
||||
</RetryUiProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EntriesProvider>
|
||||
</EntriesProvider>
|
||||
</ClickedElementsProvider>
|
||||
</ReviewProvider>
|
||||
</ProcessSelectionProvider>
|
||||
</TabNavContext.Provider>
|
||||
|
||||
@@ -18,11 +18,13 @@ import { useUserSystem } from '@/components/config-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
//
|
||||
import { useReview } from '@/contexts/ReviewProvider';
|
||||
import { useClickedElements } from '@/contexts/ClickedElementsProvider';
|
||||
//
|
||||
import { VariantSelector } from '@/components/tasks/VariantSelector';
|
||||
import { FollowUpStatusRow } from '@/components/tasks/FollowUpStatusRow';
|
||||
import { useAttemptBranch } from '@/hooks/useAttemptBranch';
|
||||
import { FollowUpConflictSection } from '@/components/tasks/follow-up/FollowUpConflictSection';
|
||||
import { ClickedElementsBanner } from '@/components/tasks/ClickedElementsBanner';
|
||||
import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard';
|
||||
import { useDraftStream } from '@/hooks/follow-up/useDraftStream';
|
||||
import { useRetryUi } from '@/contexts/RetryUiContext';
|
||||
@@ -53,10 +55,19 @@ export function TaskFollowUpSection({
|
||||
useAttemptBranch(selectedAttemptId);
|
||||
const { profiles } = useUserSystem();
|
||||
const { comments, generateReviewMarkdown, clearComments } = useReview();
|
||||
const {
|
||||
generateMarkdown: generateClickedMarkdown,
|
||||
clearElements: clearClickedElements,
|
||||
} = useClickedElements();
|
||||
|
||||
const reviewMarkdown = useMemo(
|
||||
() => generateReviewMarkdown(),
|
||||
[generateReviewMarkdown, comments]
|
||||
[generateReviewMarkdown]
|
||||
);
|
||||
|
||||
const clickedMarkdown = useMemo(
|
||||
() => generateClickedMarkdown(),
|
||||
[generateClickedMarkdown]
|
||||
);
|
||||
|
||||
// Non-editable conflict resolution instructions (derived, like review comments)
|
||||
@@ -148,10 +159,12 @@ export function TaskFollowUpSection({
|
||||
message: followUpMessage,
|
||||
conflictMarkdown: conflictResolutionInstructions,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
selectedVariant,
|
||||
images,
|
||||
newlyUploadedImageIds,
|
||||
clearComments,
|
||||
clearClickedElements,
|
||||
jumpToLogsTab,
|
||||
onAfterSendCleanup: clearImagesAndUploads,
|
||||
setMessage: setFollowUpMessage,
|
||||
@@ -190,14 +203,18 @@ export function TaskFollowUpSection({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow sending if conflict instructions or review comments exist, or message is present
|
||||
// Allow sending if conflict instructions, review comments, clicked elements, or message is present
|
||||
return Boolean(
|
||||
conflictResolutionInstructions || reviewMarkdown || followUpMessage.trim()
|
||||
conflictResolutionInstructions ||
|
||||
reviewMarkdown ||
|
||||
clickedMarkdown ||
|
||||
followUpMessage.trim()
|
||||
);
|
||||
}, [
|
||||
canTypeFollowUp,
|
||||
conflictResolutionInstructions,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
followUpMessage,
|
||||
]);
|
||||
// currentProfile is provided by useDefaultVariant
|
||||
@@ -314,6 +331,9 @@ export function TaskFollowUpSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Clicked elements notice and actions */}
|
||||
<ClickedElementsBanner />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<FollowUpEditorCard
|
||||
placeholder={
|
||||
|
||||
@@ -9,7 +9,7 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
'border-foreground/50 bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
|
||||
398
frontend/src/contexts/ClickedElementsProvider.tsx
Normal file
398
frontend/src/contexts/ClickedElementsProvider.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import type {
|
||||
OpenInEditorPayload,
|
||||
ComponentInfo,
|
||||
SelectedComponent,
|
||||
} from '@/utils/previewBridge';
|
||||
import type { TaskAttempt } from 'shared/types';
|
||||
|
||||
export interface ClickedEntry {
|
||||
id: string;
|
||||
payload: OpenInEditorPayload;
|
||||
timestamp: number;
|
||||
dedupeKey: string;
|
||||
selectedDepth?: number; // 0 = innermost (selected), 1 = parent, etc.
|
||||
}
|
||||
|
||||
interface ClickedElementsContextType {
|
||||
elements: ClickedEntry[];
|
||||
addElement: (payload: OpenInEditorPayload) => void;
|
||||
removeElement: (id: string) => void;
|
||||
clearElements: () => void;
|
||||
selectComponent: (id: string, depthFromInner: number) => void;
|
||||
generateMarkdown: () => string;
|
||||
}
|
||||
|
||||
const ClickedElementsContext = createContext<ClickedElementsContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export function useClickedElements() {
|
||||
const context = useContext(ClickedElementsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useClickedElements must be used within a ClickedElementsProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ClickedElementsProviderProps {
|
||||
children: ReactNode;
|
||||
attempt?: TaskAttempt | null;
|
||||
}
|
||||
|
||||
const MAX_ELEMENTS = 20;
|
||||
|
||||
// Helpers
|
||||
|
||||
function stripPrefixes(p?: string): string {
|
||||
if (!p) return '';
|
||||
return p
|
||||
.replace(/^file:\/\//, '')
|
||||
.replace(/^webpack:\/\/\//, '')
|
||||
.replace(/^webpack:\/\//, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// macOS alias handling; no-ops on other OSes
|
||||
function normalizeMacPrivateAliases(p: string): string {
|
||||
if (!p) return p;
|
||||
// Very light normalization mimicking path.rs logic
|
||||
if (p === '/private/var') return '/var';
|
||||
if (p.startsWith('/private/var/'))
|
||||
return '/var/' + p.slice('/private/var/'.length);
|
||||
if (p === '/private/tmp') return '/tmp';
|
||||
if (p.startsWith('/private/tmp/'))
|
||||
return '/tmp/' + p.slice('/private/tmp/'.length);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Return { path, line?, col? } where `path` has no trailing :line(:col).
|
||||
// Works even when Windows drive letters contain a colon.
|
||||
function parsePathWithLineCol(raw?: string): {
|
||||
path: string;
|
||||
line?: number;
|
||||
col?: number;
|
||||
} {
|
||||
const s = stripPrefixes(raw);
|
||||
if (!s) return { path: '' };
|
||||
const normalized = normalizeMacPrivateAliases(s);
|
||||
|
||||
// Try to split trailing :line(:col). Last and second-to-last tokens must be numbers.
|
||||
const parts = normalized.split(':');
|
||||
if (parts.length <= 2) return { path: normalized };
|
||||
|
||||
const last = parts[parts.length - 1];
|
||||
const maybeCol = Number(last);
|
||||
if (!Number.isFinite(maybeCol)) return { path: normalized };
|
||||
|
||||
const prev = parts[parts.length - 2];
|
||||
const maybeLine = Number(prev);
|
||||
if (!Number.isFinite(maybeLine)) return { path: normalized };
|
||||
|
||||
// Windows drive (e.g., "C") is at index 0; this still works because we only strip the end
|
||||
const basePath = parts.slice(0, parts.length - 2).join(':');
|
||||
return { path: basePath, line: maybeLine, col: maybeCol };
|
||||
}
|
||||
|
||||
function relativizePath(p: string, workspaceRoot?: string): string {
|
||||
if (!p) return '';
|
||||
const normalized = normalizeMacPrivateAliases(stripPrefixes(p));
|
||||
|
||||
if (!workspaceRoot) return normalized;
|
||||
|
||||
// Simple prefix strip; robust handling is on backend (path.rs).
|
||||
// This keeps the UI stable even when run inside macOS /private/var containers.
|
||||
const wr = normalizeMacPrivateAliases(workspaceRoot.replace(/\/+$/, ''));
|
||||
if (
|
||||
normalized.startsWith(wr.endsWith('/') ? wr : wr + '/') ||
|
||||
normalized === wr
|
||||
) {
|
||||
const rel = normalized.slice(wr.length);
|
||||
return rel.startsWith('/') ? rel.slice(1) : rel || '.';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatLoc(path: string, line?: number, col?: number) {
|
||||
if (!path) return '';
|
||||
if (line == null) return path;
|
||||
return `${path}:${line}${col != null ? `:${col}` : ''}`;
|
||||
}
|
||||
|
||||
function formatDomBits(ce?: OpenInEditorPayload['clickedElement']) {
|
||||
const bits: string[] = [];
|
||||
if (ce?.tag) bits.push(ce.tag.toLowerCase());
|
||||
if (ce?.id) bits.push(`#${ce.id}`);
|
||||
const classes = normalizeClassName(ce?.className);
|
||||
if (classes) bits.push(`.${classes}`);
|
||||
if (ce?.role) bits.push(`@${ce.role}`);
|
||||
return bits.join('') || '(unknown)';
|
||||
}
|
||||
|
||||
function normalizeClassName(className?: string): string {
|
||||
if (!className) return '';
|
||||
return className.split(/\s+/).filter(Boolean).sort().join('.');
|
||||
}
|
||||
|
||||
function makeDedupeKey(
|
||||
payload: OpenInEditorPayload,
|
||||
workspaceRoot?: string
|
||||
): string {
|
||||
const s = payload.selected;
|
||||
const ce = payload.clickedElement;
|
||||
|
||||
const { path } = parsePathWithLineCol(s.pathToSource);
|
||||
const rel = relativizePath(path, workspaceRoot);
|
||||
|
||||
const domBits: string[] = [];
|
||||
if (ce?.tag) domBits.push(ce.tag.toLowerCase());
|
||||
if (ce?.id) domBits.push(`#${ce.id}`);
|
||||
const normalizedClasses = normalizeClassName(ce?.className);
|
||||
if (normalizedClasses) domBits.push(`.${normalizedClasses}`);
|
||||
if (ce?.role) domBits.push(`@${ce.role}`);
|
||||
|
||||
const locKey = [
|
||||
rel,
|
||||
s.source?.lineNumber ?? '',
|
||||
s.source?.columnNumber ?? '',
|
||||
].join(':');
|
||||
return `${s.name}|${locKey}|${domBits.join('')}`;
|
||||
}
|
||||
|
||||
// Remove heavy or unsafe props while retaining debuggability
|
||||
function pruneValue(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
maxString = 200,
|
||||
maxArray = 20
|
||||
): unknown {
|
||||
if (depth <= 0) return '[MaxDepth]';
|
||||
|
||||
if (value == null) return value;
|
||||
const t = typeof value;
|
||||
if (t === 'string')
|
||||
return (value as string).length > maxString
|
||||
? (value as string).slice(0, maxString) + '…'
|
||||
: value;
|
||||
if (t === 'number' || t === 'boolean') return value;
|
||||
if (t === 'function') return '[Function]';
|
||||
if (t === 'bigint') return value.toString() + 'n';
|
||||
if (t === 'symbol') return value.toString();
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const lim = (value as unknown[])
|
||||
.slice(0, maxArray)
|
||||
.map((v) => pruneValue(v, depth - 1, maxString, maxArray));
|
||||
if ((value as unknown[]).length > maxArray)
|
||||
lim.push(`[+${(value as unknown[]).length - maxArray} more]`);
|
||||
return lim;
|
||||
}
|
||||
|
||||
if (t === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
let count = 0;
|
||||
for (const k of Object.keys(obj)) {
|
||||
// Cap keys to keep small
|
||||
if (count++ > 50) {
|
||||
out['[TruncatedKeys]'] = true;
|
||||
break;
|
||||
}
|
||||
out[k] = pruneValue(obj[k], depth - 1, maxString, maxArray);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return '[Unknown]';
|
||||
}
|
||||
|
||||
function stripHeavyProps(payload: OpenInEditorPayload): OpenInEditorPayload {
|
||||
// Avoid mutating caller objects
|
||||
const shallowSelected = {
|
||||
...payload.selected,
|
||||
props: pruneValue(payload.selected.props, 2) as Record<string, unknown>,
|
||||
};
|
||||
|
||||
const shallowComponents = payload.components.map((c) => ({
|
||||
...c,
|
||||
props: pruneValue(c.props, 2) as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
// dataset and coords are typically small; keep as-is.
|
||||
return {
|
||||
...payload,
|
||||
selected: shallowSelected,
|
||||
components: shallowComponents,
|
||||
};
|
||||
}
|
||||
|
||||
// Build component chain from inner-most to outer-most
|
||||
function buildChainInnerToOuter(
|
||||
payload: OpenInEditorPayload,
|
||||
workspaceRoot?: string
|
||||
) {
|
||||
const comps = payload.components ?? [];
|
||||
const s = payload.selected;
|
||||
|
||||
// Start with the selected component as innermost
|
||||
const innerToOuter: (ComponentInfo | SelectedComponent)[] = [s];
|
||||
|
||||
// Add components that aren't duplicates of selected
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove duplicates by creating unique keys
|
||||
const seen = new Set<string>();
|
||||
return innerToOuter.filter((c) => {
|
||||
const parsed = parsePathWithLineCol(c.pathToSource);
|
||||
const rel = relativizePath(parsed.path, workspaceRoot);
|
||||
const loc = formatLoc(
|
||||
rel,
|
||||
c.source?.lineNumber ?? parsed.line,
|
||||
c.source?.columnNumber ?? parsed.col
|
||||
);
|
||||
const key = `${c.name}|${loc}`;
|
||||
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function formatClickedMarkdown(
|
||||
entry: ClickedEntry,
|
||||
workspaceRoot?: string
|
||||
): string {
|
||||
const { payload, selectedDepth = 0 } = entry;
|
||||
const chain = buildChainInnerToOuter(payload, workspaceRoot);
|
||||
const effectiveChain = chain.slice(selectedDepth); // Start from selected anchor outward
|
||||
|
||||
// DOM
|
||||
const dom = formatDomBits(payload.clickedElement);
|
||||
|
||||
// Use first component in effective chain as the "selected start"
|
||||
const first = effectiveChain[0];
|
||||
const parsed = parsePathWithLineCol(first.pathToSource);
|
||||
const rel = relativizePath(parsed.path, workspaceRoot);
|
||||
const loc = formatLoc(
|
||||
rel,
|
||||
first.source?.lineNumber ?? parsed.line,
|
||||
first.source?.columnNumber ?? parsed.col
|
||||
);
|
||||
|
||||
// Build hierarchy from effective chain
|
||||
const items = effectiveChain.map((c, i) => {
|
||||
const p = parsePathWithLineCol(c.pathToSource);
|
||||
const r = relativizePath(p.path, workspaceRoot);
|
||||
const l = formatLoc(
|
||||
r,
|
||||
c.source?.lineNumber ?? p.line,
|
||||
c.source?.columnNumber ?? p.col
|
||||
);
|
||||
const indent = ' '.repeat(i);
|
||||
const arrow = i > 0 ? '└─ ' : '';
|
||||
const tag = i === 0 ? ' ← start' : '';
|
||||
return `${indent}${arrow}${c.name} (\`${l || 'no source'}\`)${tag}`;
|
||||
});
|
||||
|
||||
return [
|
||||
`From preview click:`,
|
||||
`- DOM: ${dom}`,
|
||||
`- Selected start: ${first.name} (${loc ? `\`${loc}\`` : 'no source'})`,
|
||||
effectiveChain.length > 1
|
||||
? ['- Component hierarchy:', ...items].join('\n')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function ClickedElementsProvider({
|
||||
children,
|
||||
attempt,
|
||||
}: ClickedElementsProviderProps) {
|
||||
const [elements, setElements] = useState<ClickedEntry[]>([]);
|
||||
const workspaceRoot = attempt?.container_ref;
|
||||
|
||||
// Clear elements when attempt changes
|
||||
useEffect(() => {
|
||||
setElements([]);
|
||||
}, [attempt?.id]);
|
||||
|
||||
const addElement = (payload: OpenInEditorPayload) => {
|
||||
const sanitized = stripHeavyProps(payload);
|
||||
const dedupeKey = makeDedupeKey(sanitized, workspaceRoot || undefined);
|
||||
|
||||
setElements((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.dedupeKey === dedupeKey) {
|
||||
return prev; // Skip consecutive duplicate
|
||||
}
|
||||
const newEntry: ClickedEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
payload: sanitized,
|
||||
timestamp: Date.now(),
|
||||
dedupeKey,
|
||||
};
|
||||
const updated = [...prev, newEntry];
|
||||
return updated.length > MAX_ELEMENTS
|
||||
? updated.slice(-MAX_ELEMENTS)
|
||||
: updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeElement = (id: string) => {
|
||||
setElements((prev) => prev.filter((e) => e.id !== id));
|
||||
};
|
||||
|
||||
const clearElements = () => {
|
||||
setElements([]);
|
||||
};
|
||||
|
||||
const selectComponent = (id: string, depthFromInner: number) => {
|
||||
setElements((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === id ? { ...e, selectedDepth: depthFromInner } : e
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const generateMarkdown = () => {
|
||||
if (elements.length === 0) return '';
|
||||
const header = `## Clicked Elements (${elements.length})\n\n`;
|
||||
const body = elements
|
||||
.map((e) => formatClickedMarkdown(e, workspaceRoot || undefined))
|
||||
.join('\n\n');
|
||||
return header + body;
|
||||
};
|
||||
|
||||
return (
|
||||
<ClickedElementsContext.Provider
|
||||
value={{
|
||||
elements,
|
||||
addElement,
|
||||
removeElement,
|
||||
clearElements,
|
||||
selectComponent,
|
||||
generateMarkdown,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClickedElementsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,12 @@ type Args = {
|
||||
message: string;
|
||||
conflictMarkdown: string | null;
|
||||
reviewMarkdown: string;
|
||||
clickedMarkdown?: string;
|
||||
selectedVariant: string | null;
|
||||
images: ImageResponse[];
|
||||
newlyUploadedImageIds: string[];
|
||||
clearComments: () => void;
|
||||
clearClickedElements?: () => void;
|
||||
jumpToLogsTab: () => void;
|
||||
onAfterSendCleanup: () => void;
|
||||
setMessage: (v: string) => void;
|
||||
@@ -21,10 +23,12 @@ export function useFollowUpSend({
|
||||
message,
|
||||
conflictMarkdown,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
selectedVariant,
|
||||
images,
|
||||
newlyUploadedImageIds,
|
||||
clearComments,
|
||||
clearClickedElements,
|
||||
jumpToLogsTab,
|
||||
onAfterSendCleanup,
|
||||
setMessage,
|
||||
@@ -35,7 +39,12 @@ export function useFollowUpSend({
|
||||
const onSendFollowUp = useCallback(async () => {
|
||||
if (!attemptId) return;
|
||||
const extraMessage = message.trim();
|
||||
const finalPrompt = [conflictMarkdown, reviewMarkdown, extraMessage]
|
||||
const finalPrompt = [
|
||||
conflictMarkdown,
|
||||
clickedMarkdown?.trim(),
|
||||
reviewMarkdown?.trim(),
|
||||
extraMessage,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
if (!finalPrompt) return;
|
||||
@@ -58,6 +67,7 @@ export function useFollowUpSend({
|
||||
} as any);
|
||||
setMessage('');
|
||||
clearComments();
|
||||
clearClickedElements?.();
|
||||
onAfterSendCleanup();
|
||||
jumpToLogsTab();
|
||||
} catch (error: unknown) {
|
||||
@@ -73,10 +83,12 @@ export function useFollowUpSend({
|
||||
message,
|
||||
conflictMarkdown,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
newlyUploadedImageIds,
|
||||
images,
|
||||
selectedVariant,
|
||||
clearComments,
|
||||
clearClickedElements,
|
||||
jumpToLogsTab,
|
||||
onAfterSendCleanup,
|
||||
setMessage,
|
||||
|
||||
336
frontend/src/hooks/useDevserverPreview.ts
Normal file
336
frontend/src/hooks/useDevserverPreview.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useExecutionProcesses } from '@/hooks/useExecutionProcesses';
|
||||
import { streamJsonPatchEntries } from '@/utils/streamJsonPatchEntries';
|
||||
import { PatchType } from 'shared/types';
|
||||
import { stripAnsi } from 'fancy-ansi';
|
||||
|
||||
export interface DevserverPreviewState {
|
||||
status: 'idle' | 'searching' | 'ready' | 'error';
|
||||
url?: string;
|
||||
port?: number;
|
||||
scheme: 'http' | 'https';
|
||||
}
|
||||
|
||||
interface UseDevserverPreviewOptions {
|
||||
projectHasDevScript?: boolean;
|
||||
projectId: string; // Required for context-based URL persistence
|
||||
}
|
||||
|
||||
export function useDevserverPreview(
|
||||
attemptId?: string | null | undefined,
|
||||
options: UseDevserverPreviewOptions = {
|
||||
projectId: '',
|
||||
projectHasDevScript: false,
|
||||
}
|
||||
): DevserverPreviewState {
|
||||
const { executionProcesses, error: processesError } = useExecutionProcesses(
|
||||
attemptId || '',
|
||||
{ showSoftDeleted: false }
|
||||
);
|
||||
|
||||
const [state, setState] = useState<DevserverPreviewState>({
|
||||
status: 'idle',
|
||||
scheme: 'http',
|
||||
});
|
||||
|
||||
// Ref to track state for stable callbacks
|
||||
const stateRef = useRef(state);
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const streamRef = useRef<(() => void) | null>(null);
|
||||
const streamTokenRef = useRef(0);
|
||||
const lastProcessedIndexRef = useRef(0);
|
||||
const streamDebounceTimeoutRef = useRef<number | null>(null);
|
||||
const pendingEntriesRef = useRef<Array<{ type: string; content: string }>>(
|
||||
[]
|
||||
);
|
||||
|
||||
// URL detection patterns (in order of priority)
|
||||
const urlPatterns = useMemo(
|
||||
() => [
|
||||
// Full URLs with protocol (localhost and IP addresses only)
|
||||
/(https?:\/\/(?:\[[0-9a-f:]+\]|localhost|127\.0\.0\.1|0\.0\.0\.0|\d{1,3}(?:\.\d{1,3}){3})(?::\d{2,5})?(?:\/\S*)?)/i,
|
||||
// Host:port patterns
|
||||
/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[[0-9a-f:]+\]|(?:\d{1,3}\.){3}\d{1,3}):(\d{2,5})/i,
|
||||
// Port mentions
|
||||
// /port[^0-9]{0,5}(\d{2,5})/i,
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const extractUrlFromLine = useCallback(
|
||||
(line: string) => {
|
||||
// Try full URL pattern first
|
||||
const fullUrlMatch = urlPatterns[0].exec(stripAnsi(line));
|
||||
if (fullUrlMatch) {
|
||||
try {
|
||||
const url = new URL(fullUrlMatch[1]);
|
||||
// Normalize 0.0.0.0 and :: to localhost for preview
|
||||
if (
|
||||
url.hostname === '0.0.0.0' ||
|
||||
url.hostname === '::' ||
|
||||
url.hostname === '[::]'
|
||||
) {
|
||||
url.hostname = 'localhost';
|
||||
}
|
||||
return {
|
||||
url: url.toString(),
|
||||
port: parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80),
|
||||
scheme:
|
||||
url.protocol === 'https:'
|
||||
? ('https' as const)
|
||||
: ('http' as const),
|
||||
};
|
||||
} catch {
|
||||
// Invalid URL, continue to other patterns
|
||||
}
|
||||
}
|
||||
|
||||
// Try host:port pattern
|
||||
const hostPortMatch = urlPatterns[1].exec(line);
|
||||
if (hostPortMatch) {
|
||||
const port = parseInt(hostPortMatch[1]);
|
||||
const scheme = /https/i.test(line) ? 'https' : 'http';
|
||||
return {
|
||||
url: `${scheme}://localhost:${port}`,
|
||||
port,
|
||||
scheme: scheme as 'http' | 'https',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[urlPatterns]
|
||||
);
|
||||
|
||||
const processPendingEntries = useCallback(
|
||||
(currentToken: number) => {
|
||||
// Ignore if this is from a stale stream
|
||||
if (currentToken !== streamTokenRef.current) return;
|
||||
|
||||
// Use ref instead of state deps to avoid dependency churn
|
||||
const currentState = stateRef.current;
|
||||
if (currentState.status === 'ready' && currentState.url) return;
|
||||
|
||||
// Process all pending entries
|
||||
for (const entry of pendingEntriesRef.current) {
|
||||
const urlInfo = extractUrlFromLine(entry.content);
|
||||
if (urlInfo) {
|
||||
setState((prev) => {
|
||||
// Only update if we don't already have a URL for this stream
|
||||
if (prev.status === 'ready' && prev.url) return prev;
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
url: urlInfo.url,
|
||||
port: urlInfo.port,
|
||||
scheme: urlInfo.scheme,
|
||||
};
|
||||
});
|
||||
|
||||
break; // Stop after finding first URL
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed entries
|
||||
pendingEntriesRef.current = [];
|
||||
},
|
||||
[extractUrlFromLine]
|
||||
);
|
||||
|
||||
const debouncedProcessEntries = useCallback(
|
||||
(currentToken: number) => {
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
streamDebounceTimeoutRef.current = window.setTimeout(() => {
|
||||
processPendingEntries(currentToken);
|
||||
}, 200); // Process when stream is quiet for 200ms
|
||||
},
|
||||
[processPendingEntries]
|
||||
);
|
||||
|
||||
const startLogStream = useCallback(
|
||||
async (processId: string) => {
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Increment token to invalidate previous streams
|
||||
const currentToken = ++streamTokenRef.current;
|
||||
|
||||
try {
|
||||
const url = `/api/execution-processes/${processId}/raw-logs/ws`;
|
||||
|
||||
streamJsonPatchEntries<PatchType>(url, {
|
||||
onEntries: (entries) => {
|
||||
// Only process new entries since last time
|
||||
const startIndex = lastProcessedIndexRef.current;
|
||||
const newEntries = entries.slice(startIndex);
|
||||
|
||||
// Add new entries to pending buffer
|
||||
newEntries.forEach((entry) => {
|
||||
if (entry.type === 'STDOUT' || entry.type === 'STDERR') {
|
||||
pendingEntriesRef.current.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
lastProcessedIndexRef.current = entries.length;
|
||||
|
||||
// Debounce processing - only process when stream is quiet
|
||||
debouncedProcessEntries(currentToken);
|
||||
},
|
||||
onFinished: () => {
|
||||
if (currentToken === streamTokenRef.current) {
|
||||
streamRef.current = null;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn(
|
||||
`Error streaming logs for process ${processId}:`,
|
||||
error
|
||||
);
|
||||
if (currentToken === streamTokenRef.current) {
|
||||
streamRef.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store a cleanup function (note: streamJsonPatchEntries doesn't return one,
|
||||
// so we'll rely on the token system for now)
|
||||
streamRef.current = () => {
|
||||
// The stream doesn't provide a direct way to close,
|
||||
// but the token system will ignore future callbacks
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to start log stream for process ${processId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
[debouncedProcessEntries]
|
||||
);
|
||||
|
||||
// Find the latest devserver process
|
||||
const selectedProcess = useMemo(() => {
|
||||
const devserverProcesses = executionProcesses.filter(
|
||||
(process) =>
|
||||
process.run_reason === 'devserver' && process.status === 'running'
|
||||
);
|
||||
|
||||
if (devserverProcesses.length === 0) return null;
|
||||
|
||||
return devserverProcesses.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at as unknown as string).getTime() -
|
||||
new Date(a.created_at as unknown as string).getTime()
|
||||
)[0];
|
||||
}, [executionProcesses]);
|
||||
|
||||
// Update state based on current conditions
|
||||
useEffect(() => {
|
||||
if (processesError) {
|
||||
setState((prev) => ({ ...prev, status: 'error' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProcess) {
|
||||
setState((prev) => {
|
||||
if (prev.status === 'ready') return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: options.projectHasDevScript ? 'searching' : 'idle',
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
if (prev.status === 'ready') return prev;
|
||||
return { ...prev, status: 'searching' };
|
||||
});
|
||||
}, [selectedProcess, processesError, options.projectHasDevScript]);
|
||||
|
||||
// Start streaming logs when selected process changes
|
||||
useEffect(() => {
|
||||
const processId = selectedProcess?.id;
|
||||
if (!processId) {
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set if something actually changes to prevent churn
|
||||
setState((prev) => {
|
||||
if (
|
||||
prev.status === 'searching' &&
|
||||
prev.url === undefined &&
|
||||
prev.port === undefined
|
||||
)
|
||||
return prev;
|
||||
return { ...prev, status: 'searching', url: undefined, port: undefined };
|
||||
});
|
||||
|
||||
// Reset processed index for new stream
|
||||
lastProcessedIndexRef.current = 0;
|
||||
|
||||
// Clear any pending debounced processing
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
streamDebounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear pending entries
|
||||
pendingEntriesRef.current = [];
|
||||
|
||||
startLogStream(processId);
|
||||
}, [selectedProcess?.id, startLogStream]);
|
||||
|
||||
// Reset state when attempt changes
|
||||
useEffect(() => {
|
||||
setState({
|
||||
status: 'idle',
|
||||
scheme: 'http',
|
||||
// Clear url/port so we can re-detect
|
||||
url: undefined,
|
||||
port: undefined,
|
||||
});
|
||||
|
||||
lastProcessedIndexRef.current = 0;
|
||||
|
||||
// Clear any pending debounced processing
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
streamDebounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear pending entries
|
||||
pendingEntriesRef.current = [];
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
streamTokenRef.current++;
|
||||
}, [attemptId]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
51
frontend/src/hooks/useProjectMutations.ts
Normal file
51
frontend/src/hooks/useProjectMutations.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { projectsApi } from '@/lib/api';
|
||||
import type { CreateProject, UpdateProject, Project } from 'shared/types';
|
||||
|
||||
interface UseProjectMutationsOptions {
|
||||
onCreateSuccess?: (project: Project) => void;
|
||||
onCreateError?: (err: unknown) => void;
|
||||
onUpdateSuccess?: (project: Project) => void;
|
||||
onUpdateError?: (err: unknown) => void;
|
||||
}
|
||||
|
||||
export function useProjectMutations(options?: UseProjectMutationsOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationKey: ['createProject'],
|
||||
mutationFn: (data: CreateProject) => projectsApi.create(data),
|
||||
onSuccess: (project: Project) => {
|
||||
queryClient.setQueryData(['project', project.id], project);
|
||||
options?.onCreateSuccess?.(project);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to create project:', err);
|
||||
options?.onCreateError?.(err);
|
||||
},
|
||||
});
|
||||
|
||||
const updateProject = useMutation({
|
||||
mutationKey: ['updateProject'],
|
||||
mutationFn: ({
|
||||
projectId,
|
||||
data,
|
||||
}: {
|
||||
projectId: string;
|
||||
data: UpdateProject;
|
||||
}) => projectsApi.update(projectId, data),
|
||||
onSuccess: (project: Project) => {
|
||||
queryClient.setQueryData(['project', project.id], project);
|
||||
options?.onUpdateSuccess?.(project);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to update project:', err);
|
||||
options?.onUpdateError?.(err);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createProject,
|
||||
updateProject,
|
||||
};
|
||||
}
|
||||
@@ -87,5 +87,48 @@
|
||||
"mergeChanges": "Failed to merge changes",
|
||||
"rebaseBranch": "Failed to rebase branch"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"troubleAlert": {
|
||||
"title": "We're having trouble previewing your application:",
|
||||
"item1": "Did the dev server start successfully? There may be a bug you need to resolve, or perhaps dependencies need to be installed.",
|
||||
"item2": "Did your dev server print the URL and port to the terminal in the format",
|
||||
"item2Suffix": "? (this is how we know it's running)",
|
||||
"item3": "Have you installed the Web Companion (required for click-to-edit)? If not, please",
|
||||
"item3Link": "follow the installation instructions here"
|
||||
},
|
||||
"noServer": {
|
||||
"title": "No dev server running",
|
||||
"startPrompt": "Please start a dev server to see the preview",
|
||||
"setupPrompt": "To use the live preview and click-to-edit, please add a dev server script to this project.",
|
||||
"companionPrompt": "For click-to-edit functionality, add the browser companion to your project.",
|
||||
"companionLink": "View installation guide",
|
||||
"startButton": "Start Dev Server",
|
||||
"editButton": "Edit Dev Script",
|
||||
"stopAndEditButton": "Stop Dev Server & Resolve Issues"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "Save & Start",
|
||||
"saveOnly": "Save Only",
|
||||
"saveChanges": "Save Changes",
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"notLoaded": "Project not loaded",
|
||||
"empty": "Dev script cannot be empty"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Dev Server Logs",
|
||||
"hide": "Hide",
|
||||
"show": "Show"
|
||||
},
|
||||
"iframe": {
|
||||
"title": "Dev server preview"
|
||||
},
|
||||
"toolbar": {
|
||||
"refresh": "Refresh preview",
|
||||
"copyUrl": "Copy URL",
|
||||
"openInTab": "Open in new tab"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,5 +87,48 @@
|
||||
"mergeChanges": "Error al fusionar cambios",
|
||||
"rebaseBranch": "Error al hacer rebase de la rama"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"troubleAlert": {
|
||||
"title": "Tenemos problemas al previsualizar tu aplicación:",
|
||||
"item1": "¿Se inició correctamente el servidor de desarrollo? Puede haber un error que necesites resolver, o quizás sea necesario instalar dependencias.",
|
||||
"item2": "¿Tu servidor de desarrollo imprimió la URL y el puerto en la terminal en el formato",
|
||||
"item2Suffix": "? (así es como sabemos que está funcionando)",
|
||||
"item3": "¿Has instalado el Web Companion (requerido para hacer clic y editar)? Si no, por favor",
|
||||
"item3Link": "sigue las instrucciones de instalación aquí"
|
||||
},
|
||||
"noServer": {
|
||||
"title": "No hay servidor de desarrollo en ejecución",
|
||||
"startPrompt": "Por favor inicia un servidor de desarrollo para ver la vista previa",
|
||||
"setupPrompt": "Para usar la vista previa en vivo y la función de hacer clic y editar, por favor agrega un script de servidor de desarrollo a este proyecto.",
|
||||
"companionPrompt": "Para la funcionalidad de clic y editar, agrega el complemento del navegador a tu proyecto.",
|
||||
"companionLink": "Ver guía de instalación",
|
||||
"startButton": "Iniciar Servidor de Desarrollo",
|
||||
"editButton": "Editar Script de Desarrollo",
|
||||
"stopAndEditButton": "Detener Servidor de Desarrollo y Resolver Problemas"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "Guardar e Iniciar",
|
||||
"saveOnly": "Solo Guardar",
|
||||
"saveChanges": "Guardar Cambios",
|
||||
"cancel": "Cancelar",
|
||||
"errors": {
|
||||
"notLoaded": "Proyecto no cargado",
|
||||
"empty": "El script de desarrollo no puede estar vacío"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "Registros del Servidor de Desarrollo",
|
||||
"hide": "Ocultar",
|
||||
"show": "Mostrar"
|
||||
},
|
||||
"iframe": {
|
||||
"title": "Vista previa del servidor de desarrollo"
|
||||
},
|
||||
"toolbar": {
|
||||
"refresh": "Actualizar vista previa",
|
||||
"copyUrl": "Copiar URL",
|
||||
"openInTab": "Abrir en nueva pestaña"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,5 +87,48 @@
|
||||
"mergeChanges": "変更のマージに失敗しました",
|
||||
"rebaseBranch": "ブランチのリベースに失敗しました"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"troubleAlert": {
|
||||
"title": "アプリケーションのプレビューに問題があります:",
|
||||
"item1": "開発サーバーが正常に起動しましたか?解決すべきバグがあるか、依存関係のインストールが必要な可能性があります。",
|
||||
"item2": "開発サーバーがターミナルにURLとポートを次の形式で出力しましたか",
|
||||
"item2Suffix": "?(これにより実行中であることを認識します)",
|
||||
"item3": "Web Companion(クリックして編集機能に必要)をインストールしましたか?インストールしていない場合は、",
|
||||
"item3Link": "こちらのインストール手順に従ってください"
|
||||
},
|
||||
"noServer": {
|
||||
"title": "開発サーバーが実行されていません",
|
||||
"startPrompt": "プレビューを表示するには開発サーバーを起動してください",
|
||||
"setupPrompt": "ライブプレビューとクリックして編集機能を使用するには、このプロジェクトに開発サーバースクリプトを追加してください。",
|
||||
"companionPrompt": "クリック編集機能のために、プロジェクトにブラウザコンパニオンを追加してください。",
|
||||
"companionLink": "インストールガイドを表示",
|
||||
"startButton": "開発サーバーを開始",
|
||||
"editButton": "開発スクリプトを編集",
|
||||
"stopAndEditButton": "開発サーバーを停止して問題を解決"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "保存して開始",
|
||||
"saveOnly": "保存のみ",
|
||||
"saveChanges": "変更を保存",
|
||||
"cancel": "キャンセル",
|
||||
"errors": {
|
||||
"notLoaded": "プロジェクトが読み込まれていません",
|
||||
"empty": "開発スクリプトを空にすることはできません"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"title": "開発サーバーログ",
|
||||
"hide": "非表示",
|
||||
"show": "表示"
|
||||
},
|
||||
"iframe": {
|
||||
"title": "開発サーバープレビュー"
|
||||
},
|
||||
"toolbar": {
|
||||
"refresh": "プレビューを更新",
|
||||
"copyUrl": "URLをコピー",
|
||||
"openInTab": "新しいタブで開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AlertTriangle, Plus } from 'lucide-react';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { projectsApi, tasksApi, attemptsApi } from '@/lib/api';
|
||||
import { tasksApi, attemptsApi } from '@/lib/api';
|
||||
import { openTaskForm } from '@/lib/openTaskForm';
|
||||
|
||||
import { useSearch } from '@/contexts/search-context';
|
||||
import { useProject } from '@/contexts/project-context';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTaskViewManager } from '@/hooks/useTaskViewManager';
|
||||
import {
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
|
||||
import TaskKanbanBoard from '@/components/tasks/TaskKanbanBoard';
|
||||
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
|
||||
import type { TaskWithAttemptStatus, Project, TaskAttempt } from 'shared/types';
|
||||
import type { TaskWithAttemptStatus, TaskAttempt } from 'shared/types';
|
||||
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
|
||||
import { useProjectTasks } from '@/hooks/useProjectTasks';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
@@ -43,7 +44,7 @@ type Task = TaskWithAttemptStatus;
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const { projectId, taskId, attemptId } = useParams<{
|
||||
const { taskId, attemptId } = useParams<{
|
||||
projectId: string;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
@@ -51,6 +52,14 @@ export function ProjectTasks() {
|
||||
const navigate = useNavigate();
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
|
||||
// Use project context for project data
|
||||
const {
|
||||
project,
|
||||
projectId,
|
||||
isLoading: projectLoading,
|
||||
error: projectError,
|
||||
} = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
enableScope(Scope.KANBAN);
|
||||
|
||||
@@ -59,25 +68,22 @@ export function ProjectTasks() {
|
||||
};
|
||||
}, [enableScope, disableScope]);
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Helper functions to open task forms
|
||||
const handleCreateTask = () => {
|
||||
if (project?.id) {
|
||||
openTaskForm({ projectId: project.id });
|
||||
if (projectId) {
|
||||
openTaskForm({ projectId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTask = (task: Task) => {
|
||||
if (project?.id) {
|
||||
openTaskForm({ projectId: project.id, task });
|
||||
if (projectId) {
|
||||
openTaskForm({ projectId, task });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateTask = (task: Task) => {
|
||||
if (project?.id) {
|
||||
openTaskForm({ projectId: project.id, initialTask: task });
|
||||
if (projectId) {
|
||||
openTaskForm({ projectId, initialTask: task });
|
||||
}
|
||||
};
|
||||
const { query: searchQuery, focusInput } = useSearch();
|
||||
@@ -280,17 +286,6 @@ export function ProjectTasks() {
|
||||
}
|
||||
);
|
||||
|
||||
// Full screen
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
try {
|
||||
const result = await projectsApi.getById(projectId!);
|
||||
setProject(result);
|
||||
} catch (err) {
|
||||
setError('Failed to load project');
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleClosePanel = useCallback(() => {
|
||||
// setIsPanelOpen(false);
|
||||
// setSelectedTask(null);
|
||||
@@ -458,26 +453,16 @@ export function ProjectTasks() {
|
||||
});
|
||||
// UI will update via WebSocket stream
|
||||
} catch (err) {
|
||||
setError('Failed to update task status');
|
||||
console.error('Failed to update task status:', err);
|
||||
}
|
||||
},
|
||||
[tasksById]
|
||||
);
|
||||
|
||||
// Initialize project when projectId changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchProject();
|
||||
}
|
||||
}, [projectId, fetchProject]);
|
||||
// Combine loading states for initial load
|
||||
const isInitialTasksLoad = isLoading && tasks.length === 0;
|
||||
|
||||
// Remove legacy direct-navigation handler; live sync above covers this
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader message={t('loading')} size={32} className="py-8" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (projectError) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Alert>
|
||||
@@ -485,12 +470,18 @@ export function ProjectTasks() {
|
||||
<AlertTriangle size="16" />
|
||||
{t('common:states.error')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{projectError.message || 'Failed to load project'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectLoading && isInitialTasksLoad) {
|
||||
return <Loader message={t('loading')} size={32} className="py-8" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-h-full ${getMainContainerClasses(isPanelOpen, isFullscreen)}`}
|
||||
@@ -548,7 +539,7 @@ export function ProjectTasks() {
|
||||
</div>
|
||||
|
||||
{/* Right Column - Task Details Panel */}
|
||||
{isPanelOpen && (
|
||||
{isPanelOpen && !projectLoading && (
|
||||
<TaskDetailsPanel
|
||||
task={selectedTask}
|
||||
projectHasDevScript={!!project?.dev_script}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type TabType = 'logs' | 'diffs' | 'processes';
|
||||
export type TabType = 'logs' | 'diffs' | 'processes' | 'preview';
|
||||
|
||||
35
frontend/src/utils/companion-install-task.ts
Normal file
35
frontend/src/utils/companion-install-task.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export const COMPANION_INSTALL_TASK_TITLE =
|
||||
'Install and integrate Vibe Kanban Web Companion';
|
||||
|
||||
export const COMPANION_INSTALL_TASK_DESCRIPTION = `Goal: Install and integrate the vibe-kanban-web-companion so it renders at the app root in development.
|
||||
|
||||
Do:
|
||||
1) Detect package manager from lockfiles and use it:
|
||||
- pnpm-lock.yaml → pnpm add vibe-kanban-web-companion
|
||||
- yarn.lock → yarn add vibe-kanban-web-companion
|
||||
- package-lock.json → npm i vibe-kanban-web-companion
|
||||
- bun.lockb → bun add vibe-kanban-web-companion
|
||||
If already listed in package.json dependencies, skip install.
|
||||
|
||||
2) Detect framework and app entry:
|
||||
- Next.js (pages router): pages/_app.(tsx|js)
|
||||
- Next.js (app router): app/layout.(tsx|js) or an app/providers.(tsx|js)
|
||||
- Vite/CRA: src/main.(tsx|jsx|ts|js) and src/App.(tsx|jsx|ts|js)
|
||||
- Monorepo: operate in the correct package for the web app.
|
||||
Confirm by reading package.json and directory structure.
|
||||
|
||||
3) Integrate the component:
|
||||
import { VibeKanbanWebCompanion } from 'vibe-kanban-web-companion';
|
||||
- Vite/CRA: render <VibeKanbanWebCompanion /> at the app root.
|
||||
- Next.js (pages): render in pages/_app.*
|
||||
- Next.js (app): render in app/layout.* or a client providers component.
|
||||
- For Next.js, if SSR issues arise, use dynamic import with ssr: false.
|
||||
|
||||
4) Verify:
|
||||
- Type-check, lint/format if configured.
|
||||
- Ensure it compiles and renders without SSR/hydration errors.
|
||||
|
||||
Acceptance:
|
||||
- vibe-kanban-web-companion is installed in the correct package.
|
||||
- The component is rendered once at the app root without SSR/hydration errors.
|
||||
- Build/type-check passes.`;
|
||||
130
frontend/src/utils/previewBridge.ts
Normal file
130
frontend/src/utils/previewBridge.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
export interface ComponentSource {
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
columnNumber: number;
|
||||
}
|
||||
|
||||
export interface ComponentInfo {
|
||||
name: string;
|
||||
props: Record<string, unknown>;
|
||||
source: ComponentSource;
|
||||
pathToSource: string;
|
||||
}
|
||||
|
||||
export interface SelectedComponent extends ComponentInfo {
|
||||
editor: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ClickedElement {
|
||||
tag?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
role?: string;
|
||||
dataset?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Coordinates {
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
export interface OpenInEditorPayload {
|
||||
selected: SelectedComponent;
|
||||
components: ComponentInfo[];
|
||||
trigger: 'alt-click' | 'context-menu';
|
||||
coords?: Coordinates;
|
||||
clickedElement?: ClickedElement;
|
||||
}
|
||||
|
||||
export interface ClickToComponentMessage {
|
||||
source: 'click-to-component';
|
||||
version: number;
|
||||
type: 'ready' | 'open-in-editor';
|
||||
payload?: OpenInEditorPayload;
|
||||
}
|
||||
|
||||
export interface EventHandlers {
|
||||
onReady?: () => void;
|
||||
onOpenInEditor?: (payload: OpenInEditorPayload) => void;
|
||||
onUnknownMessage?: (message: unknown) => void;
|
||||
}
|
||||
|
||||
export class ClickToComponentListener {
|
||||
private handlers: EventHandlers = {};
|
||||
private messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
constructor(handlers: EventHandlers = {}) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for messages from click-to-component iframe
|
||||
*/
|
||||
start(): void {
|
||||
if (this.messageListener) {
|
||||
this.stop(); // Clean up existing listener
|
||||
}
|
||||
|
||||
this.messageListener = (event: MessageEvent) => {
|
||||
const data = event.data as ClickToComponentMessage;
|
||||
|
||||
// Only handle messages from our click-to-component tool
|
||||
if (!data || data.source !== 'click-to-component') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'ready':
|
||||
this.handlers.onReady?.();
|
||||
break;
|
||||
|
||||
case 'open-in-editor':
|
||||
if (data.payload) {
|
||||
this.handlers.onOpenInEditor?.(data.payload);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.handlers.onUnknownMessage?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', this.messageListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for messages
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.messageListener) {
|
||||
window.removeEventListener('message', this.messageListener);
|
||||
this.messageListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update event handlers
|
||||
*/
|
||||
setHandlers(handlers: EventHandlers): void {
|
||||
this.handlers = { ...this.handlers, ...handlers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the iframe (if needed)
|
||||
*/
|
||||
sendToIframe(iframe: HTMLIFrameElement, message: unknown): void {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(message, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience function for quick setup
|
||||
export function listenToClickToComponent(
|
||||
handlers: EventHandlers
|
||||
): ClickToComponentListener {
|
||||
const listener = new ClickToComponentListener(handlers);
|
||||
listener.start();
|
||||
return listener;
|
||||
}
|
||||
Reference in New Issue
Block a user