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:
Louis Knight-Webb
2025-10-01 17:15:12 +01:00
committed by GitHub
parent 0ace01b55f
commit 2781e3651b
24 changed files with 2224 additions and 282 deletions

View File

@@ -50,6 +50,9 @@ module.exports = {
'style',
'aria-describedby',
],
'jsx-components': {
exclude: ['code'],
},
},
]
: 'off',

View File

@@ -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>
);

View File

@@ -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>
)}

View 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}
>
&lt;{component.name}/&gt;
</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>
);
};

View 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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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={

View File

@@ -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:

View 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>
);
}

View File

@@ -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,

View 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;
}

View 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,
};
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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": "新しいタブで開く"
}
}
}

View File

@@ -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}

View File

@@ -1 +1 @@
export type TabType = 'logs' | 'diffs' | 'processes';
export type TabType = 'logs' | 'diffs' | 'processes' | 'preview';

View 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.`;

View 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;
}