Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for

- When refactoring, use `git mv` to preserve file history instead of rewriting files from scratch

**⚠️ NEVER kill the running cmux process** - The main cmux instance is used for active development. Use `make test` or `make typecheck` to verify changes instead of starting the app in test worktrees.

## Testing

### Test-Driven Development (TDD)
Expand Down
10 changes: 5 additions & 5 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useRef } from "react";
import App from "./App";
import { AppLoader } from "./components/AppLoader";
import type { ProjectConfig } from "./config";
import type { FrontendWorkspaceMetadata } from "./types/workspace";
import type { IPCApi } from "./types/ipc";
Expand Down Expand Up @@ -94,7 +94,7 @@ function setupMockAPI(options: {

const meta = {
title: "App/Full Application",
component: App,
component: AppLoader,
parameters: {
layout: "fullscreen",
backgrounds: {
Expand All @@ -103,7 +103,7 @@ const meta = {
},
},
tags: ["autodocs"],
} satisfies Meta<typeof App>;
} satisfies Meta<typeof AppLoader>;

export default meta;
type Story = StoryObj<typeof meta>;
Expand All @@ -122,7 +122,7 @@ const AppWithMocks: React.FC<{
initialized.current = true;
}

return <App />;
return <AppLoader />;
};

export const WelcomeScreen: Story = {
Expand Down Expand Up @@ -618,7 +618,7 @@ export const ActiveWorkspaceWithChat: Story = {
initialized.current = true;
}

return <App />;
return <AppLoader />;
};

return <AppWithChatMocks />;
Expand Down
126 changes: 32 additions & 94 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import "./styles/globals.css";
import type { ProjectConfig } from "./config";
import { useApp } from "./contexts/AppContext";
import type { WorkspaceSelection } from "./components/ProjectSidebar";
import type { FrontendWorkspaceMetadata } from "./types/workspace";
import { LeftSidebar } from "./components/LeftSidebar";
Expand All @@ -10,13 +10,10 @@ import { AIView } from "./components/AIView";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { useProjectManagement } from "./hooks/useProjectManagement";
import { useWorkspaceManagement } from "./hooks/useWorkspaceManagement";
import { useResumeManager } from "./hooks/useResumeManager";
import { useUnreadTracking } from "./hooks/useUnreadTracking";
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";
Expand All @@ -34,10 +31,19 @@ import { useTelemetry } from "./hooks/useTelemetry";
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];

function AppInner() {
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
"selectedWorkspace",
null
);
// Get app-level state from context
const {
projects,
addProject,
removeProject,
workspaceMetadata,
setWorkspaceMetadata,
createWorkspace,
removeWorkspace,
renameWorkspace,
selectedWorkspace,
setSelectedWorkspace,
} = useApp();
const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false);
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState<string>("");
Expand All @@ -59,69 +65,33 @@ function AppInner() {
// Telemetry tracking
const telemetry = useTelemetry();

// Get workspace store for command palette
const workspaceStore = useWorkspaceStoreRaw();

// Wrapper for setSelectedWorkspace that tracks telemetry
const handleWorkspaceSwitch = useCallback(
(newWorkspace: WorkspaceSelection | null) => {
console.debug("[App] handleWorkspaceSwitch called", {
from: selectedWorkspace?.workspaceId,
to: newWorkspace?.workspaceId,
});

// Track workspace switch when both old and new are non-null (actual switch, not init/clear)
if (
selectedWorkspace &&
newWorkspace &&
selectedWorkspace.workspaceId !== newWorkspace.workspaceId
) {
console.debug("[App] Calling telemetry.workspaceSwitched");
telemetry.workspaceSwitched(selectedWorkspace.workspaceId, newWorkspace.workspaceId);
}

setSelectedWorkspace(newWorkspace);
},
[selectedWorkspace, setSelectedWorkspace, telemetry]
);

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

// Workspace management needs to update projects state when workspace operations complete
const handleProjectsUpdate = useCallback(
(newProjects: Map<string, ProjectConfig>) => {
setProjects(newProjects);
},
[setProjects]
);

const {
workspaceMetadata,
setWorkspaceMetadata,
loading: metadataLoading,
createWorkspace,
removeWorkspace,
renameWorkspace,
} = useWorkspaceManagement({
selectedWorkspace,
onProjectsUpdate: handleProjectsUpdate,
onSelectedWorkspaceUpdate: setSelectedWorkspace,
});

// NEW: Sync workspace metadata with the stores
const workspaceStore = useWorkspaceStoreRaw();
const gitStatusStore = useGitStatusStoreRaw();

useEffect(() => {
// Only sync when metadata has actually loaded (not empty initial state)
if (workspaceMetadata.size > 0) {
workspaceStore.syncWorkspaces(workspaceMetadata);
}
}, [workspaceMetadata, workspaceStore]);

// Validate selectedWorkspace when metadata changes
// Clear selection if workspace was deleted
useEffect(() => {
// Only sync when metadata has actually loaded (not empty initial state)
if (workspaceMetadata.size > 0) {
gitStatusStore.syncWorkspaces(workspaceMetadata);
if (selectedWorkspace && !workspaceMetadata.has(selectedWorkspace.workspaceId)) {
setSelectedWorkspace(null);
}
}, [workspaceMetadata, gitStatusStore]);
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);

// Track last-read timestamps for unread indicators
const { lastReadTimestamps, onToggleUnread } = useUnreadTracking(selectedWorkspace);
Expand Down Expand Up @@ -155,43 +125,8 @@ function AppInner() {
}
}, [selectedWorkspace, workspaceMetadata]);

// Restore workspace from URL on mount (if valid)
// This effect runs once on mount to restore from hash, which takes priority over localStorage
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);

useEffect(() => {
// Only run once
if (hasRestoredFromHash) return;

// Wait for metadata to finish loading
if (metadataLoading) return;

const hash = window.location.hash;
if (hash.startsWith("#workspace=")) {
const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));

// Find workspace in metadata
const metadata = workspaceMetadata.get(workspaceId);

if (metadata) {
// Restore from hash (overrides localStorage)
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
});
}
}

setHasRestoredFromHash(true);
}, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]);

// Validate selected workspace exists and has all required fields
useEffect(() => {
// Don't validate until metadata is loaded
if (metadataLoading) return;

if (selectedWorkspace) {
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);

Expand All @@ -215,7 +150,7 @@ function AppInner() {
});
}
}
}, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);

const openWorkspaceInTerminal = useCallback(
(workspaceId: string) => {
Expand Down Expand Up @@ -635,6 +570,14 @@ function AppInner() {
return;
}

// DEFENSIVE: Ensure createdAt exists
if (!workspaceInfo.createdAt) {
console.warn(
`[Frontend] Workspace ${workspaceInfo.id} missing createdAt in fork switch - using default (2025-01-01)`
);
workspaceInfo.createdAt = "2025-01-01T00:00:00.000Z";
}

// Update metadata Map immediately (don't wait for async metadata event)
// This ensures the title bar effect has the workspace name available
setWorkspaceMetadata((prev) => {
Expand Down Expand Up @@ -664,15 +607,10 @@ function AppInner() {
<>
<div className="bg-bg-dark flex h-screen overflow-hidden [@media(max-width:768px)]:flex-col">
<LeftSidebar
projects={projects}
workspaceMetadata={workspaceMetadata}
selectedWorkspace={selectedWorkspace}
onSelectWorkspace={handleWorkspaceSwitch}
onAddProject={handleAddProjectCallback}
onAddWorkspace={handleAddWorkspaceCallback}
onRemoveProject={handleRemoveProjectCallback}
onRemoveWorkspace={removeWorkspace}
onRenameWorkspace={renameWorkspace}
lastReadTimestamps={lastReadTimestamps}
onToggleUnread={onToggleUnread}
collapsed={sidebarCollapsed}
Expand Down
122 changes: 122 additions & 0 deletions src/components/AppLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
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";

/**
* AppLoader handles all initialization before rendering the main App:
* 1. Load workspace metadata and projects
* 2. Sync stores with loaded data
* 3. Restore workspace selection from URL hash (if present)
* 4. Only render App when everything is ready
*
* This ensures App.tsx can assume stores are always synced and removes
* the need for conditional guards in effects.
*/
export function AppLoader() {
// Workspace selection - restored from localStorage immediately
const [selectedWorkspace, setSelectedWorkspace] = usePersistedState<WorkspaceSelection | null>(
"selectedWorkspace",
null
);

// Load projects
const projectManagement = useProjectManagement();

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

// Get store instances
const workspaceStore = useWorkspaceStoreRaw();
const gitStatusStore = useGitStatusStoreRaw();

// Track whether stores have been synced
const [storesSynced, setStoresSynced] = useState(false);

// Sync stores when metadata finishes loading
useEffect(() => {
if (!workspaceManagement.loading) {
workspaceStore.syncWorkspaces(workspaceManagement.workspaceMetadata);
gitStatusStore.syncWorkspaces(workspaceManagement.workspaceMetadata);
setStoresSynced(true);
} else {
setStoresSynced(false);
}
}, [
workspaceManagement.loading,
workspaceManagement.workspaceMetadata,
workspaceStore,
gitStatusStore,
]);

// Restore workspace from URL hash (runs once when stores are synced)
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);

useEffect(() => {
// Wait until stores are synced before attempting restoration
if (!storesSynced) return;

// Only run once
if (hasRestoredFromHash) return;

const hash = window.location.hash;
if (hash.startsWith("#workspace=")) {
const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));

// Find workspace in metadata
const metadata = workspaceManagement.workspaceMetadata.get(workspaceId);

if (metadata) {
// Restore from hash (overrides localStorage)
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
namedWorkspacePath: metadata.namedWorkspacePath,
});
}
}

setHasRestoredFromHash(true);
}, [
storesSynced,
workspaceManagement.workspaceMetadata,
hasRestoredFromHash,
setSelectedWorkspace,
]);

// Show loading screen until stores are synced
if (workspaceManagement.loading || !storesSynced) {
return <LoadingScreen />;
}

// 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}
removeWorkspace={workspaceManagement.removeWorkspace}
renameWorkspace={workspaceManagement.renameWorkspace}
selectedWorkspace={selectedWorkspace}
setSelectedWorkspace={setSelectedWorkspace}
>
<App />
</AppProvider>
);
}
Loading