Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 1 addition & 17 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
149 changes: 149 additions & 0 deletions apps/desktop/docs/INPUT_LAG_FIXES.md
Original file line number Diff line number Diff line change
@@ -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 tab={tabToRender} />

// 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<HTMLInputElement>) => {
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
<Input value={localTitle} onChange={handleTitleChange} />
```

## 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.
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,6 +63,8 @@ export function NewWorkspaceModal() {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
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);
Expand All @@ -72,6 +75,25 @@ export function NewWorkspaceModal() {
const [showAdvanced, setShowAdvanced] = useState(false);
const titleInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 {
Expand Down Expand Up @@ -124,6 +146,7 @@ export function NewWorkspaceModal() {

const resetForm = () => {
setSelectedProjectId(null);
setLocalTitle("");
setTitle("");
setBranchName("");
setBranchNameEdited(false);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<GoGitBranch className="size-3" />
<span className="font-mono">
{branchName || generateBranchFromTitle(title)}
{branchName || generateBranchFromTitle(localTitle)}
</span>
<span className="text-muted-foreground/60">
from {effectiveBaseBranch}
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,7 +14,6 @@ import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
interface FileViewerPaneProps {
paneId: string;
path: MosaicBranch[];
pane: Pane;
isActive: boolean;
tabId: string;
worktreePath: string;
Expand Down Expand Up @@ -44,7 +43,6 @@ interface FileViewerPaneProps {
export function FileViewerPane({
paneId,
path,
pane,
isActive,
tabId,
worktreePath,
Expand All @@ -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<Monaco.editor.IStandaloneCodeEditor | null>(null);
const [isDirty, setIsDirty] = useState(false);
const originalContentRef = useRef<string>("");
Expand All @@ -66,8 +67,6 @@ export function FileViewerPane({
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false);
const pendingModeRef = useRef<FileViewerMode | null>(null);

const fileViewer = pane.fileViewer;
const filePath = fileViewer?.filePath ?? "";
const viewMode = fileViewer?.viewMode ?? "raw";
const isPinned = fileViewer?.isPinned ?? false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,7 +15,6 @@ import { BasePaneWindow, PaneToolbarActions } from "./components";
interface TabPaneProps {
paneId: string;
path: MosaicBranch[];
pane: Pane;
isActive: boolean;
tabId: string;
workspaceId: string;
Expand Down Expand Up @@ -44,7 +44,6 @@ interface TabPaneProps {
export function TabPane({
paneId,
path,
pane,
isActive,
tabId,
workspaceId,
Expand All @@ -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<HTMLDivElement>(null);
const getClearCallback = useTerminalCallbacksStore((s) => s.getClearCallback);
const getScrollToBottomCallback = useTerminalCallbacksStore(
Expand Down Expand Up @@ -95,8 +98,8 @@ export function TabPane({
<div className="flex min-w-0 items-center gap-2">
<DirectoryNavigator
paneId={paneId}
currentCwd={pane.cwd}
cwdConfirmed={pane.cwdConfirmed}
currentCwd={paneCwd}
cwdConfirmed={paneCwdConfirmed}
/>
</div>
<PaneToolbarActions
Expand Down
Loading
Loading