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
12 changes: 12 additions & 0 deletions bun.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "mux",
Expand Down Expand Up @@ -88,6 +89,7 @@
"eslint-plugin-storybook": "10.0.0",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"geist": "^1.5.1",
"happy-dom": "^20.0.10",
"jest": "^30.1.3",
"mermaid": "^11.12.0",
"nodemon": "^3.1.10",
Expand Down Expand Up @@ -955,6 +957,8 @@

"@types/wait-on": ["@types/wait-on@5.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw=="],

"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],

"@types/write-file-atomic": ["@types/write-file-atomic@4.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-qdo+vZRchyJIHNeuI1nrpsLw+hnkgqP/8mlaN6Wle/NKhydHmUN9l4p3ZE8yP90AJNJW4uB8HQhedb4f1vNayQ=="],

"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
Expand Down Expand Up @@ -1757,6 +1761,8 @@

"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],

"happy-dom": ["happy-dom@20.0.10", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-6umCCHcjQrhP5oXhrHQQvLB0bwb1UzHAHdsXy+FjtKoYjUhmNZsQL8NivwM1vDvNEChJabVrUYxUnp/ZdYmy2g=="],

"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],

"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
Expand Down Expand Up @@ -2991,6 +2997,8 @@

"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],

"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],

"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],

"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
Expand Down Expand Up @@ -3241,6 +3249,8 @@

"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

"happy-dom/@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],

"hasha/type-fest": ["type-fest@0.8.1", "", {}, "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA=="],

"hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
Expand Down Expand Up @@ -3513,6 +3523,8 @@

"global-prefix/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],

"happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],

"jest-circus/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],

"jest-cli/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
"eslint-plugin-storybook": "10.0.0",
"eslint-plugin-tailwindcss": "4.0.0-beta.0",
"geist": "^1.5.1",
"happy-dom": "^20.0.10",
"jest": "^30.1.3",
"mermaid": "^11.12.0",
"nodemon": "^3.1.10",
Expand Down
172 changes: 40 additions & 132 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useEffect, useCallback, useRef } from "react";
import "./styles/globals.css";
import { useApp } from "./contexts/AppContext";
import { useProjectContext } from "./contexts/ProjectContext";
import { useSortedWorkspacesByProject } from "./hooks/useSortedWorkspacesByProject";
import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "./types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
import { ProjectCreateModal } from "./components/ProjectCreateModal";
import { AIView } from "./components/AIView";
Expand All @@ -12,11 +13,10 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { useResumeManager } from "./hooks/useResumeManager";
import { useUnreadTracking } from "./hooks/useUnreadTracking";
import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore";
import { useWorkspaceStoreRaw } from "./stores/WorkspaceStore";
import { ChatInput } from "./components/ChatInput/index";
import type { ChatInputAPI } from "./components/ChatInput/types";

import { useStableReference, compareMaps } from "./hooks/useStableReference";
import { CommandRegistryProvider, useCommandRegistry } from "./contexts/CommandRegistryContext";
import type { CommandAction } from "./contexts/CommandRegistryContext";
import { ModeProvider } from "./contexts/ModeContext";
Expand All @@ -28,7 +28,6 @@ import type { ThinkingLevel } from "./types/thinking";
import { CUSTOM_EVENTS } from "./constants/events";
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
import { getThinkingLevelKey } from "./constants/storage";
import type { BranchListResult } from "./types/ipc";
import { useTelemetry } from "./hooks/useTelemetry";
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";

Expand All @@ -37,20 +36,25 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
function AppInner() {
// Get app-level state from context
const {
projects,
addProject,
removeProject,
workspaceMetadata,
setWorkspaceMetadata,
removeWorkspace,
renameWorkspace,
selectedWorkspace,
setSelectedWorkspace,
} = useApp();
const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false);

// Track when we're in "new workspace creation" mode (show FirstMessageInput)
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);
const {
projects,
addProject,
removeProject: removeProjectFromContext,
isProjectCreateModalOpen,
openProjectCreateModal,
closeProjectCreateModal,
pendingNewWorkspaceProject,
beginWorkspaceCreation,
clearPendingWorkspaceCreation,
getBranchesForProject,
} = useProjectContext();

// Auto-collapse sidebar on mobile by default
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
Expand All @@ -67,7 +71,13 @@ function AppInner() {

const startWorkspaceCreation = useStartWorkspaceCreation({
projects,
setPendingNewWorkspaceProject,
setPendingNewWorkspaceProject: (projectPath: string | null) => {
if (projectPath) {
beginWorkspaceCreation(projectPath);
} else {
clearPendingWorkspaceCreation();
}
},
setSelectedWorkspace,
});

Expand Down Expand Up @@ -179,96 +189,22 @@ function AppInner() {
if (selectedWorkspace?.projectPath === path) {
setSelectedWorkspace(null);
}
await removeProject(path);
},
[removeProject, selectedWorkspace, setSelectedWorkspace]
);

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

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

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

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

const handleGetSecrets = useCallback(async (projectPath: string) => {
return await window.api.projects.secrets.get(projectPath);
}, []);

const handleUpdateSecrets = useCallback(
async (projectPath: string, secrets: Array<{ key: string; value: string }>) => {
const result = await window.api.projects.secrets.update(projectPath, secrets);
if (!result.success) {
console.error("Failed to update secrets:", result.error);
if (pendingNewWorkspaceProject === path) {
clearPendingWorkspaceCreation();
}
await removeProjectFromContext(path);
},
[]
[
clearPendingWorkspaceCreation,
pendingNewWorkspaceProject,
removeProjectFromContext,
selectedWorkspace,
setSelectedWorkspace,
]
);

// NEW: Get workspace recency from store
const workspaceRecency = useWorkspaceRecency();

// Sort workspaces by recency (most recent first)
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
const sortedWorkspacesByProject = useStableReference(
() => {
const result = new Map<string, FrontendWorkspaceMetadata[]>();
for (const [projectPath, config] of projects) {
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
const metadataList = config.workspaces
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);

// Sort by recency
metadataList.sort((a, b) => {
const aTimestamp = workspaceRecency[a.id] ?? 0;
const bTimestamp = workspaceRecency[b.id] ?? 0;
return bTimestamp - aTimestamp;
});

result.set(projectPath, metadataList);
}
return result;
},
(prev, next) => {
// Compare Maps: check if size, workspace order, and metadata content are the same
if (
!compareMaps(prev, next, (a, b) => {
if (a.length !== b.length) return false;
// Check both ID and name to detect renames
return a.every((metadata, i) => {
const bMeta = b[i];
if (!bMeta || !metadata) return false; // Null-safe
return metadata.id === bMeta.id && metadata.name === bMeta.name;
});
})
) {
return false;
}
return true;
},
[projects, workspaceMetadata, workspaceRecency]
);
const sortedWorkspacesByProject = useSortedWorkspacesByProject();

const handleNavigateWorkspace = useCallback(
(direction: "next" | "prev") => {
Expand Down Expand Up @@ -367,27 +303,6 @@ function AppInner() {
[startWorkspaceCreation]
);

const getBranchesForProject = useCallback(
async (projectPath: string): Promise<BranchListResult> => {
const branchResult = await window.api.projects.listBranches(projectPath);
const sanitizedBranches = Array.isArray(branchResult?.branches)
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
: [];

const recommended =
typeof branchResult?.recommendedTrunk === "string" &&
sanitizedBranches.includes(branchResult.recommendedTrunk)
? branchResult.recommendedTrunk
: (sanitizedBranches[0] ?? "");

return {
branches: sanitizedBranches,
recommendedTrunk: recommended,
};
},
[]
);

const selectWorkspaceFromPalette = useCallback(
(selection: WorkspaceSelection) => {
handleWorkspaceSwitch(selection);
Expand All @@ -406,8 +321,8 @@ function AppInner() {
);

const addProjectFromPalette = useCallback(() => {
setProjectCreateModalOpen(true);
}, []);
openProjectCreateModal();
}, [openProjectCreateModal]);

const removeProjectFromPalette = useCallback(
(path: string) => {
Expand Down Expand Up @@ -553,17 +468,10 @@ function AppInner() {
<div className="bg-bg-dark mobile-layout flex h-screen overflow-hidden">
<LeftSidebar
onSelectWorkspace={handleWorkspaceSwitch}
onAddProject={handleAddProjectCallback}
onAddWorkspace={handleAddWorkspaceCallback}
onRemoveProject={handleRemoveProjectCallback}
lastReadTimestamps={lastReadTimestamps}
onToggleUnread={onToggleUnread}
collapsed={sidebarCollapsed}
onToggleCollapsed={handleToggleSidebar}
onGetSecrets={handleGetSecrets}
onUpdateSecrets={handleUpdateSecrets}
sortedWorkspacesByProject={sortedWorkspacesByProject}
workspaceRecency={workspaceRecency}
/>
<div className="mobile-main-content flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="mobile-layout flex flex-1 overflow-hidden">
Expand Down Expand Up @@ -614,13 +522,13 @@ function AppInner() {
telemetry.workspaceCreated(metadata.id);

// Clear pending state
setPendingNewWorkspaceProject(null);
clearPendingWorkspaceCreation();
}}
onCancel={
pendingNewWorkspaceProject
? () => {
// User cancelled workspace creation - clear pending state
setPendingNewWorkspaceProject(null);
clearPendingWorkspaceCreation();
}
: undefined
}
Expand Down Expand Up @@ -652,8 +560,8 @@ function AppInner() {
})}
/>
<ProjectCreateModal
isOpen={projectCreateModalOpen}
onClose={() => setProjectCreateModalOpen(false)}
isOpen={isProjectCreateModalOpen}
onClose={closeProjectCreateModal}
onSuccess={addProject}
/>
</div>
Expand Down
19 changes: 11 additions & 8 deletions src/components/AppLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useState, useEffect } from "react";
import App from "../App";
import { LoadingScreen } from "./LoadingScreen";
import { useProjectManagement } from "../hooks/useProjectManagement";
import { useWorkspaceManagement } from "../hooks/useWorkspaceManagement";
import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore";
import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
import { usePersistedState } from "../hooks/usePersistedState";
import type { WorkspaceSelection } from "./ProjectSidebar";
import { AppProvider } from "../contexts/AppContext";
import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext";

/**
* AppLoader handles all initialization before rendering the main App:
Expand All @@ -20,20 +20,27 @@ import { AppProvider } from "../contexts/AppContext";
* the need for conditional guards in effects.
*/
export function AppLoader() {
return (
<ProjectProvider>
<AppLoaderInner />
</ProjectProvider>
);
}

function AppLoaderInner() {
// Workspace selection - restored from localStorage immediately
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
"selectedWorkspace",
null
);

// Load projects
const projectManagement = useProjectManagement();
const { refreshProjects } = useProjectContext();

// Load workspace metadata
// Pass empty callbacks for now - App will provide the actual handlers
const workspaceManagement = useWorkspaceManagement({
selectedWorkspace,
onProjectsUpdate: projectManagement.setProjects,
onProjectsRefresh: refreshProjects,
onSelectedWorkspaceUpdate: setSelectedWorkspace,
});

Expand Down Expand Up @@ -149,10 +156,6 @@ export function AppLoader() {
// Render App with all initialized data via context
return (
<AppProvider
projects={projectManagement.projects}
setProjects={projectManagement.setProjects}
addProject={projectManagement.addProject}
removeProject={projectManagement.removeProject}
workspaceMetadata={workspaceManagement.workspaceMetadata}
setWorkspaceMetadata={workspaceManagement.setWorkspaceMetadata}
createWorkspace={workspaceManagement.createWorkspace}
Expand Down
Loading