diff --git a/.mcp.json b/.mcp.json index 659f15ea7c8..47925bfecab 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,19 +1,3 @@ { - "mcpServers": { - "neon": { - "command": "npx", - "args": ["-y", "@neondatabase/mcp-server-neon@0.6.5", "start"], - "env": { - "NEON_API_KEY": "${NEON_API_KEY}" - } - }, - "morph-warp-grep": { - "command": "npx", - "args": ["-y", "@morphllm/morphmcp"], - "env": { - "MORPH_API_KEY": "${MORPH_API_KEY}", - "ENABLED_TOOLS": "warpgrep_codebase_search" - } - } - } + "mcpServers": {} } diff --git a/apps/desktop/docs/INPUT_LAG_FIXES.md b/apps/desktop/docs/INPUT_LAG_FIXES.md new file mode 100644 index 00000000000..c3c7f3a6317 --- /dev/null +++ b/apps/desktop/docs/INPUT_LAG_FIXES.md @@ -0,0 +1,149 @@ +# Input Lag Performance Fixes + +This document outlines the root causes of input lag in the desktop app and the fixes implemented. + +## Problem Summary + +Users experienced noticeable lag when typing in: +1. Terminal components +2. NewWorkspaceModal input fields + +## Root Causes Identified + +### 1. Global Zustand Store Re-renders (HIGH IMPACT) + +**Location:** `TabsContent/index.tsx:17-19` + +```typescript +const allTabs = useTabsStore((s) => s.tabs); +const panes = useTabsStore((s) => s.panes); +const activeTabIds = useTabsStore((s) => s.activeTabIds); +``` + +**Problem:** The entire `panes` object is passed as a prop to `TabView`. Any change to any pane triggers a re-render of the entire component tree: +1. `updatePaneCwd` or `setPaneStatus` updates the store +2. This changes the `panes` object reference +3. `TabsContent` re-renders → `TabView` re-renders → all `Terminal` components re-render + +### 2. Terminal Component: Multiple Store Selectors (HIGH IMPACT) + +**Location:** `Terminal.tsx:31, 48-53` + +```typescript +const panes = useTabsStore((s) => s.panes); +const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); +``` + +**Problem:** Each Terminal subscribes to the entire `panes` and `focusedPaneIds` objects instead of selecting just its own data. Any terminal update triggers ALL terminals to re-render. + +### 3. CWD Updates on Every Terminal Data Event (MEDIUM IMPACT) + +**Location:** `Terminal.tsx:159-161` + +```typescript +useEffect(() => { + updatePaneCwd(paneId, terminalCwd, cwdConfirmed); +}, [terminalCwd, cwdConfirmed, paneId, updatePaneCwd]); +``` + +**Problem:** Combined with `updateCwdFromData` being called on every stream event, this creates frequent Zustand store updates that propagate to all subscribers. + +### 4. NewWorkspaceModal: No Input Debouncing (MEDIUM IMPACT) + +**Location:** `NewWorkspaceModal.tsx:269-270` + +```typescript +onChange={(e) => setTitle(e.target.value)} +``` + +**Problem:** Every keystroke triggers: +1. `title` state update +2. `useEffect` that updates `branchName` +3. `useMemo` that recalculates `filteredBranches` +4. Full modal re-render + +## Fixes Implemented + +### Fix 1: Granular Selectors in Terminal ✅ + +Changed Terminal component to select only its own pane data instead of all panes: + +```typescript +// Before +const panes = useTabsStore((s) => s.panes); +const pane = panes[paneId]; +const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + +// After +const pane = useTabsStore((s) => s.panes[paneId]); +const focusedPaneId = useTabsStore((s) => + s.focusedPaneIds[s.panes[paneId]?.tabId ?? ""] +); +``` + +### Fix 2: Avoid Passing `panes` Object as Prop ✅ + +Changed `TabsContent` to only select pane IDs for the active tab, and have `TabView` select its own panes internally: + +```typescript +// TabsContent - no longer passes panes prop + + +// TabView - selects its own pane data +const paneIds = useMemo(() => extractPaneIdsFromLayout(tab.layout), [tab.layout]); +``` + +### Fix 3: Debounce CWD Updates ✅ + +Added debouncing to the CWD store sync: + +```typescript +const debouncedUpdatePaneCwd = useRef( + debounce((paneId: string, cwd: string | null, confirmed: boolean) => { + updatePaneCwd(paneId, cwd, confirmed); + }, 150) +); +``` + +### Fix 4: Debounce Title Input in NewWorkspaceModal ✅ + +Added debouncing to the title input with immediate local state for responsive typing: + +```typescript +const [localTitle, setLocalTitle] = useState(""); +const debouncedSetTitle = useMemo( + () => debounce((value: string) => setTitle(value), 150), + [] +); + +const handleTitleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setLocalTitle(value); // Immediate update for responsive typing + debouncedSetTitle(value); // Debounced update for derived state +}; + +// In render - uses localTitle for immediate feedback + +``` + +## Future Improvements (Deferred) + +### Fix 5: React.memo Wrappers + +Wrap frequently re-rendered components with `React.memo`: +- `Terminal` component +- `TabView` component +- `TabPane` component +- `NewWorkspaceModal` component + +This was deferred pending testing of fixes 1-4. + +## Testing + +To verify the fixes work: + +1. **Terminal typing test:** Open multiple terminals and type rapidly in one - the others should not re-render +2. **CWD update test:** Navigate directories in terminal - should not cause lag +3. **NewWorkspaceModal test:** Type rapidly in the title field - should feel responsive + +Use React DevTools Profiler to verify reduced re-renders. diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 13e0210a827..9d69a3857a1 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -27,6 +27,7 @@ import { SelectValue, } from "@superset/ui/select"; import { toast } from "@superset/ui/sonner"; +import debounce from "lodash/debounce"; import { useEffect, useMemo, useRef, useState } from "react"; import { GoGitBranch } from "react-icons/go"; import { HiCheck, HiChevronDown, HiChevronUpDown } from "react-icons/hi2"; @@ -62,6 +63,8 @@ export function NewWorkspaceModal() { const [selectedProjectId, setSelectedProjectId] = useState( null, ); + // Use local title for immediate input feedback, debounce updates to derived state + const [localTitle, setLocalTitle] = useState(""); const [title, setTitle] = useState(""); const [branchName, setBranchName] = useState(""); const [branchNameEdited, setBranchNameEdited] = useState(false); @@ -72,6 +75,25 @@ export function NewWorkspaceModal() { const [showAdvanced, setShowAdvanced] = useState(false); const titleInputRef = useRef(null); + // Debounced title update to reduce re-renders from derived state calculations + const debouncedSetTitle = useMemo( + () => debounce((value: string) => setTitle(value), 150), + [], + ); + + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + debouncedSetTitle.cancel(); + }; + }, [debouncedSetTitle]); + + const handleTitleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setLocalTitle(value); // Immediate update for responsive typing + debouncedSetTitle(value); // Debounced update for derived state + }; + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const { @@ -124,6 +146,7 @@ export function NewWorkspaceModal() { const resetForm = () => { setSelectedProjectId(null); + setLocalTitle(""); setTitle(""); setBranchName(""); setBranchNameEdited(false); @@ -170,7 +193,8 @@ export function NewWorkspaceModal() { const handleCreateWorkspace = async () => { if (!selectedProjectId) return; - const workspaceName = title.trim() || undefined; + // Use localTitle for the actual value (in case debounce hasn't fired yet) + const workspaceName = localTitle.trim() || undefined; const customBranchName = branchName.trim() || undefined; try { @@ -266,15 +290,15 @@ export function NewWorkspaceModal() { id="title" className="h-9 text-sm" placeholder="Feature name (press Enter to create)" - value={title} - onChange={(e) => setTitle(e.target.value)} + value={localTitle} + onChange={handleTitleChange} /> - {title && !showAdvanced && ( + {localTitle && !showAdvanced && (

- {branchName || generateBranchFromTitle(title)} + {branchName || generateBranchFromTitle(localTitle)} from {effectiveBaseBranch} @@ -304,8 +328,8 @@ export function NewWorkspaceModal() { id="branch" className="h-8 text-sm font-mono" placeholder={ - title - ? generateBranchFromTitle(title) + localTitle + ? generateBranchFromTitle(localTitle) : "auto-generated" } value={branchName} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 7ef3c5db38b..94fd04421cf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -2,7 +2,7 @@ import type * as Monaco from "monaco-editor"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MosaicBranch } from "react-mosaic-component"; import { useTabsStore } from "renderer/stores/tabs/store"; -import type { Pane, Tab } from "renderer/stores/tabs/types"; +import type { Tab } from "renderer/stores/tabs/types"; import type { FileViewerMode } from "shared/tabs-types"; import { BasePaneWindow } from "../components"; import { FileViewerContent } from "./components/FileViewerContent"; @@ -14,7 +14,6 @@ import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; interface FileViewerPaneProps { paneId: string; path: MosaicBranch[]; - pane: Pane; isActive: boolean; tabId: string; worktreePath: string; @@ -44,7 +43,6 @@ interface FileViewerPaneProps { export function FileViewerPane({ paneId, path, - pane, isActive, tabId, worktreePath, @@ -57,6 +55,9 @@ export function FileViewerPane({ onMoveToTab, onMoveToNewTab, }: FileViewerPaneProps) { + // Use granular selector to only get this pane's fileViewer data + const fileViewer = useTabsStore((s) => s.panes[paneId]?.fileViewer); + const editorRef = useRef(null); const [isDirty, setIsDirty] = useState(false); const originalContentRef = useRef(""); @@ -66,8 +67,6 @@ export function FileViewerPane({ const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false); const pendingModeRef = useRef(null); - - const fileViewer = pane.fileViewer; const filePath = fileViewer?.filePath ?? ""; const viewMode = fileViewer?.viewMode ?? "raw"; const isPinned = fileViewer?.isPinned ?? false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx index 77a85e0dfeb..8e82a2d5289 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/TabPane.tsx @@ -4,8 +4,9 @@ import { registerPaneRef, unregisterPaneRef, } from "renderer/stores/tabs/pane-refs"; +import { useTabsStore } from "renderer/stores/tabs/store"; import { useTerminalCallbacksStore } from "renderer/stores/tabs/terminal-callbacks"; -import type { Pane, Tab } from "renderer/stores/tabs/types"; +import type { Tab } from "renderer/stores/tabs/types"; import { TabContentContextMenu } from "../TabContentContextMenu"; import { Terminal } from "../Terminal"; import { DirectoryNavigator } from "../Terminal/DirectoryNavigator"; @@ -14,7 +15,6 @@ import { BasePaneWindow, PaneToolbarActions } from "./components"; interface TabPaneProps { paneId: string; path: MosaicBranch[]; - pane: Pane; isActive: boolean; tabId: string; workspaceId: string; @@ -44,7 +44,6 @@ interface TabPaneProps { export function TabPane({ paneId, path, - pane, isActive, tabId, workspaceId, @@ -57,6 +56,10 @@ export function TabPane({ onMoveToTab, onMoveToNewTab, }: TabPaneProps) { + // Use granular selector to only get this pane's cwd data + const paneCwd = useTabsStore((s) => s.panes[paneId]?.cwd); + const paneCwdConfirmed = useTabsStore((s) => s.panes[paneId]?.cwdConfirmed); + const terminalContainerRef = useRef(null); const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback); const getScrollToBottomCallback = useTerminalCallbacksStore( @@ -95,8 +98,8 @@ export function TabPane({

; } -export function TabView({ tab, panes }: TabViewProps) { +export function TabView({ tab }: TabViewProps) { const updateTabLayout = useTabsStore((s) => s.updateTabLayout); const removePane = useTabsStore((s) => s.removePane); const removeTab = useTabsStore((s) => s.removeTab); const { splitPaneAuto, splitPaneHorizontal, splitPaneVertical } = useTabsWithPresets(); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); - const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const focusedPaneId = useTabsStore((s) => s.focusedPaneIds[tab.id]); const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); + const allPanes = useTabsStore((s) => s.panes); // Get worktree path for file viewer panes const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); @@ -46,9 +45,25 @@ export function TabView({ tab, panes }: TabViewProps) { (t) => t.workspaceId === tab.workspaceId, ); - const focusedPaneId = focusedPaneIds[tab.id]; + // Extract pane IDs from layout + const layoutPaneIds = useMemo( + () => extractPaneIdsFromLayout(tab.layout), + [tab.layout], + ); + + // Memoize the filtered panes to avoid creating new objects on every render + const tabPanes = useMemo(() => { + const result: Record = {}; + for (const paneId of layoutPaneIds) { + const pane = allPanes[paneId]; + if (pane?.tabId === tab.id) { + result[paneId] = { tabId: pane.tabId, type: pane.type }; + } + } + return result; + }, [layoutPaneIds, allPanes, tab.id]); - const validPaneIds = new Set(getPaneIdsForTab(panes, tab.id)); + const validPaneIds = new Set(Object.keys(tabPanes)); const cleanedLayout = cleanLayout(tab.layout, validPaneIds); // Auto-remove tab when all panes are gone @@ -85,10 +100,10 @@ export function TabView({ tab, panes }: TabViewProps) { const renderPane = useCallback( (paneId: string, path: MosaicBranch[]) => { - const pane = panes[paneId]; + const paneInfo = tabPanes[paneId]; const isActive = paneId === focusedPaneId; - if (!pane) { + if (!paneInfo) { return (
Pane not found: {paneId} @@ -97,7 +112,7 @@ export function TabView({ tab, panes }: TabViewProps) { } // Route file-viewer panes to FileViewerPane component - if (pane.type === "file-viewer") { + if (paneInfo.type === "file-viewer") { if (!worktreePath) { return (
@@ -109,7 +124,6 @@ export function TabView({ tab, panes }: TabViewProps) { { const paneId = tabId; - const panes = useTabsStore((s) => s.panes); - const pane = panes[paneId]; + // Use granular selectors to avoid re-renders when other panes change + const pane = useTabsStore((s) => s.panes[paneId]); const paneInitialCommands = pane?.initialCommands; const paneInitialCwd = pane?.initialCwd; const clearPaneInitialData = useTabsStore((s) => s.clearPaneInitialData); @@ -48,7 +48,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const setFocusedPane = useTabsStore((s) => s.setFocusedPane); const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); - const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + // Use granular selector - only subscribe to this tab's focused pane + const focusedPaneId = useTabsStore( + (s) => s.focusedPaneIds[pane?.tabId ?? ""], + ); const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const setPaneStatus = useTabsStore((s) => s.setPaneStatus); const terminalTheme = useTerminalTheme(); @@ -56,7 +59,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // Ref for initial theme to avoid recreating terminal on theme change const initialThemeRef = useRef(terminalTheme); - const isFocused = pane?.tabId ? focusedPaneIds[pane.tabId] === paneId : false; + const isFocused = focusedPaneId === paneId; // Refs avoid effect re-runs when these values change const isFocusedRef = useRef(isFocused); @@ -155,10 +158,29 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }, [paneInitialCwd, workspaceCwd, terminalCwd]); - // Sync terminal cwd to store for DirectoryNavigator + // Debounced CWD update to reduce store updates during rapid directory changes + const debouncedUpdatePaneCwdRef = useRef( + debounce((id: string, cwd: string | null, confirmed: boolean) => { + updatePaneCwd(id, cwd, confirmed); + }, 150), + ); + + // Sync terminal cwd to store for DirectoryNavigator (debounced) useEffect(() => { - updatePaneCwd(paneId, terminalCwd, cwdConfirmed); - }, [terminalCwd, cwdConfirmed, paneId, updatePaneCwd]); + debouncedUpdatePaneCwdRef.current( + paneId, + terminalCwd, + cwdConfirmed ?? false, + ); + }, [terminalCwd, cwdConfirmed, paneId]); + + // Cleanup debounced function on unmount + useEffect(() => { + const debouncedFn = debouncedUpdatePaneCwdRef.current; + return () => { + debouncedFn.cancel(); + }; + }, []); // Parse terminal data for cwd (OSC 7 sequences) const updateCwdFromData = useCallback((data: string) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 06e4cab2f7b..ae8087438e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -15,7 +15,6 @@ export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; const allTabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); const activeTabIds = useTabsStore((s) => s.activeTabIds); const { @@ -37,11 +36,7 @@ export function TabsContent() { return (
- {tabToRender ? ( - - ) : ( - - )} + {tabToRender ? : }
{isSidebarOpen && (