diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73f2a805..ba20c312 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -104,7 +104,7 @@ function AppContent() { return () => { cancelled = true; }; - }, [config, isSignedIn]); + }, [config, isSignedIn, updateAndSaveConfig]); if (loading) { return ( diff --git a/frontend/src/components/NormalizedConversation/EditDiffRenderer.tsx b/frontend/src/components/NormalizedConversation/EditDiffRenderer.tsx index c7da1c7f..d5ba6f5e 100644 --- a/frontend/src/components/NormalizedConversation/EditDiffRenderer.tsx +++ b/frontend/src/components/NormalizedConversation/EditDiffRenderer.tsx @@ -77,7 +77,7 @@ function EditDiffRenderer({ const theme = getActualTheme(config?.theme); const { hunks, hideLineNumbers, additions, deletions, isValidDiff } = useMemo( () => processUnifiedDiff(unifiedDiff, hasLineNumbers), - [path, unifiedDiff, hasLineNumbers] + [unifiedDiff, hasLineNumbers] ); const hideLineNumbersClass = hideLineNumbers ? ' edit-diff-hide-nums' : ''; diff --git a/frontend/src/components/panels/PreviewPanel.tsx b/frontend/src/components/panels/PreviewPanel.tsx index 2aa7aaeb..8b7d3143 100644 --- a/frontend/src/components/panels/PreviewPanel.tsx +++ b/frontend/src/components/panels/PreviewPanel.tsx @@ -114,12 +114,7 @@ export function PreviewPanel() { setShowLogs(true); setLoadingTimeFinished(false); } - }, [ - loadingTimeFinished, - isReady, - latestDevServerProcess?.id, - runningDevServer, - ]); + }, [loadingTimeFinished, isReady, latestDevServerProcess, runningDevServer]); const isPreviewReady = previewState.status === 'ready' && diff --git a/frontend/src/components/projects/project-form-fields.tsx b/frontend/src/components/projects/project-form-fields.tsx index 48d5024e..0beb6d2d 100644 --- a/frontend/src/components/projects/project-form-fields.tsx +++ b/frontend/src/components/projects/project-form-fields.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -77,14 +77,7 @@ export function ProjectFormFields({ const [showMoreOptions, setShowMoreOptions] = useState(false); const [showRecentRepos, setShowRecentRepos] = useState(false); - // Lazy-load repositories when the user navigates to the repo list - useEffect(() => { - if (!isEditing && showRecentRepos && !loading && allRepos.length === 0) { - loadRecentRepos(); - } - }, [isEditing, showRecentRepos]); - - const loadRecentRepos = async () => { + const loadRecentRepos = useCallback(async () => { setLoading(true); setReposError(''); @@ -97,7 +90,14 @@ export function ProjectFormFields({ } finally { setLoading(false); } - }; + }, []); + + // Lazy-load repositories when the user navigates to the repo list + useEffect(() => { + if (!isEditing && showRecentRepos && !loading && allRepos.length === 0) { + loadRecentRepos(); + } + }, [isEditing, showRecentRepos, loading, allRepos.length, loadRecentRepos]); return ( <> diff --git a/frontend/src/components/projects/project-list.tsx b/frontend/src/components/projects/project-list.tsx index 6d76d373..7cb4e8a6 100644 --- a/frontend/src/components/projects/project-list.tsx +++ b/frontend/src/components/projects/project-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -20,7 +20,7 @@ export function ProjectList() { const [error, setError] = useState(''); const [focusedProjectId, setFocusedProjectId] = useState(null); - const fetchProjects = async () => { + const fetchProjects = useCallback(async () => { setLoading(true); setError(''); @@ -33,7 +33,7 @@ export function ProjectList() { } finally { setLoading(false); } - }; + }, [t]); const handleCreateProject = async () => { try { @@ -62,7 +62,7 @@ export function ProjectList() { useEffect(() => { fetchProjects(); - }, []); + }, [fetchProjects]); return (
diff --git a/frontend/src/components/ui/ImageUploadSection.tsx b/frontend/src/components/ui/ImageUploadSection.tsx index 5adcaa09..e839a563 100644 --- a/frontend/src/components/ui/ImageUploadSection.tsx +++ b/frontend/src/components/ui/ImageUploadSection.tsx @@ -89,7 +89,7 @@ export const ImageUploadSection = forwardRef< if (collapsible && images.length > 0 && !isExpanded) { setIsExpanded(true); } - }, [collapsible, images.length]); + }, [collapsible, images.length, isExpanded]); const handleFiles = useCallback( async (filesInput: FileList | File[] | null) => { diff --git a/frontend/src/components/ui/auto-expanding-textarea.tsx b/frontend/src/components/ui/auto-expanding-textarea.tsx index 18ebc990..380c6443 100644 --- a/frontend/src/components/ui/auto-expanding-textarea.tsx +++ b/frontend/src/components/ui/auto-expanding-textarea.tsx @@ -44,7 +44,7 @@ const AutoExpandingTextarea = React.forwardRef< const newHeight = Math.min(textarea.scrollHeight, maxHeight); textarea.style.height = `${newHeight}px`; } - }, [maxRows, disableInternalScroll]); + }, [maxRows, disableInternalScroll, textareaRef]); // Adjust height on mount and when content changes React.useEffect(() => { @@ -52,14 +52,15 @@ const AutoExpandingTextarea = React.forwardRef< }, [adjustHeight, props.value]); // Adjust height on input + const { onInput } = props; const handleInput = React.useCallback( (e: React.FormEvent) => { adjustHeight(); - if (props.onInput) { - props.onInput(e); + if (onInput) { + onInput(e); } }, - [adjustHeight, props.onInput] + [adjustHeight, onInput] ); return ( diff --git a/frontend/src/components/ui/file-search-textarea.tsx b/frontend/src/components/ui/file-search-textarea.tsx index 55005cac..624c82ef 100644 --- a/frontend/src/components/ui/file-search-textarea.tsx +++ b/frontend/src/components/ui/file-search-textarea.tsx @@ -330,7 +330,7 @@ export const FileSearchTextarea = forwardRef< left: finalLeft, maxHeight, }; - }, [searchQuery, value]); + }, [textareaRef]); const [dropdownPosition, setDropdownPosition] = useState(() => getDropdownPosition() diff --git a/frontend/src/hooks/useConversationHistory.ts b/frontend/src/hooks/useConversationHistory.ts index 6c9aaf49..6d93c539 100644 --- a/frontend/src/hooks/useConversationHistory.ts +++ b/frontend/src/hooks/useConversationHistory.ts @@ -10,7 +10,7 @@ import { ToolStatus, } from 'shared/types'; import { useExecutionProcessesContext } from '@/contexts/ExecutionProcessesContext'; -import { useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { streamJsonPatchEntries } from '@/utils/streamJsonPatchEntries'; export type PatchTypeWithKey = PatchType & { @@ -158,67 +158,16 @@ export const useConversationHistory = ({ ); }; - // This emits its own events as they are streamed - const loadRunningAndEmit = ( - executionProcess: ExecutionProcess - ): Promise => { - return new Promise((resolve, reject) => { - let url = ''; - if (executionProcess.executor_action.typ.type === 'ScriptRequest') { - url = `/api/execution-processes/${executionProcess.id}/raw-logs/ws`; - } else { - url = `/api/execution-processes/${executionProcess.id}/normalized-logs/ws`; - } - const controller = streamJsonPatchEntries(url, { - onEntries(entries) { - const patchesWithKey = entries.map((entry, index) => - patchWithKey(entry, executionProcess.id, index) - ); - mergeIntoDisplayed((state) => { - state[executionProcess.id] = { - executionProcess, - entries: patchesWithKey, - }; - }); - emitEntries(displayedExecutionProcesses.current, 'running', false); - }, - onFinished: () => { - emitEntries(displayedExecutionProcesses.current, 'running', false); - controller.close(); - resolve(); - }, - onError: () => { - controller.close(); - reject(); - }, - }); - }); - }; - - // Sometimes it can take a few seconds for the stream to start, wrap the loadRunningAndEmit method - const loadRunningAndEmitWithBackoff = async ( - executionProcess: ExecutionProcess + const patchWithKey = ( + patch: PatchType, + executionProcessId: string, + index: number | 'user' ) => { - for (let i = 0; i < 20; i++) { - try { - await loadRunningAndEmit(executionProcess); - break; - } catch (_) { - await new Promise((resolve) => setTimeout(resolve, 500)); - } - } - }; - - const getActiveAgentProcess = (): ExecutionProcess | null => { - const activeProcesses = executionProcesses?.current.filter( - (p) => - p.status === ExecutionProcessStatus.running && - p.run_reason !== 'devserver' - ); - if (activeProcesses.length > 1) { - console.error('More than one active execution process found'); - } - return activeProcesses[0] || null; + return { + ...patch, + patchKey: `${executionProcessId}:${index}`, + executionProcessId, + }; }; const flattenEntries = ( @@ -242,305 +191,373 @@ export const useConversationHistory = ({ .flatMap((p) => p.entries); }; - const flattenEntriesForEmit = ( - executionProcessState: ExecutionProcessStateStore - ): PatchTypeWithKey[] => { - // Flags to control Next Action bar emit - let hasPendingApproval = false; - let hasRunningProcess = false; - let lastProcessFailedOrKilled = false; - let needsSetup = false; - let setupHelpText: string | undefined; + const getActiveAgentProcess = (): ExecutionProcess | null => { + const activeProcesses = executionProcesses?.current.filter( + (p) => + p.status === ExecutionProcessStatus.running && + p.run_reason !== 'devserver' + ); + if (activeProcesses.length > 1) { + console.error('More than one active execution process found'); + } + return activeProcesses[0] || null; + }; - // Create user messages + tool calls for setup/cleanup scripts - const allEntries = Object.values(executionProcessState) - .sort( - (a, b) => - new Date( - a.executionProcess.created_at as unknown as string - ).getTime() - - new Date(b.executionProcess.created_at as unknown as string).getTime() - ) - .flatMap((p, index) => { - const entries: PatchTypeWithKey[] = []; - if ( - p.executionProcess.executor_action.typ.type === - 'CodingAgentInitialRequest' || - p.executionProcess.executor_action.typ.type === - 'CodingAgentFollowUpRequest' - ) { - // New user message - const userNormalizedEntry: NormalizedEntry = { - entry_type: { - type: 'user_message', - }, - content: p.executionProcess.executor_action.typ.prompt, - timestamp: null, - }; - const userPatch: PatchType = { - type: 'NORMALIZED_ENTRY', - content: userNormalizedEntry, - }; - const userPatchTypeWithKey = patchWithKey( - userPatch, - p.executionProcess.id, - 'user' - ); - entries.push(userPatchTypeWithKey); + const flattenEntriesForEmit = useCallback( + (executionProcessState: ExecutionProcessStateStore): PatchTypeWithKey[] => { + // Flags to control Next Action bar emit + let hasPendingApproval = false; + let hasRunningProcess = false; + let lastProcessFailedOrKilled = false; + let needsSetup = false; + let setupHelpText: string | undefined; - // Remove all coding agent added user messages, replace with our custom one - const entriesExcludingUser = p.entries.filter( - (e) => - e.type !== 'NORMALIZED_ENTRY' || - e.content.entry_type.type !== 'user_message' - ); - - const hasPendingApprovalEntry = entriesExcludingUser.some((entry) => { - if (entry.type !== 'NORMALIZED_ENTRY') return false; - const entryType = entry.content.entry_type; - return ( - entryType.type === 'tool_use' && - entryType.status.status === 'pending_approval' - ); - }); - - if (hasPendingApprovalEntry) { - hasPendingApproval = true; - } - - entries.push(...entriesExcludingUser); - - const liveProcessStatus = getLiveExecutionProcess( - p.executionProcess.id - )?.status; - const isProcessRunning = - liveProcessStatus === ExecutionProcessStatus.running; - const processFailedOrKilled = - liveProcessStatus === ExecutionProcessStatus.failed || - liveProcessStatus === ExecutionProcessStatus.killed; - - if (isProcessRunning) { - hasRunningProcess = true; - } - - if ( - processFailedOrKilled && - index === Object.keys(executionProcessState).length - 1 - ) { - lastProcessFailedOrKilled = true; - - // Check if this failed process has a SetupRequired entry - const hasSetupRequired = entriesExcludingUser.some((entry) => { - if (entry.type !== 'NORMALIZED_ENTRY') return false; - if ( - entry.content.entry_type.type === 'error_message' && - entry.content.entry_type.error_type.type === 'setup_required' - ) { - setupHelpText = entry.content.content; - return true; - } - return false; - }); - - if (hasSetupRequired) { - needsSetup = true; - } - } - - if (isProcessRunning && !hasPendingApprovalEntry) { - entries.push(loadingPatch); - } - } else if ( - p.executionProcess.executor_action.typ.type === 'ScriptRequest' - ) { - // Add setup and cleanup script as a tool call - let toolName = ''; - switch (p.executionProcess.executor_action.typ.context) { - case 'SetupScript': - toolName = 'Setup Script'; - break; - case 'CleanupScript': - toolName = 'Cleanup Script'; - break; - case 'GithubCliSetupScript': - toolName = 'GitHub CLI Setup Script'; - break; - default: - return []; - } - - const executionProcess = getLiveExecutionProcess( - p.executionProcess.id - ); - - if (executionProcess?.status === ExecutionProcessStatus.running) { - hasRunningProcess = true; - } - - if ( - (executionProcess?.status === ExecutionProcessStatus.failed || - executionProcess?.status === ExecutionProcessStatus.killed) && - index === Object.keys(executionProcessState).length - 1 - ) { - lastProcessFailedOrKilled = true; - } - - const exitCode = Number(executionProcess?.exit_code) || 0; - const exit_status: CommandExitStatus | null = - executionProcess?.status === 'running' - ? null - : { - type: 'exit_code', - code: exitCode, - }; - - const toolStatus: ToolStatus = - executionProcess?.status === ExecutionProcessStatus.running - ? { status: 'created' } - : exitCode === 0 - ? { status: 'success' } - : { status: 'failed' }; - - const output = p.entries.map((line) => line.content).join('\n'); - - const toolNormalizedEntry: NormalizedEntry = { - entry_type: { - type: 'tool_use', - tool_name: toolName, - action_type: { - action: 'command_run', - command: p.executionProcess.executor_action.typ.script, - result: { - output, - exit_status, - }, - }, - status: toolStatus, - }, - content: toolName, - timestamp: null, - }; - const toolPatch: PatchType = { - type: 'NORMALIZED_ENTRY', - content: toolNormalizedEntry, - }; - const toolPatchWithKey: PatchTypeWithKey = patchWithKey( - toolPatch, - p.executionProcess.id, - 0 - ); - - entries.push(toolPatchWithKey); - } - - return entries; - }); - - // Emit the next action bar if no process running - if (!hasRunningProcess && !hasPendingApproval) { - allEntries.push( - nextActionPatch( - lastProcessFailedOrKilled, - Object.keys(executionProcessState).length, - needsSetup, - setupHelpText + // Create user messages + tool calls for setup/cleanup scripts + const allEntries = Object.values(executionProcessState) + .sort( + (a, b) => + new Date( + a.executionProcess.created_at as unknown as string + ).getTime() - + new Date( + b.executionProcess.created_at as unknown as string + ).getTime() ) - ); - } + .flatMap((p, index) => { + const entries: PatchTypeWithKey[] = []; + if ( + p.executionProcess.executor_action.typ.type === + 'CodingAgentInitialRequest' || + p.executionProcess.executor_action.typ.type === + 'CodingAgentFollowUpRequest' + ) { + // New user message + const userNormalizedEntry: NormalizedEntry = { + entry_type: { + type: 'user_message', + }, + content: p.executionProcess.executor_action.typ.prompt, + timestamp: null, + }; + const userPatch: PatchType = { + type: 'NORMALIZED_ENTRY', + content: userNormalizedEntry, + }; + const userPatchTypeWithKey = patchWithKey( + userPatch, + p.executionProcess.id, + 'user' + ); + entries.push(userPatchTypeWithKey); - return allEntries; - }; + // Remove all coding agent added user messages, replace with our custom one + const entriesExcludingUser = p.entries.filter( + (e) => + e.type !== 'NORMALIZED_ENTRY' || + e.content.entry_type.type !== 'user_message' + ); - const patchWithKey = ( - patch: PatchType, - executionProcessId: string, - index: number | 'user' - ) => { - return { - ...patch, - patchKey: `${executionProcessId}:${index}`, - executionProcessId, - }; - }; + const hasPendingApprovalEntry = entriesExcludingUser.some( + (entry) => { + if (entry.type !== 'NORMALIZED_ENTRY') return false; + const entryType = entry.content.entry_type; + return ( + entryType.type === 'tool_use' && + entryType.status.status === 'pending_approval' + ); + } + ); - const loadInitialEntries = async (): Promise => { - const localDisplayedExecutionProcesses: ExecutionProcessStateStore = {}; + if (hasPendingApprovalEntry) { + hasPendingApproval = true; + } - if (!executionProcesses?.current) return localDisplayedExecutionProcesses; + entries.push(...entriesExcludingUser); - for (const executionProcess of [...executionProcesses.current].reverse()) { - if (executionProcess.status === ExecutionProcessStatus.running) continue; + const liveProcessStatus = getLiveExecutionProcess( + p.executionProcess.id + )?.status; + const isProcessRunning = + liveProcessStatus === ExecutionProcessStatus.running; + const processFailedOrKilled = + liveProcessStatus === ExecutionProcessStatus.failed || + liveProcessStatus === ExecutionProcessStatus.killed; - const entries = - await loadEntriesForHistoricExecutionProcess(executionProcess); - const entriesWithKey = entries.map((e, idx) => - patchWithKey(e, executionProcess.id, idx) - ); + if (isProcessRunning) { + hasRunningProcess = true; + } - localDisplayedExecutionProcesses[executionProcess.id] = { - executionProcess, - entries: entriesWithKey, - }; + if ( + processFailedOrKilled && + index === Object.keys(executionProcessState).length - 1 + ) { + lastProcessFailedOrKilled = true; - if ( - flattenEntries(localDisplayedExecutionProcesses).length > - MIN_INITIAL_ENTRIES - ) { - break; + // Check if this failed process has a SetupRequired entry + const hasSetupRequired = entriesExcludingUser.some((entry) => { + if (entry.type !== 'NORMALIZED_ENTRY') return false; + if ( + entry.content.entry_type.type === 'error_message' && + entry.content.entry_type.error_type.type === 'setup_required' + ) { + setupHelpText = entry.content.content; + return true; + } + return false; + }); + + if (hasSetupRequired) { + needsSetup = true; + } + } + + if (isProcessRunning && !hasPendingApprovalEntry) { + entries.push(loadingPatch); + } + } else if ( + p.executionProcess.executor_action.typ.type === 'ScriptRequest' + ) { + // Add setup and cleanup script as a tool call + let toolName = ''; + switch (p.executionProcess.executor_action.typ.context) { + case 'SetupScript': + toolName = 'Setup Script'; + break; + case 'CleanupScript': + toolName = 'Cleanup Script'; + break; + case 'GithubCliSetupScript': + toolName = 'GitHub CLI Setup Script'; + break; + default: + return []; + } + + const executionProcess = getLiveExecutionProcess( + p.executionProcess.id + ); + + if (executionProcess?.status === ExecutionProcessStatus.running) { + hasRunningProcess = true; + } + + if ( + (executionProcess?.status === ExecutionProcessStatus.failed || + executionProcess?.status === ExecutionProcessStatus.killed) && + index === Object.keys(executionProcessState).length - 1 + ) { + lastProcessFailedOrKilled = true; + } + + const exitCode = Number(executionProcess?.exit_code) || 0; + const exit_status: CommandExitStatus | null = + executionProcess?.status === 'running' + ? null + : { + type: 'exit_code', + code: exitCode, + }; + + const toolStatus: ToolStatus = + executionProcess?.status === ExecutionProcessStatus.running + ? { status: 'created' } + : exitCode === 0 + ? { status: 'success' } + : { status: 'failed' }; + + const output = p.entries.map((line) => line.content).join('\n'); + + const toolNormalizedEntry: NormalizedEntry = { + entry_type: { + type: 'tool_use', + tool_name: toolName, + action_type: { + action: 'command_run', + command: p.executionProcess.executor_action.typ.script, + result: { + output, + exit_status, + }, + }, + status: toolStatus, + }, + content: toolName, + timestamp: null, + }; + const toolPatch: PatchType = { + type: 'NORMALIZED_ENTRY', + content: toolNormalizedEntry, + }; + const toolPatchWithKey: PatchTypeWithKey = patchWithKey( + toolPatch, + p.executionProcess.id, + 0 + ); + + entries.push(toolPatchWithKey); + } + + return entries; + }); + + // Emit the next action bar if no process running + if (!hasRunningProcess && !hasPendingApproval) { + allEntries.push( + nextActionPatch( + lastProcessFailedOrKilled, + Object.keys(executionProcessState).length, + needsSetup, + setupHelpText + ) + ); } - } - return localDisplayedExecutionProcesses; - }; + return allEntries; + }, + [] + ); - const loadRemainingEntriesInBatches = async ( - batchSize: number - ): Promise => { - if (!executionProcesses?.current) return false; + const emitEntries = useCallback( + ( + executionProcessState: ExecutionProcessStateStore, + addEntryType: AddEntryType, + loading: boolean + ) => { + const entries = flattenEntriesForEmit(executionProcessState); + onEntriesUpdatedRef.current?.(entries, addEntryType, loading); + }, + [flattenEntriesForEmit] + ); - let anyUpdated = false; - for (const executionProcess of [...executionProcesses.current].reverse()) { - const current = displayedExecutionProcesses.current; - if ( - current[executionProcess.id] || - executionProcess.status === ExecutionProcessStatus.running - ) - continue; + // This emits its own events as they are streamed + const loadRunningAndEmit = useCallback( + (executionProcess: ExecutionProcess): Promise => { + return new Promise((resolve, reject) => { + let url = ''; + if (executionProcess.executor_action.typ.type === 'ScriptRequest') { + url = `/api/execution-processes/${executionProcess.id}/raw-logs/ws`; + } else { + url = `/api/execution-processes/${executionProcess.id}/normalized-logs/ws`; + } + const controller = streamJsonPatchEntries(url, { + onEntries(entries) { + const patchesWithKey = entries.map((entry, index) => + patchWithKey(entry, executionProcess.id, index) + ); + mergeIntoDisplayed((state) => { + state[executionProcess.id] = { + executionProcess, + entries: patchesWithKey, + }; + }); + emitEntries(displayedExecutionProcesses.current, 'running', false); + }, + onFinished: () => { + emitEntries(displayedExecutionProcesses.current, 'running', false); + controller.close(); + resolve(); + }, + onError: () => { + controller.close(); + reject(); + }, + }); + }); + }, + [emitEntries] + ); - const entries = - await loadEntriesForHistoricExecutionProcess(executionProcess); - const entriesWithKey = entries.map((e, idx) => - patchWithKey(e, executionProcess.id, idx) - ); + // Sometimes it can take a few seconds for the stream to start, wrap the loadRunningAndEmit method + const loadRunningAndEmitWithBackoff = useCallback( + async (executionProcess: ExecutionProcess) => { + for (let i = 0; i < 20; i++) { + try { + await loadRunningAndEmit(executionProcess); + break; + } catch (_) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + }, + [loadRunningAndEmit] + ); - mergeIntoDisplayed((state) => { - state[executionProcess.id] = { + const loadInitialEntries = + useCallback(async (): Promise => { + const localDisplayedExecutionProcesses: ExecutionProcessStateStore = {}; + + if (!executionProcesses?.current) return localDisplayedExecutionProcesses; + + for (const executionProcess of [ + ...executionProcesses.current, + ].reverse()) { + if (executionProcess.status === ExecutionProcessStatus.running) + continue; + + const entries = + await loadEntriesForHistoricExecutionProcess(executionProcess); + const entriesWithKey = entries.map((e, idx) => + patchWithKey(e, executionProcess.id, idx) + ); + + localDisplayedExecutionProcesses[executionProcess.id] = { executionProcess, entries: entriesWithKey, }; - }); - if ( - flattenEntries(displayedExecutionProcesses.current).length > batchSize - ) { - anyUpdated = true; - break; + if ( + flattenEntries(localDisplayedExecutionProcesses).length > + MIN_INITIAL_ENTRIES + ) { + break; + } } - anyUpdated = true; - } - return anyUpdated; - }; - const emitEntries = ( - executionProcessState: ExecutionProcessStateStore, - addEntryType: AddEntryType, - loading: boolean - ) => { - const entries = flattenEntriesForEmit(executionProcessState); - onEntriesUpdatedRef.current?.(entries, addEntryType, loading); - }; + return localDisplayedExecutionProcesses; + }, [executionProcesses]); - const ensureProcessVisible = (p: ExecutionProcess) => { + const loadRemainingEntriesInBatches = useCallback( + async (batchSize: number): Promise => { + if (!executionProcesses?.current) return false; + + let anyUpdated = false; + for (const executionProcess of [ + ...executionProcesses.current, + ].reverse()) { + const current = displayedExecutionProcesses.current; + if ( + current[executionProcess.id] || + executionProcess.status === ExecutionProcessStatus.running + ) + continue; + + const entries = + await loadEntriesForHistoricExecutionProcess(executionProcess); + const entriesWithKey = entries.map((e, idx) => + patchWithKey(e, executionProcess.id, idx) + ); + + mergeIntoDisplayed((state) => { + state[executionProcess.id] = { + executionProcess, + entries: entriesWithKey, + }; + }); + + if ( + flattenEntries(displayedExecutionProcesses.current).length > batchSize + ) { + anyUpdated = true; + break; + } + anyUpdated = true; + } + return anyUpdated; + }, + [executionProcesses] + ); + + const ensureProcessVisible = useCallback((p: ExecutionProcess) => { mergeIntoDisplayed((state) => { if (!state[p.id]) { state[p.id] = { @@ -554,7 +571,7 @@ export const useConversationHistory = ({ }; } }); - }; + }, []); const idListKey = useMemo( () => executionProcessesRaw?.map((p) => p.id).join(','), @@ -599,7 +616,13 @@ export const useConversationHistory = ({ return () => { cancelled = true; }; - }, [attempt.id, idListKey]); // include idListKey so new processes trigger reload + }, [ + attempt.id, + idListKey, + loadInitialEntries, + loadRemainingEntriesInBatches, + emitEntries, + ]); // include idListKey so new processes trigger reload useEffect(() => { const activeProcess = getActiveAgentProcess(); @@ -621,7 +644,13 @@ export const useConversationHistory = ({ lastActiveProcessId.current = activeProcess.id; loadRunningAndEmitWithBackoff(activeProcess); } - }, [attempt.id, idStatusKey]); + }, [ + attempt.id, + idStatusKey, + emitEntries, + ensureProcessVisible, + loadRunningAndEmitWithBackoff, + ]); // If an execution process is removed, remove it from the state useEffect(() => { @@ -638,7 +667,7 @@ export const useConversationHistory = ({ }); }); } - }, [attempt.id, idListKey]); + }, [attempt.id, idListKey, executionProcessesRaw]); // Reset state when attempt changes useEffect(() => { @@ -646,7 +675,7 @@ export const useConversationHistory = ({ loadedInitialEntries.current = false; lastActiveProcessId.current = null; emitEntries(displayedExecutionProcesses.current, 'initial', true); - }, [attempt.id]); + }, [attempt.id, emitEntries]); return {}; }; diff --git a/frontend/src/hooks/useJsonPatchWsStream.ts b/frontend/src/hooks/useJsonPatchWsStream.ts index a0a711c1..eee09f9c 100644 --- a/frontend/src/hooks/useJsonPatchWsStream.ts +++ b/frontend/src/hooks/useJsonPatchWsStream.ts @@ -30,7 +30,7 @@ export const useJsonPatchWsStream = ( endpoint: string | undefined, enabled: boolean, initialData: () => T, - options: UseJsonPatchStreamOptions = {} + options?: UseJsonPatchStreamOptions ): UseJsonPatchStreamResult => { const [data, setData] = useState(undefined); const [isConnected, setIsConnected] = useState(false); @@ -42,6 +42,9 @@ export const useJsonPatchWsStream = ( const [retryNonce, setRetryNonce] = useState(0); const finishedRef = useRef(false); + const injectInitialEntry = options?.injectInitialEntry; + const deduplicatePatches = options?.deduplicatePatches; + function scheduleReconnect() { if (retryTimerRef.current) return; // already scheduled // Exponential backoff with cap: 1s, 2s, 4s, 8s (max), then stay at 8s @@ -78,8 +81,8 @@ export const useJsonPatchWsStream = ( dataRef.current = initialData(); // Inject initial entry if provided - if (options.injectInitialEntry) { - options.injectInitialEntry(dataRef.current); + if (injectInitialEntry) { + injectInitialEntry(dataRef.current); } } @@ -110,8 +113,8 @@ export const useJsonPatchWsStream = ( // Handle JsonPatch messages (same as SSE json_patch event) if ('JsonPatch' in msg) { const patches: Operation[] = msg.JsonPatch; - const filtered = options.deduplicatePatches - ? options.deduplicatePatches(patches) + const filtered = deduplicatePatches + ? deduplicatePatches(patches) : patches; if (!filtered.length || !dataRef.current) return; @@ -187,8 +190,8 @@ export const useJsonPatchWsStream = ( endpoint, enabled, initialData, - options.injectInitialEntry, - options.deduplicatePatches, + injectInitialEntry, + deduplicatePatches, retryNonce, ]); diff --git a/frontend/src/hooks/useProjectTasks.ts b/frontend/src/hooks/useProjectTasks.ts index 23978f08..1b3fcffc 100644 --- a/frontend/src/hooks/useProjectTasks.ts +++ b/frontend/src/hooks/useProjectTasks.ts @@ -58,8 +58,11 @@ export const useProjectTasks = (projectId: string): UseProjectTasksResult => { initialData ); - const localTasksById = data?.tasks ?? {}; - const sharedTasksById = data?.shared_tasks ?? {}; + const localTasksById = useMemo(() => data?.tasks ?? {}, [data?.tasks]); + const sharedTasksById = useMemo( + () => data?.shared_tasks ?? {}, + [data?.shared_tasks] + ); const { tasks, tasksById, tasksByStatus } = useMemo(() => { const merged: Record = { ...localTasksById }; diff --git a/frontend/src/hooks/useShowcasePersistence.ts b/frontend/src/hooks/useShowcasePersistence.ts index 881beede..e32f5b03 100644 --- a/frontend/src/hooks/useShowcasePersistence.ts +++ b/frontend/src/hooks/useShowcasePersistence.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useUserSystem } from '@/components/config-provider'; export interface ShowcasePersistence { @@ -10,7 +10,10 @@ export interface ShowcasePersistence { export function useShowcasePersistence(): ShowcasePersistence { const { config, updateAndSaveConfig, loading } = useUserSystem(); - const seenFeatures = config?.showcases?.seen_features ?? []; + const seenFeatures = useMemo( + () => config?.showcases?.seen_features ?? [], + [config?.showcases?.seen_features] + ); const hasSeen = useCallback( (id: string): boolean => { diff --git a/frontend/src/keyboard/useSemanticKey.ts b/frontend/src/keyboard/useSemanticKey.ts index fd94f7ed..f0202483 100644 --- a/frontend/src/keyboard/useSemanticKey.ts +++ b/frontend/src/keyboard/useSemanticKey.ts @@ -35,7 +35,7 @@ export function createSemanticHook(action: A) { const isEnabled = when !== undefined ? when : enabled; // Memoize to get stable array references and prevent unnecessary re-registrations - const keys = useMemo(() => getKeysFor(action, scope), [action, scope]); + const keys = useMemo(() => getKeysFor(action, scope), [scope]); useHotkeys( keys, diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index dbec5e12..a5467105 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -224,6 +224,14 @@ export function ProjectTasks() { })[0].id; }, [attempts]); + const navigateWithSearch = useCallback( + (pathname: string, options?: { replace?: boolean }) => { + const search = searchParams.toString(); + navigate({ pathname, search: search ? `?${search}` : '' }, options); + }, + [navigate, searchParams] + ); + useEffect(() => { if (!projectId || !taskId) return; if (!isLatest) return; @@ -244,6 +252,7 @@ export function ProjectTasks() { isAttemptsLoading, latestAttemptId, navigate, + navigateWithSearch, ]); useEffect(() => { @@ -296,14 +305,6 @@ export function ProjectTasks() { [searchParams, setSearchParams] ); - const navigateWithSearch = useCallback( - (pathname: string, options?: { replace?: boolean }) => { - const search = searchParams.toString(); - navigate({ pathname, search: search ? `?${search}` : '' }, options); - }, - [navigate, searchParams] - ); - const handleCreateNewTask = useCallback(() => { handleCreateTask(); }, [handleCreateTask]); diff --git a/frontend/src/pages/settings/McpSettings.tsx b/frontend/src/pages/settings/McpSettings.tsx index b976d47c..e246bb02 100644 --- a/frontend/src/pages/settings/McpSettings.tsx +++ b/frontend/src/pages/settings/McpSettings.tsx @@ -106,7 +106,7 @@ export function McpSettings() { if (selectedProfile) { loadMcpServersForProfile(selectedProfile); } - }, [selectedProfile]); + }, [selectedProfile, profiles]); const handleMcpServersChange = (value: string) => { setMcpServers(value);