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

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