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
89 changes: 64 additions & 25 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import styled from "@emotion/styled";
import { Global, css } from "@emotion/react";
import { GlobalColors } from "./styles/colors";
Expand All @@ -20,6 +20,7 @@ import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
import { useGitStatusStoreRaw } from "./stores/GitStatusStore";

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

const handleToggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => !prev);
}, [setSidebarCollapsed]);

// Use custom hooks for project and workspace management
const { projects, setProjects, addProject, removeProject } = useProjectManagement();

Expand Down Expand Up @@ -265,6 +270,25 @@ function AppInner() {
setWorkspaceModalOpen(true);
}, []);

// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
const handleAddProjectCallback = useCallback(() => {
void addProject();
}, [addProject]);

const handleAddWorkspaceCallback = useCallback(
(projectPath: string) => {
void handleAddWorkspace(projectPath);
},
[handleAddWorkspace]
);

const handleRemoveProjectCallback = useCallback(
(path: string) => {
void handleRemoveProject(path);
},
[handleRemoveProject]
);

const handleCreateWorkspace = async (branchName: string, trunkBranch: string) => {
if (!workspaceModalProject) return;

Expand Down Expand Up @@ -297,26 +321,41 @@ function AppInner() {
const workspaceRecency = useWorkspaceRecency();

// Sort workspaces by recency (most recent first)
// This ensures navigation follows the visual order displayed in the sidebar
const sortedWorkspacesByProject = useMemo(() => {
const result = new Map<string, ProjectConfig["workspaces"]>();
for (const [projectPath, config] of projects) {
result.set(
projectPath,
config.workspaces.slice().sort((a, b) => {
const aMeta = workspaceMetadata.get(a.path);
const bMeta = workspaceMetadata.get(b.path);
if (!aMeta || !bMeta) return 0;

// Get timestamp of most recent user message (0 if never used)
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
return bTimestamp - aTimestamp;
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => {
const result = new Map<string, ProjectConfig["workspaces"]>();
for (const [projectPath, config] of projects) {
result.set(
projectPath,
config.workspaces.slice().sort((a, b) => {
const aMeta = workspaceMetadata.get(a.path);
const bMeta = workspaceMetadata.get(b.path);
if (!aMeta || !bMeta) return 0;

// Get timestamp of most recent user message (0 if never used)
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
return bTimestamp - aTimestamp;
})
);
}
return result;
},
(prev, next) => {
// Compare Maps: check if both size and workspace order are the same
if (
!compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
return a.every((workspace, i) => workspace.path === b[i].path);
})
);
}
return result;
}, [projects, workspaceMetadata, workspaceRecency]);
) {
return false;
}
return true;
},
[projects, workspaceMetadata, workspaceRecency]
);

const handleNavigateWorkspace = useCallback(
(direction: "next" | "prev") => {
Expand Down Expand Up @@ -523,7 +562,7 @@ function AppInner() {
const allStates = workspaceStore.getAllStates();
const streamingModels = new Map<string, string>();
for (const [workspaceId, state] of allStates) {
if (state.canInterrupt) {
if (state.canInterrupt && state.currentModel) {
streamingModels.set(workspaceId, state.currentModel);
}
}
Expand Down Expand Up @@ -582,15 +621,15 @@ function AppInner() {
workspaceMetadata={workspaceMetadata}
selectedWorkspace={selectedWorkspace}
onSelectWorkspace={setSelectedWorkspace}
onAddProject={() => void addProject()}
onAddWorkspace={(projectPath) => void handleAddWorkspace(projectPath)}
onRemoveProject={(path) => void handleRemoveProject(path)}
onAddProject={handleAddProjectCallback}
onAddWorkspace={handleAddWorkspaceCallback}
onRemoveProject={handleRemoveProjectCallback}
onRemoveWorkspace={removeWorkspace}
onRenameWorkspace={renameWorkspace}
lastReadTimestamps={lastReadTimestamps}
onToggleUnread={onToggleUnread}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => setSidebarCollapsed((prev) => !prev)}
onToggleCollapsed={handleToggleSidebar}
onGetSecrets={handleGetSecrets}
onUpdateSecrets={handleUpdateSecrets}
sortedWorkspacesByProject={sortedWorkspacesByProject}
Expand Down
16 changes: 11 additions & 5 deletions src/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
// Handle keyboard shortcuts (using optional refs that are safe even if not initialized)
useAIViewKeybinds({
workspaceId,
currentModel: workspaceState?.currentModel ?? "claude-sonnet-4-5",
currentModel: workspaceState?.currentModel ?? null,
canInterrupt: workspaceState?.canInterrupt ?? false,
showRetryBarrier: workspaceState
? !workspaceState.canInterrupt && hasInterruptedStream(workspaceState.messages)
Expand Down Expand Up @@ -388,14 +388,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
}

return (
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel}>
<ChatProvider messages={messages} cmuxMessages={cmuxMessages} model={currentModel ?? "unknown"}>
<ViewContainer className={className}>
<ChatArea>
<ViewHeader>
<WorkspaceTitle>
<StatusIndicator
streaming={canInterrupt}
title={canInterrupt ? `${getModelName(currentModel)} streaming` : "Idle"}
title={
canInterrupt && currentModel ? `${getModelName(currentModel)} streaming` : "Idle"
}
/>
<GitStatusIndicator
gitStatus={gitStatus}
Expand Down Expand Up @@ -448,7 +450,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
message={msg}
onEditUserMessage={handleEditUserMessage}
workspaceId={workspaceId}
model={currentModel}
model={currentModel ?? undefined}
/>
{isAtCutoff && (
<EditBarrier>
Expand All @@ -473,7 +475,11 @@ const AIViewInner: React.FC<AIViewProps> = ({
{canInterrupt && (
<StreamingBarrier
statusText={
isCompacting ? "compacting..." : `${getModelName(currentModel)} streaming...`
isCompacting
? "compacting..."
: currentModel
? `${getModelName(currentModel)} streaming...`
: "streaming..."
}
cancelText={`hit ${formatKeybind(KEYBINDS.INTERRUPT_STREAM)} to cancel`}
tokenCount={
Expand Down
Loading