Skip to content

Commit 4a4f628

Browse files
authored
🤖 Optimize sidebar renders (#248)
Follow-up cleanup work for MapStore implementation. ## Changes **Extracted WorkspaceListItem component** (316 lines) - Moved from nested component in ProjectSidebar to standalone file - Includes all workspace-specific UI logic and styled components - Self-contained with clear responsibilities **Created RenameContext** (91 lines) - Coordinates rename state across all workspace items ("one at a time" enforcement) - Handles the actual rename IPC call - Components manage their own local edit state **Reduced ProjectSidebar** (897 lines, down from 1210) - Removed 313 lines of workspace item logic - No longer manages per-workspace rename state - Cleaner separation of concerns ## Props Reduction WorkspaceListItem now receives **11 props instead of 18** (-7 props): **Removed:** - `isEditing`, `editingName`, `originalName`, `renameError` (now local state) - `setEditingName` (managed internally) - `startRenaming`, `confirmRename`, `handleRenameKeyDown` (use context) **Benefits:** - ✅ Only edited workspace re-renders during rename (not all items) - ✅ Better separation: global coordination in context, local state in component - ✅ Cleaner interfaces with fewer props - ✅ Same UX (still only one workspace can be renamed at a time) ## Testing All 486 tests pass. _Generated with `cmux`_
1 parent f7cfcbd commit 4a4f628

13 files changed

+895
-678
lines changed

src/App.tsx

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
1+
import { useState, useEffect, useCallback, useRef } from "react";
22
import styled from "@emotion/styled";
33
import { Global, css } from "@emotion/react";
44
import { GlobalColors } from "./styles/colors";
@@ -20,6 +20,7 @@ import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
2020
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
2121
import { useGitStatusStoreRaw } from "./stores/GitStatusStore";
2222

23+
import { useStableReference, compareMaps } from "./hooks/useStableReference";
2324
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
2425
import type { CommandAction } from "./contexts/CommandRegistryContext";
2526
import { CommandPalette } from "./components/CommandPalette";
@@ -149,6 +150,10 @@ function AppInner() {
149150
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
150151
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", false);
151152

153+
const handleToggleSidebar = useCallback(() => {
154+
setSidebarCollapsed((prev) => !prev);
155+
}, [setSidebarCollapsed]);
156+
152157
// Use custom hooks for project and workspace management
153158
const { projects, setProjects, addProject, removeProject } = useProjectManagement();
154159

@@ -265,6 +270,25 @@ function AppInner() {
265270
setWorkspaceModalOpen(true);
266271
}, []);
267272

273+
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
274+
const handleAddProjectCallback = useCallback(() => {
275+
void addProject();
276+
}, [addProject]);
277+
278+
const handleAddWorkspaceCallback = useCallback(
279+
(projectPath: string) => {
280+
void handleAddWorkspace(projectPath);
281+
},
282+
[handleAddWorkspace]
283+
);
284+
285+
const handleRemoveProjectCallback = useCallback(
286+
(path: string) => {
287+
void handleRemoveProject(path);
288+
},
289+
[handleRemoveProject]
290+
);
291+
268292
const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => {
269293
if (!workspaceModalProject) return;
270294

@@ -297,26 +321,41 @@ function AppInner() {
297321
const workspaceRecency = useWorkspaceRecency();
298322

299323
// Sort workspaces by recency (most recent first)
300-
// This ensures navigation follows the visual order displayed in the sidebar
301-
const sortedWorkspacesByProject = useMemo(() => {
302-
const result = new Map<string, ProjectConfig["workspaces"]>();
303-
for (const [projectPath, config] of projects) {
304-
result.set(
305-
projectPath,
306-
config.workspaces.slice().sort((a, b) => {
307-
const aMeta = workspaceMetadata.get(a.path);
308-
const bMeta = workspaceMetadata.get(b.path);
309-
if (!aMeta || !bMeta) return 0;
310-
311-
// Get timestamp of most recent user message (0 if never used)
312-
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
313-
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
314-
return bTimestamp - aTimestamp;
324+
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
325+
const sortedWorkspacesByProject = useStableReference(
326+
() => {
327+
const result = new Map<string, ProjectConfig["workspaces"]>();
328+
for (const [projectPath, config] of projects) {
329+
result.set(
330+
projectPath,
331+
config.workspaces.slice().sort((a, b) => {
332+
const aMeta = workspaceMetadata.get(a.path);
333+
const bMeta = workspaceMetadata.get(b.path);
334+
if (!aMeta || !bMeta) return 0;
335+
336+
// Get timestamp of most recent user message (0 if never used)
337+
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
338+
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
339+
return bTimestamp - aTimestamp;
340+
})
341+
);
342+
}
343+
return result;
344+
},
345+
(prev, next) => {
346+
// Compare Maps: check if both size and workspace order are the same
347+
if (
348+
!compareMaps(prev, next, (a, b) => {
349+
if (a.length !== b.length) return false;
350+
return a.every((workspace, i) => workspace.path === b[i].path);
315351
})
316-
);
317-
}
318-
return result;
319-
}, [projects, workspaceMetadata, workspaceRecency]);
352+
) {
353+
return false;
354+
}
355+
return true;
356+
},
357+
[projects, workspaceMetadata, workspaceRecency]
358+
);
320359

321360
const handleNavigateWorkspace = useCallback(
322361
(direction: "next" | "prev") => {
@@ -523,7 +562,7 @@ function AppInner() {
523562
const allStates = workspaceStore.getAllStates();
524563
const streamingModels = new Map<string, string>();
525564
for (const [workspaceId, state] of allStates) {
526-
if (state.canInterrupt) {
565+
if (state.canInterrupt && state.currentModel) {
527566
streamingModels.set(workspaceId, state.currentModel);
528567
}
529568
}
@@ -582,15 +621,15 @@ function AppInner() {
582621
workspaceMetadata={workspaceMetadata}
583622
selectedWorkspace={selectedWorkspace}
584623
onSelectWorkspace={setSelectedWorkspace}
585-
onAddProject={() => void addProject()}
586-
onAddWorkspace={(projectPath) => void handleAddWorkspace(projectPath)}
587-
onRemoveProject={(path) => void handleRemoveProject(path)}
624+
onAddProject={handleAddProjectCallback}
625+
onAddWorkspace={handleAddWorkspaceCallback}
626+
onRemoveProject={handleRemoveProjectCallback}
588627
onRemoveWorkspace={removeWorkspace}
589628
onRenameWorkspace={renameWorkspace}
590629
lastReadTimestamps={lastReadTimestamps}
591630
onToggleUnread={onToggleUnread}
592631
collapsed={sidebarCollapsed}
593-
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
632+
onToggleCollapsed={handleToggleSidebar}
594633
onGetSecrets={handleGetSecrets}
595634
onUpdateSecrets={handleUpdateSecrets}
596635
sortedWorkspacesByProject={sortedWorkspacesByProject}

src/components/AIView.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
313313
// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
314314
useAIViewKeybinds({
315315
workspaceId,
316-
currentModel: workspaceState?.currentModel ?? "claude-sonnet-4-5",
316+
currentModel: workspaceState?.currentModel ?? null,
317317
canInterrupt: workspaceState?.canInterrupt ?? false,
318318
showRetryBarrier: workspaceState
319319
? !workspaceState.canInterrupt && hasInterruptedStream(workspaceState.messages)
@@ -388,14 +388,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
388388
}
389389

390390
return (
391-
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel}>
391+
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel ?? "unknown"}>
392392
<ViewContainer className={className}>
393393
<ChatArea>
394394
<ViewHeader>
395395
<WorkspaceTitle>
396396
<StatusIndicator
397397
streaming={canInterrupt}
398-
title={canInterrupt ? `${getModelName(currentModel)} streaming` : "Idle"}
398+
title={
399+
canInterrupt && currentModel ? `${getModelName(currentModel)} streaming` : "Idle"
400+
}
399401
/>
400402
<GitStatusIndicator
401403
gitStatus={gitStatus}
@@ -448,7 +450,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
448450
message={msg}
449451
onEditUserMessage={handleEditUserMessage}
450452
workspaceId={workspaceId}
451-
model={currentModel}
453+
model={currentModel ?? undefined}
452454
/>
453455
{isAtCutoff && (
454456
<EditBarrier>
@@ -473,7 +475,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
473475
{canInterrupt && (
474476
<StreamingBarrier
475477
statusText={
476-
isCompacting ? "compacting..." : `${getModelName(currentModel)} streaming...`
478+
isCompacting
479+
? "compacting..."
480+
: currentModel
481+
? `${getModelName(currentModel)} streaming...`
482+
: "streaming..."
477483
}
478484
cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`}
479485
tokenCount={

0 commit comments

Comments
 (0)