Files
vibe-kanban/frontend/src/components/panels/PreviewPanel.tsx
Louis Knight-Webb 5e7742da2a Batch B: Fix React Hooks dependency warnings in core files (vibe-kanban) (#1307)
* Excellent! All done! Let me create a summary:

##  Batch B Complete: All React Hooks Exhaustive-Deps Warnings Fixed

Successfully fixed **all 22 exhaustive-deps warnings** across the codebase. The changes improved code correctness and stability.

### Files Fixed (15 total):
1. **useProjectTasks.ts** - Wrapped logical expressions in useMemo
2. **useConversationHistory.ts** - Wrapped functions in useCallback, added missing deps
3. **useShowcasePersistence.ts** - Wrapped logical expression in useMemo
4. **auto-expanding-textarea.tsx** - Added missing deps, destructured props
5. **App.tsx** - Added missing updateAndSaveConfig dep
6. **EditDiffRenderer.tsx** - Removed unnecessary path dep
7. **PreviewPanel.tsx** - Fixed latestDevServerProcess dependency
8. **project-form-fields.tsx** - Wrapped loadRecentRepos in useCallback
9. **project-list.tsx** - Wrapped fetchProjects in useCallback
10. **ImageUploadSection.tsx** - Added isExpanded dep
11. **file-search-textarea.tsx** - Added textareaRef dep
12. **useJsonPatchWsStream.ts** - Fixed options dependency
13. **useSemanticKey.ts** - Removed unnecessary action dep
14. **McpSettings.tsx** - Added profiles dep
15. **project-tasks.tsx** - Moved navigateWithSearch before usage

### Key Changes:
- Added `useCallback` where functions are used in dependency arrays
- Added `useMemo` to stabilize object/array references
- Fixed function declaration order to avoid hoisting issues
- One ESLint disable for circular dependency case (documented)

### Verification:
-  **0 exhaustive-deps warnings** (down from 22)
-  **Type check passes** (`pnpm run check`)
-  **All changes follow style guide** (simplest, most readable)

* Cleanup script changes for task attempt 0564411a-2cf4-488a-a082-d0f7ebcfc101

* Perfect! All fixed. Let me create a summary:

##  Fixed Infinite Re-render Issue in useJsonPatchWsStream

### Root Cause
The `options` object had a new identity on every render (from default `= {}`), causing the useEffect to restart infinitely and trigger continuous re-renders.

### Changes Applied

**useJsonPatchWsStream.ts:**
1.  Removed default parameter: `options?: UseJsonPatchStreamOptions<T>` (no more `= {}`)
2.  Destructured options at the top:
   ```typescript
   const injectInitialEntry = options?.injectInitialEntry;
   const deduplicatePatches = options?.deduplicatePatches;
   ```
3.  Updated all usages to use destructured variables
4.  Fixed dependency array:
   ```typescript
   }, [endpoint, enabled, initialData, injectInitialEntry, deduplicatePatches, retryNonce]);
   ```

**useConversationHistory.ts:**
- Added comment explaining why `emitEntries` is omitted from deps (it's stable)

### Verification
-  **Type check passes** (`pnpm run check`)
-  **0 exhaustive-deps warnings**
-  **No infinite re-renders** (destructured variables have stable identities)

### Why This Works
- Destructured variables (`injectInitialEntry`, `deduplicatePatches`) maintain stable identities across renders (undefined stays undefined)
- Only changes when callbacks are actually different, not on every render
- More explicit and readable - shows exactly what triggers reconnection

* Cleanup script changes for task attempt 0564411a-2cf4-488a-a082-d0f7ebcfc101

* Perfect! Let me create a final summary of all the work done:

##  Complete: Removed eslint-disable Tech Debt

### What Was Done
Refactored `useConversationHistory.ts` to eliminate the eslint-disable by reordering function definitions so dependencies are declared before use.

### Changes
**Reordered functions in useConversationHistory.ts:**
```
Old order:
  loadRunningAndEmit → emitEntries (hoisting error)

New order:
  1. patchWithKey (helper)
  2. flattenEntries (helper)
  3. getActiveAgentProcess (helper)
  4. flattenEntriesForEmit (useCallback)
  5. emitEntries (useCallback, depends on flattenEntriesForEmit) 
  6. loadRunningAndEmit (useCallback, depends on emitEntries) 
  7. loadRunningAndEmitWithBackoff (depends on loadRunningAndEmit)
```

**Updated dependency array:**
- `loadRunningAndEmit`: Now properly includes `[emitEntries]`
- Removed eslint-disable comment

### Final Results
-  **0 exhaustive-deps warnings** (down from 22)
-  **TypeScript check passes**
-  **No eslint-disable tech debt**
-  **No infinite re-renders**
-  **More readable** (dependencies declared before use)

All React Hooks warnings properly fixed with clean, maintainable code!

* Cleanup script changes for task attempt 0564411a-2cf4-488a-a082-d0f7ebcfc101
2025-11-17 22:12:23 +00:00

249 lines
7.6 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDevserverPreview } from '@/hooks/useDevserverPreview';
import { useDevServer } from '@/hooks/useDevServer';
import { useLogStream } from '@/hooks/useLogStream';
import { useDevserverUrlFromLogs } from '@/hooks/useDevserverUrl';
import { ClickToComponentListener } from '@/utils/previewBridge';
import { useClickedElements } from '@/contexts/ClickedElementsProvider';
import { Alert } from '@/components/ui/alert';
import { useProject } from '@/contexts/project-context';
import { DevServerLogsView } from '@/components/tasks/TaskDetails/preview/DevServerLogsView';
import { PreviewToolbar } from '@/components/tasks/TaskDetails/preview/PreviewToolbar';
import { NoServerContent } from '@/components/tasks/TaskDetails/preview/NoServerContent';
import { ReadyContent } from '@/components/tasks/TaskDetails/preview/ReadyContent';
export function PreviewPanel() {
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);
const { t } = useTranslation('tasks');
const { project, projectId } = useProject();
const { attemptId: rawAttemptId } = useParams<{ attemptId?: string }>();
const attemptId =
rawAttemptId && rawAttemptId !== 'latest' ? rawAttemptId : undefined;
const projectHasDevScript = Boolean(project?.dev_script);
const {
start: startDevServer,
stop: stopDevServer,
isStarting: isStartingDevServer,
isStopping: isStoppingDevServer,
runningDevServer,
latestDevServerProcess,
} = useDevServer(attemptId);
const logStream = useLogStream(latestDevServerProcess?.id ?? '');
const lastKnownUrl = useDevserverUrlFromLogs(logStream.logs);
const previewState = useDevserverPreview(attemptId, {
projectHasDevScript,
projectId: projectId!,
lastKnownUrl,
});
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);
}
};
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();
}, []);
useEffect(() => {
if (
loadingTimeFinished &&
!isReady &&
latestDevServerProcess &&
runningDevServer
) {
setShowHelp(true);
setShowLogs(true);
setLoadingTimeFinished(false);
}
}, [loadingTimeFinished, isReady, latestDevServerProcess, runningDevServer]);
const isPreviewReady =
previewState.status === 'ready' &&
Boolean(previewState.url) &&
!iframeError;
const mode = iframeError
? 'error'
: isPreviewReady
? 'ready'
: runningDevServer
? 'searching'
: 'noServer';
const toggleLogs = () => {
setShowLogs((v) => !v);
};
const handleStartDevServer = () => {
setLoadingTimeFinished(false);
startDevServer();
startTimer();
setShowHelp(false);
setIsReady(false);
};
const handleStopAndEdit = () => {
stopDevServer(undefined, {
onSuccess: () => {
setShowHelp(false);
},
});
};
if (!attemptId) {
return (
<div className="h-full flex items-center justify-center p-8">
<div className="text-center text-muted-foreground">
<p className="text-lg font-medium">{t('preview.title')}</p>
<p className="text-sm mt-2">{t('preview.selectAttempt')}</p>
</div>
</div>
);
}
return (
<div className="h-full 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}
onStop={stopDevServer}
isStopping={isStoppingDevServer}
/>
<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">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 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>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowHelp(false)}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
</Alert>
)}
<DevServerLogsView
latestDevServerProcess={latestDevServerProcess}
showLogs={showLogs}
onToggle={toggleLogs}
showToggleText
logs={logStream.logs}
error={logStream.error}
/>
</div>
</div>
);
}