Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
153ed5c
🤖 refactor: split workspaces into WorkspaceContext with tests
kylecarbs Nov 14, 2025
d99f9de
🤖 fix: resolve eslint warnings in WorkspaceContext
kylecarbs Nov 14, 2025
9b1649e
🤖 fix: expose setWorkspaceMetadata from WorkspaceContext
kylecarbs Nov 14, 2025
fd95a2e
🤖 fmt: reformat dependency arrays per prettier
kylecarbs Nov 14, 2025
804a785
🤖 refactor: eliminate workspace prop drilling via WorkspaceContext
kylecarbs Nov 15, 2025
587f139
🤖 fix: update after rebase on main with ProjectContext
kylecarbs Nov 15, 2025
0105d2f
🤖 refactor: eliminate project prop drilling via ProjectContext
kylecarbs Nov 15, 2025
c9ab993
🤖 fix: clean up unused imports and callbacks after prop drilling removal
kylecarbs Nov 15, 2025
d9c313c
🤖 fix: add missing ProjectContext imports after refactoring
kylecarbs Nov 15, 2025
5948822
🤖 refactor: remove unused useWorkspaceManagement hook
kylecarbs Nov 15, 2025
1cf729e
🤖 refactor: move selectedWorkspace into WorkspaceContext
kylecarbs Nov 15, 2025
ee0093f
🤖 fix: remove duplicate import after rebase
kylecarbs Nov 15, 2025
56aa7d3
🤖 fix: remove unused imports and run prettier
kylecarbs Nov 15, 2025
115f7bb
🤖 fix: restore AppLoaderMiddle to fix Storybook tests
kylecarbs Nov 15, 2025
412b896
🤖 refactor: WorkspaceProvider calls useProjectContext internally
kylecarbs Nov 15, 2025
9b1f02f
🤖 fix: remove dynamic imports and fix linting issues
kylecarbs Nov 15, 2025
1be580d
🤖 refactor: eliminate AppContext entirely
kylecarbs Nov 15, 2025
8e3e9fa
🤖 refactor: unify test mock API setup in WorkspaceContext.test.tsx
kylecarbs Nov 15, 2025
03992aa
🤖 style: apply prettier formatting to WorkspaceContext.test.tsx
kylecarbs Nov 15, 2025
b7e2938
fix: add type cast for happy-dom document in tests
kylecarbs Nov 15, 2025
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
155 changes: 101 additions & 54 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import "./styles/globals.css";
import { useApp } from "./contexts/AppContext";
import { useWorkspaceContext } from "./contexts/WorkspaceContext";
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 @@ -13,10 +13,11 @@ import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
import { useResumeManager } from "./hooks/useResumeManager";
import { useUnreadTracking } from "./hooks/useUnreadTracking";
import { useAutoCompactContinue } from "./hooks/useAutoCompactContinue";
import { useWorkspaceStoreRaw } from "./stores/WorkspaceStore";
import { useWorkspaceStoreRaw, useWorkspaceRecency } 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,34 +29,34 @@ 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";

const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];

function AppInner() {
// Get app-level state from context
// Get workspace state from context
const {
workspaceMetadata,
setWorkspaceMetadata,
removeWorkspace,
renameWorkspace,
selectedWorkspace,
setSelectedWorkspace,
} = useApp();
} = useWorkspaceContext();
const {
projects,
addProject,
removeProject: removeProjectFromContext,
isProjectCreateModalOpen,
removeProject,
openProjectCreateModal,
isProjectCreateModalOpen,
closeProjectCreateModal,
pendingNewWorkspaceProject,
beginWorkspaceCreation,
clearPendingWorkspaceCreation,
getBranchesForProject,
addProject,
} = useProjectContext();

// Track when we're in "new workspace creation" mode (show FirstMessageInput)
const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState<string | null>(null);

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

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

Expand All @@ -97,22 +92,15 @@ function AppInner() {
// Get workspace store for command palette
const workspaceStore = useWorkspaceStoreRaw();

// Wrapper for setSelectedWorkspace that tracks telemetry
const handleWorkspaceSwitch = useCallback(
(newWorkspace: WorkspaceSelection | null) => {
// Track workspace switch when both old and new are non-null (actual switch, not init/clear)
if (
selectedWorkspace &&
newWorkspace &&
selectedWorkspace.workspaceId !== newWorkspace.workspaceId
) {
telemetry.workspaceSwitched(selectedWorkspace.workspaceId, newWorkspace.workspaceId);
}

setSelectedWorkspace(newWorkspace);
},
[selectedWorkspace, setSelectedWorkspace, telemetry]
);
// Track telemetry when workspace selection changes
const prevWorkspaceRef = useRef<WorkspaceSelection | null>(null);
useEffect(() => {
const prev = prevWorkspaceRef.current;
if (prev && selectedWorkspace && prev.workspaceId !== selectedWorkspace.workspaceId) {
telemetry.workspaceSwitched(prev.workspaceId, selectedWorkspace.workspaceId);
}
prevWorkspaceRef.current = selectedWorkspace;
}, [selectedWorkspace, telemetry]);

// Validate selectedWorkspace when metadata changes
// Clear selection if workspace was deleted
Expand Down Expand Up @@ -189,22 +177,59 @@ function AppInner() {
if (selectedWorkspace?.projectPath === path) {
setSelectedWorkspace(null);
}
if (pendingNewWorkspaceProject === path) {
clearPendingWorkspaceCreation();
}
await removeProjectFromContext(path);
await removeProject(path);
},
[
clearPendingWorkspaceCreation,
pendingNewWorkspaceProject,
removeProjectFromContext,
selectedWorkspace,
setSelectedWorkspace,
]
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedWorkspace, setSelectedWorkspace]
);

// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders

// NEW: Get workspace recency from store
const sortedWorkspacesByProject = useSortedWorkspacesByProject();
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 handleNavigateWorkspace = useCallback(
(direction: "next" | "prev") => {
Expand Down Expand Up @@ -303,11 +328,32 @@ 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);
setSelectedWorkspace(selection);
},
[handleWorkspaceSwitch]
[setSelectedWorkspace]
);

const removeWorkspaceFromPalette = useCallback(
Expand Down Expand Up @@ -467,11 +513,12 @@ function AppInner() {
<>
<div className="bg-bg-dark mobile-layout flex h-screen overflow-hidden">
<LeftSidebar
onSelectWorkspace={handleWorkspaceSwitch}
lastReadTimestamps={lastReadTimestamps}
onToggleUnread={onToggleUnread}
collapsed={sidebarCollapsed}
onToggleCollapsed={handleToggleSidebar}
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 @@ -511,7 +558,7 @@ function AppInner() {
setWorkspaceMetadata((prev) => new Map(prev).set(metadata.id, metadata));

// Switch to new workspace
handleWorkspaceSwitch({
setSelectedWorkspace({
workspaceId: metadata.id,
projectPath: metadata.projectPath,
projectName: metadata.projectName,
Expand All @@ -522,13 +569,13 @@ function AppInner() {
telemetry.workspaceCreated(metadata.id);

// Clear pending state
clearPendingWorkspaceCreation();
setPendingNewWorkspaceProject(null);
}}
onCancel={
pendingNewWorkspaceProject
? () => {
// User cancelled workspace creation - clear pending state
clearPendingWorkspaceCreation();
setPendingNewWorkspaceProject(null);
}
: undefined
}
Expand Down
Loading