Add preview tab + point and click editor (vibe-kanban) (#870)
* Perfect! The preview tab implementation is complete and all type checks pass. Let me provide a summary of what was implemented: 1. **Updated `TabType`** - Added 'preview' to the union type 2. **Created `useDevserverPreview` hook** - Intelligent devserver URL detection with: - Real-time log parsing using the regex `(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d+)[^\d]*` - Three states: idle/searching/ready - URL normalization (0.0.0.0 → localhost) - Process monitoring for setup scripts and dev servers 3. **Built `PreviewTab` component** - Full-featured iframe preview with: - Proper sandbox settings for security - Toolbar with refresh, copy URL, and open external buttons - Smart error handling for mixed content/CORS issues - Clear empty states and loading indicators 4. **Enhanced `TabNavigation`** - Conditionally shows preview tab with: - Monitor icon and spinner when searching - Only appears when devserver is detected or likely 5. **Integrated in `TaskDetailsPanel`** - Wires everything together in fullscreen mode - **Smart Detection**: Parses logs in real-time to find devserver URLs - **Robust Error Handling**: Graceful fallbacks for iframe loading issues - **Security**: Proper iframe sandboxing with necessary permissions - **Performance**: Efficient log processing with deduplication - **UX**: Clear states (searching/ready/error) with helpful messaging The implementation follows the Oracle's architectural recommendations with proper separation of concerns and real-time detection capabilities. * Cleanup script changes for task attempt 4f59c374-1cc5-4b3c-a79a-5f63756a366b * Perfect! Now the preview tab will use the **first** dev server URL it detects rather than being overwritten by subsequent matches. This means: - If both backend (e.g., `:3001`) and frontend (e.g., `:5173`) servers start - The preview tab will show whichever URL appears first in the logs - Subsequent URLs will be ignored to avoid switching the preview unexpectedly This provides a more stable preview experience when multiple dev servers are running. * Cleanup script changes for task attempt 4f59c374-1cc5-4b3c-a79a-5f63756a366b * add parent lib * fmt * Listen for communication from the preview tab (vibe-kanban ace46045) In frontend/src/components/tasks/TaskDetails/PreviewTab.tsx We should expect that the iframe will communicate via frontend/src/utils/previewBridge.ts When a message is received, we should add some details about the clicked element to the follow up textarea * Component to view clicked element (vibe-kanban e3b90cc1) frontend/src/components/tasks/TaskDetails/PreviewTab.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx When a user clicks on an element, we should display a box in the follow up section similar to how we show reviews or conflicts. The section should display a summary of each of the elements, the name of the component and the file location. When the user sends a follow up, a markdown equivalent of the summary should be appended to the top of the follow up message. * Component to view clicked element (vibe-kanban e3b90cc1) frontend/src/components/tasks/TaskDetails/PreviewTab.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx When a user clicks on an element, we should display a box in the follow up section similar to how we show reviews or conflicts. The section should display a summary of each of the elements, the name of the component and the file location. When the user sends a follow up, a markdown equivalent of the summary should be appended to the top of the follow up message. * Tweaks to component click (vibe-kanban 756e1212) Preview tab frontend/src/components/tasks/TaskDetails/PreviewTab.tsx - Preview should remember which URL you were on - Auto select the follow up box after point and click, so you can type feedback Clicked elements: frontend/src/components/tasks/ClickedElementsBanner.tsx, frontend/src/contexts/ClickedElementsProvider.tsx - The list of components should not overflow horizontally, instead we should truncate, omiting components from the left first - If the user clicks on a component, it should omit the downstream components from the list, they should be displayed disabled and the prompt should start from the selected component * strip ansi when parsing dev server URL * cleanup * cleanup * improve help copy * start dev server from preview page * dev server wip * restructure * instructions * fix * restructur * fmt * i18n * i18n fix * config fix * wip cleanup * minor cleanup * Preview tab feedback (vibe-kanban d531fff8) In the PreviewToolbar, each icon button should have a tooltip * fix + fmt * move dev script textarea * improve when help is shown * i18n * improve URL matching * fix close logs * auto install companion * cleanup notices * Copy tweak
This commit is contained in:
committed by
GitHub
parent
0ace01b55f
commit
2781e3651b
@@ -7,10 +7,12 @@ type Args = {
|
||||
message: string;
|
||||
conflictMarkdown: string | null;
|
||||
reviewMarkdown: string;
|
||||
clickedMarkdown?: string;
|
||||
selectedVariant: string | null;
|
||||
images: ImageResponse[];
|
||||
newlyUploadedImageIds: string[];
|
||||
clearComments: () => void;
|
||||
clearClickedElements?: () => void;
|
||||
jumpToLogsTab: () => void;
|
||||
onAfterSendCleanup: () => void;
|
||||
setMessage: (v: string) => void;
|
||||
@@ -21,10 +23,12 @@ export function useFollowUpSend({
|
||||
message,
|
||||
conflictMarkdown,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
selectedVariant,
|
||||
images,
|
||||
newlyUploadedImageIds,
|
||||
clearComments,
|
||||
clearClickedElements,
|
||||
jumpToLogsTab,
|
||||
onAfterSendCleanup,
|
||||
setMessage,
|
||||
@@ -35,7 +39,12 @@ export function useFollowUpSend({
|
||||
const onSendFollowUp = useCallback(async () => {
|
||||
if (!attemptId) return;
|
||||
const extraMessage = message.trim();
|
||||
const finalPrompt = [conflictMarkdown, reviewMarkdown, extraMessage]
|
||||
const finalPrompt = [
|
||||
conflictMarkdown,
|
||||
clickedMarkdown?.trim(),
|
||||
reviewMarkdown?.trim(),
|
||||
extraMessage,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
if (!finalPrompt) return;
|
||||
@@ -58,6 +67,7 @@ export function useFollowUpSend({
|
||||
} as any);
|
||||
setMessage('');
|
||||
clearComments();
|
||||
clearClickedElements?.();
|
||||
onAfterSendCleanup();
|
||||
jumpToLogsTab();
|
||||
} catch (error: unknown) {
|
||||
@@ -73,10 +83,12 @@ export function useFollowUpSend({
|
||||
message,
|
||||
conflictMarkdown,
|
||||
reviewMarkdown,
|
||||
clickedMarkdown,
|
||||
newlyUploadedImageIds,
|
||||
images,
|
||||
selectedVariant,
|
||||
clearComments,
|
||||
clearClickedElements,
|
||||
jumpToLogsTab,
|
||||
onAfterSendCleanup,
|
||||
setMessage,
|
||||
|
||||
336
frontend/src/hooks/useDevserverPreview.ts
Normal file
336
frontend/src/hooks/useDevserverPreview.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useExecutionProcesses } from '@/hooks/useExecutionProcesses';
|
||||
import { streamJsonPatchEntries } from '@/utils/streamJsonPatchEntries';
|
||||
import { PatchType } from 'shared/types';
|
||||
import { stripAnsi } from 'fancy-ansi';
|
||||
|
||||
export interface DevserverPreviewState {
|
||||
status: 'idle' | 'searching' | 'ready' | 'error';
|
||||
url?: string;
|
||||
port?: number;
|
||||
scheme: 'http' | 'https';
|
||||
}
|
||||
|
||||
interface UseDevserverPreviewOptions {
|
||||
projectHasDevScript?: boolean;
|
||||
projectId: string; // Required for context-based URL persistence
|
||||
}
|
||||
|
||||
export function useDevserverPreview(
|
||||
attemptId?: string | null | undefined,
|
||||
options: UseDevserverPreviewOptions = {
|
||||
projectId: '',
|
||||
projectHasDevScript: false,
|
||||
}
|
||||
): DevserverPreviewState {
|
||||
const { executionProcesses, error: processesError } = useExecutionProcesses(
|
||||
attemptId || '',
|
||||
{ showSoftDeleted: false }
|
||||
);
|
||||
|
||||
const [state, setState] = useState<DevserverPreviewState>({
|
||||
status: 'idle',
|
||||
scheme: 'http',
|
||||
});
|
||||
|
||||
// Ref to track state for stable callbacks
|
||||
const stateRef = useRef(state);
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const streamRef = useRef<(() => void) | null>(null);
|
||||
const streamTokenRef = useRef(0);
|
||||
const lastProcessedIndexRef = useRef(0);
|
||||
const streamDebounceTimeoutRef = useRef<number | null>(null);
|
||||
const pendingEntriesRef = useRef<Array<{ type: string; content: string }>>(
|
||||
[]
|
||||
);
|
||||
|
||||
// URL detection patterns (in order of priority)
|
||||
const urlPatterns = useMemo(
|
||||
() => [
|
||||
// Full URLs with protocol (localhost and IP addresses only)
|
||||
/(https?:\/\/(?:\[[0-9a-f:]+\]|localhost|127\.0\.0\.1|0\.0\.0\.0|\d{1,3}(?:\.\d{1,3}){3})(?::\d{2,5})?(?:\/\S*)?)/i,
|
||||
// Host:port patterns
|
||||
/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[[0-9a-f:]+\]|(?:\d{1,3}\.){3}\d{1,3}):(\d{2,5})/i,
|
||||
// Port mentions
|
||||
// /port[^0-9]{0,5}(\d{2,5})/i,
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const extractUrlFromLine = useCallback(
|
||||
(line: string) => {
|
||||
// Try full URL pattern first
|
||||
const fullUrlMatch = urlPatterns[0].exec(stripAnsi(line));
|
||||
if (fullUrlMatch) {
|
||||
try {
|
||||
const url = new URL(fullUrlMatch[1]);
|
||||
// Normalize 0.0.0.0 and :: to localhost for preview
|
||||
if (
|
||||
url.hostname === '0.0.0.0' ||
|
||||
url.hostname === '::' ||
|
||||
url.hostname === '[::]'
|
||||
) {
|
||||
url.hostname = 'localhost';
|
||||
}
|
||||
return {
|
||||
url: url.toString(),
|
||||
port: parseInt(url.port) || (url.protocol === 'https:' ? 443 : 80),
|
||||
scheme:
|
||||
url.protocol === 'https:'
|
||||
? ('https' as const)
|
||||
: ('http' as const),
|
||||
};
|
||||
} catch {
|
||||
// Invalid URL, continue to other patterns
|
||||
}
|
||||
}
|
||||
|
||||
// Try host:port pattern
|
||||
const hostPortMatch = urlPatterns[1].exec(line);
|
||||
if (hostPortMatch) {
|
||||
const port = parseInt(hostPortMatch[1]);
|
||||
const scheme = /https/i.test(line) ? 'https' : 'http';
|
||||
return {
|
||||
url: `${scheme}://localhost:${port}`,
|
||||
port,
|
||||
scheme: scheme as 'http' | 'https',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[urlPatterns]
|
||||
);
|
||||
|
||||
const processPendingEntries = useCallback(
|
||||
(currentToken: number) => {
|
||||
// Ignore if this is from a stale stream
|
||||
if (currentToken !== streamTokenRef.current) return;
|
||||
|
||||
// Use ref instead of state deps to avoid dependency churn
|
||||
const currentState = stateRef.current;
|
||||
if (currentState.status === 'ready' && currentState.url) return;
|
||||
|
||||
// Process all pending entries
|
||||
for (const entry of pendingEntriesRef.current) {
|
||||
const urlInfo = extractUrlFromLine(entry.content);
|
||||
if (urlInfo) {
|
||||
setState((prev) => {
|
||||
// Only update if we don't already have a URL for this stream
|
||||
if (prev.status === 'ready' && prev.url) return prev;
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
url: urlInfo.url,
|
||||
port: urlInfo.port,
|
||||
scheme: urlInfo.scheme,
|
||||
};
|
||||
});
|
||||
|
||||
break; // Stop after finding first URL
|
||||
}
|
||||
}
|
||||
|
||||
// Clear processed entries
|
||||
pendingEntriesRef.current = [];
|
||||
},
|
||||
[extractUrlFromLine]
|
||||
);
|
||||
|
||||
const debouncedProcessEntries = useCallback(
|
||||
(currentToken: number) => {
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
streamDebounceTimeoutRef.current = window.setTimeout(() => {
|
||||
processPendingEntries(currentToken);
|
||||
}, 200); // Process when stream is quiet for 200ms
|
||||
},
|
||||
[processPendingEntries]
|
||||
);
|
||||
|
||||
const startLogStream = useCallback(
|
||||
async (processId: string) => {
|
||||
// Close any existing stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Increment token to invalidate previous streams
|
||||
const currentToken = ++streamTokenRef.current;
|
||||
|
||||
try {
|
||||
const url = `/api/execution-processes/${processId}/raw-logs/ws`;
|
||||
|
||||
streamJsonPatchEntries<PatchType>(url, {
|
||||
onEntries: (entries) => {
|
||||
// Only process new entries since last time
|
||||
const startIndex = lastProcessedIndexRef.current;
|
||||
const newEntries = entries.slice(startIndex);
|
||||
|
||||
// Add new entries to pending buffer
|
||||
newEntries.forEach((entry) => {
|
||||
if (entry.type === 'STDOUT' || entry.type === 'STDERR') {
|
||||
pendingEntriesRef.current.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
lastProcessedIndexRef.current = entries.length;
|
||||
|
||||
// Debounce processing - only process when stream is quiet
|
||||
debouncedProcessEntries(currentToken);
|
||||
},
|
||||
onFinished: () => {
|
||||
if (currentToken === streamTokenRef.current) {
|
||||
streamRef.current = null;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.warn(
|
||||
`Error streaming logs for process ${processId}:`,
|
||||
error
|
||||
);
|
||||
if (currentToken === streamTokenRef.current) {
|
||||
streamRef.current = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Store a cleanup function (note: streamJsonPatchEntries doesn't return one,
|
||||
// so we'll rely on the token system for now)
|
||||
streamRef.current = () => {
|
||||
// The stream doesn't provide a direct way to close,
|
||||
// but the token system will ignore future callbacks
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to start log stream for process ${processId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
[debouncedProcessEntries]
|
||||
);
|
||||
|
||||
// Find the latest devserver process
|
||||
const selectedProcess = useMemo(() => {
|
||||
const devserverProcesses = executionProcesses.filter(
|
||||
(process) =>
|
||||
process.run_reason === 'devserver' && process.status === 'running'
|
||||
);
|
||||
|
||||
if (devserverProcesses.length === 0) return null;
|
||||
|
||||
return devserverProcesses.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at as unknown as string).getTime() -
|
||||
new Date(a.created_at as unknown as string).getTime()
|
||||
)[0];
|
||||
}, [executionProcesses]);
|
||||
|
||||
// Update state based on current conditions
|
||||
useEffect(() => {
|
||||
if (processesError) {
|
||||
setState((prev) => ({ ...prev, status: 'error' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedProcess) {
|
||||
setState((prev) => {
|
||||
if (prev.status === 'ready') return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: options.projectHasDevScript ? 'searching' : 'idle',
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => {
|
||||
if (prev.status === 'ready') return prev;
|
||||
return { ...prev, status: 'searching' };
|
||||
});
|
||||
}, [selectedProcess, processesError, options.projectHasDevScript]);
|
||||
|
||||
// Start streaming logs when selected process changes
|
||||
useEffect(() => {
|
||||
const processId = selectedProcess?.id;
|
||||
if (!processId) {
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set if something actually changes to prevent churn
|
||||
setState((prev) => {
|
||||
if (
|
||||
prev.status === 'searching' &&
|
||||
prev.url === undefined &&
|
||||
prev.port === undefined
|
||||
)
|
||||
return prev;
|
||||
return { ...prev, status: 'searching', url: undefined, port: undefined };
|
||||
});
|
||||
|
||||
// Reset processed index for new stream
|
||||
lastProcessedIndexRef.current = 0;
|
||||
|
||||
// Clear any pending debounced processing
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
streamDebounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear pending entries
|
||||
pendingEntriesRef.current = [];
|
||||
|
||||
startLogStream(processId);
|
||||
}, [selectedProcess?.id, startLogStream]);
|
||||
|
||||
// Reset state when attempt changes
|
||||
useEffect(() => {
|
||||
setState({
|
||||
status: 'idle',
|
||||
scheme: 'http',
|
||||
// Clear url/port so we can re-detect
|
||||
url: undefined,
|
||||
port: undefined,
|
||||
});
|
||||
|
||||
lastProcessedIndexRef.current = 0;
|
||||
|
||||
// Clear any pending debounced processing
|
||||
if (streamDebounceTimeoutRef.current) {
|
||||
clearTimeout(streamDebounceTimeoutRef.current);
|
||||
streamDebounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear pending entries
|
||||
pendingEntriesRef.current = [];
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
streamTokenRef.current++;
|
||||
}, [attemptId]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
51
frontend/src/hooks/useProjectMutations.ts
Normal file
51
frontend/src/hooks/useProjectMutations.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { projectsApi } from '@/lib/api';
|
||||
import type { CreateProject, UpdateProject, Project } from 'shared/types';
|
||||
|
||||
interface UseProjectMutationsOptions {
|
||||
onCreateSuccess?: (project: Project) => void;
|
||||
onCreateError?: (err: unknown) => void;
|
||||
onUpdateSuccess?: (project: Project) => void;
|
||||
onUpdateError?: (err: unknown) => void;
|
||||
}
|
||||
|
||||
export function useProjectMutations(options?: UseProjectMutationsOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createProject = useMutation({
|
||||
mutationKey: ['createProject'],
|
||||
mutationFn: (data: CreateProject) => projectsApi.create(data),
|
||||
onSuccess: (project: Project) => {
|
||||
queryClient.setQueryData(['project', project.id], project);
|
||||
options?.onCreateSuccess?.(project);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to create project:', err);
|
||||
options?.onCreateError?.(err);
|
||||
},
|
||||
});
|
||||
|
||||
const updateProject = useMutation({
|
||||
mutationKey: ['updateProject'],
|
||||
mutationFn: ({
|
||||
projectId,
|
||||
data,
|
||||
}: {
|
||||
projectId: string;
|
||||
data: UpdateProject;
|
||||
}) => projectsApi.update(projectId, data),
|
||||
onSuccess: (project: Project) => {
|
||||
queryClient.setQueryData(['project', project.id], project);
|
||||
options?.onUpdateSuccess?.(project);
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('Failed to update project:', err);
|
||||
options?.onUpdateError?.(err);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
createProject,
|
||||
updateProject,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user