* 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
533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
import {
|
|
ImageIcon,
|
|
Loader2,
|
|
Send,
|
|
StopCircle,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
|
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
|
//
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { imagesApi } from '@/lib/api.ts';
|
|
import type { TaskWithAttemptStatus } from 'shared/types';
|
|
import { useBranchStatus } from '@/hooks';
|
|
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
|
|
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';
|
|
import { useDraftEditor } from '@/hooks/follow-up/useDraftEditor';
|
|
import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave';
|
|
import { useDraftQueue } from '@/hooks/follow-up/useDraftQueue';
|
|
import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend';
|
|
import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant';
|
|
import { buildResolveConflictsInstructions } from '@/lib/conflicts';
|
|
import { appendImageMarkdown } from '@/utils/markdownImages';
|
|
|
|
interface TaskFollowUpSectionProps {
|
|
task: TaskWithAttemptStatus;
|
|
selectedAttemptId?: string;
|
|
jumpToLogsTab: () => void;
|
|
}
|
|
|
|
export function TaskFollowUpSection({
|
|
task,
|
|
selectedAttemptId,
|
|
jumpToLogsTab,
|
|
}: TaskFollowUpSectionProps) {
|
|
const { isAttemptRunning, stopExecution, isStopping, processes } =
|
|
useAttemptExecution(selectedAttemptId, task.id);
|
|
const { data: branchStatus, refetch: refetchBranchStatus } =
|
|
useBranchStatus(selectedAttemptId);
|
|
const { branch: attemptBranch, refetch: refetchAttemptBranch } =
|
|
useAttemptBranch(selectedAttemptId);
|
|
const { profiles } = useUserSystem();
|
|
const { comments, generateReviewMarkdown, clearComments } = useReview();
|
|
const {
|
|
generateMarkdown: generateClickedMarkdown,
|
|
clearElements: clearClickedElements,
|
|
} = useClickedElements();
|
|
|
|
const reviewMarkdown = useMemo(
|
|
() => generateReviewMarkdown(),
|
|
[generateReviewMarkdown]
|
|
);
|
|
|
|
const clickedMarkdown = useMemo(
|
|
() => generateClickedMarkdown(),
|
|
[generateClickedMarkdown]
|
|
);
|
|
|
|
// Non-editable conflict resolution instructions (derived, like review comments)
|
|
const conflictResolutionInstructions = useMemo(() => {
|
|
const hasConflicts = (branchStatus?.conflicted_files?.length ?? 0) > 0;
|
|
if (!hasConflicts) return null;
|
|
return buildResolveConflictsInstructions(
|
|
attemptBranch,
|
|
branchStatus?.target_branch_name,
|
|
branchStatus?.conflicted_files || [],
|
|
branchStatus?.conflict_op ?? null
|
|
);
|
|
}, [
|
|
attemptBranch,
|
|
branchStatus?.target_branch_name,
|
|
branchStatus?.conflicted_files,
|
|
branchStatus?.conflict_op,
|
|
]);
|
|
|
|
// Draft stream and synchronization
|
|
const { draft, isDraftLoaded } = useDraftStream(selectedAttemptId);
|
|
|
|
// Editor state
|
|
const {
|
|
message: followUpMessage,
|
|
setMessage: setFollowUpMessage,
|
|
images,
|
|
setImages,
|
|
newlyUploadedImageIds,
|
|
handleImageUploaded,
|
|
clearImagesAndUploads,
|
|
} = useDraftEditor({
|
|
draft,
|
|
taskId: task.id,
|
|
});
|
|
|
|
// Presentation-only: show/hide image upload panel
|
|
const [showImageUpload, setShowImageUpload] = useState(false);
|
|
|
|
// Variant selection (with keyboard cycling)
|
|
const { selectedVariant, setSelectedVariant, currentProfile } =
|
|
useDefaultVariant({ processes, profiles: profiles ?? null });
|
|
|
|
// Queue management (including derived lock flag)
|
|
const { onQueue, onUnqueue } = useDraftQueue({
|
|
attemptId: selectedAttemptId,
|
|
draft,
|
|
message: followUpMessage,
|
|
selectedVariant,
|
|
images,
|
|
});
|
|
|
|
// Presentation-only queue state
|
|
const [isQueuing, setIsQueuing] = useState(false);
|
|
const [isUnqueuing, setIsUnqueuing] = useState(false);
|
|
// Local queued state override after server action completes; null = rely on server
|
|
const [queuedOptimistic, setQueuedOptimistic] = useState<boolean | null>(
|
|
null
|
|
);
|
|
|
|
// Server + presentation derived flags (computed early so they are usable below)
|
|
const isQueued = !!draft?.queued;
|
|
const displayQueued = queuedOptimistic ?? isQueued;
|
|
|
|
// During retry, follow-up box is greyed/disabled (not hidden)
|
|
// Use RetryUi context so optimistic retry immediately disables this box
|
|
const { activeRetryProcessId } = useRetryUi();
|
|
const isRetryActive = !!activeRetryProcessId;
|
|
|
|
// Autosave draft when editing
|
|
const { isSaving, saveStatus } = useDraftAutosave({
|
|
attemptId: selectedAttemptId,
|
|
serverDraft: draft,
|
|
current: {
|
|
prompt: followUpMessage,
|
|
variant: selectedVariant,
|
|
image_ids: images.map((img) => img.id),
|
|
},
|
|
isQueuedUI: displayQueued,
|
|
isDraftSending: !!draft?.sending,
|
|
isQueuing: isQueuing,
|
|
isUnqueuing: isUnqueuing,
|
|
});
|
|
|
|
// Send follow-up action
|
|
const { isSendingFollowUp, followUpError, setFollowUpError, onSendFollowUp } =
|
|
useFollowUpSend({
|
|
attemptId: selectedAttemptId,
|
|
message: followUpMessage,
|
|
conflictMarkdown: conflictResolutionInstructions,
|
|
reviewMarkdown,
|
|
clickedMarkdown,
|
|
selectedVariant,
|
|
images,
|
|
newlyUploadedImageIds,
|
|
clearComments,
|
|
clearClickedElements,
|
|
jumpToLogsTab,
|
|
onAfterSendCleanup: clearImagesAndUploads,
|
|
setMessage: setFollowUpMessage,
|
|
});
|
|
|
|
// Profile/variant derived from processes only (see useDefaultVariant)
|
|
|
|
// Separate logic for when textarea should be disabled vs when send button should be disabled
|
|
const canTypeFollowUp = useMemo(() => {
|
|
if (!selectedAttemptId || processes.length === 0 || isSendingFollowUp) {
|
|
return false;
|
|
}
|
|
|
|
// Check if PR is merged - if so, block follow-ups
|
|
if (branchStatus?.merges) {
|
|
const mergedPR = branchStatus.merges.find(
|
|
(m) => m.type === 'pr' && m.pr_info.status === 'merged'
|
|
);
|
|
if (mergedPR) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (isRetryActive) return false; // disable typing while retry editor is active
|
|
return true;
|
|
}, [
|
|
selectedAttemptId,
|
|
processes.length,
|
|
isSendingFollowUp,
|
|
branchStatus?.merges,
|
|
isRetryActive,
|
|
]);
|
|
|
|
const canSendFollowUp = useMemo(() => {
|
|
if (!canTypeFollowUp) {
|
|
return false;
|
|
}
|
|
|
|
// Allow sending if conflict instructions, review comments, clicked elements, or message is present
|
|
return Boolean(
|
|
conflictResolutionInstructions ||
|
|
reviewMarkdown ||
|
|
clickedMarkdown ||
|
|
followUpMessage.trim()
|
|
);
|
|
}, [
|
|
canTypeFollowUp,
|
|
conflictResolutionInstructions,
|
|
reviewMarkdown,
|
|
clickedMarkdown,
|
|
followUpMessage,
|
|
]);
|
|
// currentProfile is provided by useDefaultVariant
|
|
|
|
const isDraftLocked =
|
|
displayQueued || isQueuing || isUnqueuing || !!draft?.sending;
|
|
const isEditable = isDraftLoaded && !isDraftLocked && !isRetryActive;
|
|
|
|
// When a process completes (e.g., agent resolved conflicts), refresh branch status promptly
|
|
const prevRunningRef = useRef<boolean>(isAttemptRunning);
|
|
useEffect(() => {
|
|
if (prevRunningRef.current && !isAttemptRunning && selectedAttemptId) {
|
|
refetchBranchStatus();
|
|
refetchAttemptBranch();
|
|
}
|
|
prevRunningRef.current = isAttemptRunning;
|
|
}, [
|
|
isAttemptRunning,
|
|
selectedAttemptId,
|
|
refetchBranchStatus,
|
|
refetchAttemptBranch,
|
|
]);
|
|
|
|
// When server indicates sending started, clear draft and images; hide upload panel
|
|
const prevSendingRef = useRef<boolean>(!!draft?.sending);
|
|
useEffect(() => {
|
|
const now = !!draft?.sending;
|
|
if (now && !prevSendingRef.current) {
|
|
if (followUpMessage !== '') setFollowUpMessage('');
|
|
if (images.length > 0 || newlyUploadedImageIds.length > 0) {
|
|
clearImagesAndUploads();
|
|
}
|
|
if (showImageUpload) setShowImageUpload(false);
|
|
if (queuedOptimistic !== null) setQueuedOptimistic(null);
|
|
}
|
|
prevSendingRef.current = now;
|
|
}, [
|
|
draft?.sending,
|
|
followUpMessage,
|
|
setFollowUpMessage,
|
|
images.length,
|
|
newlyUploadedImageIds.length,
|
|
clearImagesAndUploads,
|
|
showImageUpload,
|
|
queuedOptimistic,
|
|
]);
|
|
|
|
// On server queued state change, drop optimistic override and stop spinners accordingly
|
|
useEffect(() => {
|
|
setQueuedOptimistic(null);
|
|
if (isQueued) {
|
|
if (isQueuing) setIsQueuing(false);
|
|
} else {
|
|
if (isUnqueuing) setIsUnqueuing(false);
|
|
}
|
|
}, [isQueued, isQueuing, isUnqueuing]);
|
|
|
|
return (
|
|
selectedAttemptId && (
|
|
<div
|
|
className={cn(
|
|
'border-t p-4 focus-within:ring ring-inset',
|
|
isRetryActive && 'opacity-50'
|
|
)}
|
|
>
|
|
<div className="space-y-2">
|
|
{followUpError && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{followUpError}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<div className="space-y-2">
|
|
{showImageUpload && (
|
|
<div className="mb-2">
|
|
<ImageUploadSection
|
|
images={images}
|
|
onImagesChange={setImages}
|
|
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
|
|
onDelete={imagesApi.delete}
|
|
onImageUploaded={(image) => {
|
|
handleImageUploaded(image);
|
|
setFollowUpMessage((prev) =>
|
|
appendImageMarkdown(prev, image)
|
|
);
|
|
}}
|
|
disabled={!isEditable}
|
|
collapsible={false}
|
|
defaultExpanded={true}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review comments preview */}
|
|
{reviewMarkdown && (
|
|
<div className="text-sm mb-4">
|
|
<div className="whitespace-pre-wrap">{reviewMarkdown}</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflict notice and actions (optional UI) */}
|
|
{branchStatus && (
|
|
<FollowUpConflictSection
|
|
selectedAttemptId={selectedAttemptId}
|
|
attemptBranch={attemptBranch}
|
|
branchStatus={branchStatus}
|
|
isEditable={isEditable}
|
|
onResolve={onSendFollowUp}
|
|
enableResolve={
|
|
canSendFollowUp && !isAttemptRunning && isEditable
|
|
}
|
|
enableAbort={canSendFollowUp && !isAttemptRunning}
|
|
conflictResolutionInstructions={conflictResolutionInstructions}
|
|
/>
|
|
)}
|
|
|
|
{/* Clicked elements notice and actions */}
|
|
<ClickedElementsBanner />
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<FollowUpEditorCard
|
|
placeholder={
|
|
isQueued
|
|
? 'Type your follow-up… It will auto-send when ready.'
|
|
: reviewMarkdown || conflictResolutionInstructions
|
|
? '(Optional) Add additional instructions... Type @ to search files.'
|
|
: 'Continue working on this task attempt... Type @ to search files.'
|
|
}
|
|
value={followUpMessage}
|
|
onChange={(value) => {
|
|
setFollowUpMessage(value);
|
|
if (followUpError) setFollowUpError(null);
|
|
}}
|
|
disabled={!isEditable}
|
|
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
|
|
onCommandEnter={onSendFollowUp}
|
|
onCommandShiftEnter={onSendFollowUp}
|
|
/>
|
|
<FollowUpStatusRow
|
|
status={{
|
|
save: { state: saveStatus, isSaving },
|
|
draft: {
|
|
isLoaded: isDraftLoaded,
|
|
isSending: !!draft?.sending,
|
|
},
|
|
queue: { isUnqueuing: isUnqueuing, isQueued: displayQueued },
|
|
}}
|
|
/>
|
|
<div className="flex flex-row gap-2 items-center">
|
|
<div className="flex-1 flex gap-2">
|
|
{/* Image button */}
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setShowImageUpload(!showImageUpload)}
|
|
disabled={!isEditable}
|
|
>
|
|
<ImageIcon
|
|
className={cn(
|
|
'h-4 w-4',
|
|
(images.length > 0 || showImageUpload) && 'text-primary'
|
|
)}
|
|
/>
|
|
</Button>
|
|
|
|
<VariantSelector
|
|
currentProfile={currentProfile}
|
|
selectedVariant={selectedVariant}
|
|
onChange={setSelectedVariant}
|
|
disabled={!isEditable}
|
|
/>
|
|
</div>
|
|
|
|
{isAttemptRunning ? (
|
|
<Button
|
|
onClick={stopExecution}
|
|
disabled={isStopping}
|
|
size="sm"
|
|
variant="destructive"
|
|
>
|
|
{isStopping ? (
|
|
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
|
) : (
|
|
<>
|
|
<StopCircle className="h-4 w-4 mr-2" />
|
|
Stop
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
{comments.length > 0 && (
|
|
<Button
|
|
onClick={clearComments}
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={!isEditable}
|
|
>
|
|
Clear Review Comments
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={onSendFollowUp}
|
|
disabled={
|
|
!canSendFollowUp ||
|
|
isDraftLocked ||
|
|
!isDraftLoaded ||
|
|
isSendingFollowUp ||
|
|
isRetryActive
|
|
}
|
|
size="sm"
|
|
>
|
|
{isSendingFollowUp ? (
|
|
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
|
) : (
|
|
<>
|
|
<Send className="h-4 w-4 mr-2" />
|
|
{conflictResolutionInstructions
|
|
? 'Resolve conflicts'
|
|
: 'Send'}
|
|
</>
|
|
)}
|
|
</Button>
|
|
{isQueued && (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="min-w-[180px] transition-all"
|
|
onClick={async () => {
|
|
setIsUnqueuing(true);
|
|
try {
|
|
const ok = await onUnqueue();
|
|
if (ok) setQueuedOptimistic(false);
|
|
} finally {
|
|
setIsUnqueuing(false);
|
|
}
|
|
}}
|
|
disabled={isUnqueuing}
|
|
>
|
|
{isUnqueuing ? (
|
|
<>
|
|
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
|
Unqueuing…
|
|
</>
|
|
) : (
|
|
'Edit'
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{isAttemptRunning && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={async () => {
|
|
if (displayQueued) {
|
|
setIsUnqueuing(true);
|
|
try {
|
|
const ok = await onUnqueue();
|
|
if (ok) setQueuedOptimistic(false);
|
|
} finally {
|
|
setIsUnqueuing(false);
|
|
}
|
|
} else {
|
|
setIsQueuing(true);
|
|
try {
|
|
const ok = await onQueue();
|
|
if (ok) setQueuedOptimistic(true);
|
|
} finally {
|
|
setIsQueuing(false);
|
|
}
|
|
}
|
|
}}
|
|
disabled={
|
|
displayQueued
|
|
? isUnqueuing
|
|
: !canSendFollowUp ||
|
|
!isDraftLoaded ||
|
|
isQueuing ||
|
|
isUnqueuing ||
|
|
!!draft?.sending ||
|
|
isRetryActive
|
|
}
|
|
size="sm"
|
|
variant="default"
|
|
className="md:min-w-[180px] transition-all"
|
|
>
|
|
{displayQueued ? (
|
|
isUnqueuing ? (
|
|
<>
|
|
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
|
Unqueuing…
|
|
</>
|
|
) : (
|
|
'Edit'
|
|
)
|
|
) : isQueuing ? (
|
|
<>
|
|
<Loader2 className="animate-spin h-4 w-4 mr-2" />
|
|
Queuing…
|
|
</>
|
|
) : (
|
|
'Queue for next turn'
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
);
|
|
}
|