Skip to content

Commit e5972fb

Browse files
committed
🤖 Fix workspace path resolution for legacy workspaces
Use config.findWorkspace() to get actual workspace path instead of computing it. This fixes ENOENT errors for legacy workspaces where the directory name doesn't match the computed path. **Root Cause:** - Legacy workspaces: ID is 'cmux-compact-flag-bug', dir is 'compact-flag-bug' - getWorkspacePath(projectPath, id) computed: src/cmux/cmux-compact-flag-bug ❌ - Actual path in config: src/cmux/compact-flag-bug ✅ **Solution:** - Use findWorkspace() which returns path directly from config - Config stores actual filesystem paths (source of truth) - Works for both legacy and new workspace formats **Changes:** - WORKSPACE_EXECUTE_BASH: Use findWorkspace() instead of getWorkspacePath() - Removed debug logging from getWorkspaceMetadata and ipcMain handler - Removed projectPath validation (no longer needed since using config path) Fixes git status ENOENT errors. 🤖 Fix all workspace path resolution + reduce git status error spam **Fixes path resolution everywhere:** - aiService.sendMessage: Use findWorkspace() for AI tool operations - ipcMain.removeWorkspaceInternal: Use findWorkspace() for deletion - ipcMain.enrichMetadataWithPaths: Use findWorkspace() for frontend paths **Reduces console spam:** - GitStatusStore: Only log OUTPUT TRUNCATED/OVERFLOW as debug level - Common in large repos, not an actionable error **Root cause:** getWorkspacePath() computes paths that don't match legacy workspace directories: - Computed: ~/.cmux/src/cmux/cmux-compact-flag-bug - Actual: ~/.cmux/src/cmux/compact-flag-bug **Solution:** Always use findWorkspace() to get actual paths from config (source of truth). Only use getWorkspacePath() for NEW workspace creation. **Changes:** - 3 more call sites fixed to use findWorkspace() - Added warning comment to getWorkspacePath() - GitStatusStore filters out truncation spam 🤖 Replace ambiguous workspacePath with explicit namedWorkspacePath Replaced all uses of workspacePath with namedWorkspacePath throughout the codebase to be explicit that we're using the user-friendly path (symlink for new workspaces). **Benefits:** - Clear distinction between stableWorkspacePath (for operations) and namedWorkspacePath (for display) - No more ambiguity about which path is being used - Window title now shows workspace name instead of ID - Terminal opens to named path (user-friendly) - Rename updates UI immediately with enriched metadata **Files changed:** - WorkspaceSelection interface: workspacePath → namedWorkspacePath - AIView props: workspacePath → namedWorkspacePath - App.tsx: Window title uses workspace name, all selections use namedWorkspacePath - All command palette sources updated - All hooks updated This anti-pattern cleanup ensures clarity everywhere paths are used. 🤖 Add safety checks for undefined namedWorkspacePath Handle case where selectedWorkspace has old format without namedWorkspacePath (from localStorage before rebuild). Uses optional chaining and fallback to workspaceId. Fixes: Cannot read properties of undefined (reading 'split') 🤖 Subscribe to workspace metadata updates in useWorkspaceManagement Fixed rename not updating UI immediately by subscribing to WORKSPACE_METADATA IPC events. Previously only reloaded metadata after explicit operations. **Root cause:** - workspaceMetadata was only loaded on mount and after create/delete/rename - But rename emits WORKSPACE_METADATA event that wasn't being listened to - So metadata map in App.tsx was stale until manual reload **Solution:** - Subscribe to workspace.subscribeToMetadata() in useEffect - Update workspaceMetadata map when event received - Unsubscribe on cleanup **Result:** - Rename updates UI immediately (sidebar, window title, paths) - No manual refresh needed 🤖 Fix type annotation for metadata event 🤖 Fix: Use onMetadata not subscribeToMetadata 🤖 Add debug logging for workspace metadata loading 🤖 Fix: onMetadata already sends all initial state, remove duplicate load onMetadata is designed to send all current metadata immediately upon subscription, so we don't need the manual loadWorkspaceMetadata() call. The duplicate load was causing a race condition where one could overwrite the other. Removed loadWorkspaceMetadata() from useEffect and rely solely on onMetadata subscription for both initial state and updates. Fix: Use workspace ID for metadata lookup, not path The sortedWorkspacesByProject was building a path-based lookup map, but workspaceMetadata is keyed by workspace ID. This caused all workspaces to be filtered out when building the sorted list. Now we directly look up by ws.id from the config. Remove debug logging from useWorkspaceManagement Fix: Detect workspace renames in sortedWorkspacesByProject The comparison function only checked workspace IDs, so when a workspace was renamed (ID stays same, name changes), it didn't detect the change and the UI didn't update. Now checks both id and name to properly detect renames. Batch workspace metadata updates on initial load When subscribing to metadata, backend sends one event per workspace synchronously. This was causing N re-renders for N workspaces. Now we batch these updates using queueMicrotask - all synchronous events are collected and flushed in a single state update after the current task completes. This reduces 19 re-renders to 1 on startup. Fix: Restore workspace from URL hash after metadata loads The restore effect ran on mount before workspaceMetadata was loaded, so it always failed to find the workspace. Now it waits for metadata to be loaded and only restores once. Use named workspace paths for all user-facing operations Agent bash calls and UI bash execute should use the friendly named workspace path (symlink or legacy dir name), not the internal stable ID path. This matches what users see in the UI. Backwards compatible: legacy workspaces have no symlinks, so the named path IS the actual directory path. Fix: Update old localStorage entries with missing namedWorkspacePath Old selectedWorkspace entries from localStorage may be missing the namedWorkspacePath field. The validation effect now detects this and updates the workspace with current metadata, preventing errors in command palette and other components that expect the field. Fix crash on workspace delete and hash restore priority - Add null check in workspace sort comparison to prevent crash when workspace is deleted - Fix hash restore: now takes priority over localStorage by running once on mount - Add guard to prevent duplicate 'Updating workspace' log messages Fix critical bug: findWorkspace now checks config.id first Root cause: findWorkspace() was reading metadata.json files instead of checking workspace.id from config, causing all workspaces to fail enrichment. Changes: - findWorkspace() now checks workspace.id from config first (primary source) - Falls back to metadata.json only for unmigrated legacy workspaces - Remove metadata.json writes from workspace create/rename (config is source of truth) - Keep metadata.json read for backward compat during migration This fixes: - "Workspace no longer exists" errors - Title showing ID instead of name - Terminal opening in project path instead of workspace path All caused by enrichMetadataWithPaths() failing when findWorkspace() returned null. Simplify: getAllWorkspaceMetadata returns complete data with paths Architectural simplification to eliminate O(n²) complexity and prevent bugs: BEFORE: - getAllWorkspaceMetadata() returned WorkspaceMetadata (no paths) - Subscription handler sent incomplete data to frontend → UI broke - enrichMetadataWithPaths() had to search config again to find paths - Each caller had to remember to call enrichment → easy to forget AFTER: - getAllWorkspaceMetadata() returns FrontendWorkspaceMetadata with paths - Paths computed once during the initial loop (we already have the data!) - No enrichment step needed - data is always complete - Subscription handler sends complete data → frontend always gets paths Changes: - Added addPathsToMetadata() helper to avoid duplication (DRY) - Updated all 5 path-adding locations to use helper - Removed enrichMetadataWithPaths() (~20 LOC deleted) - Updated all callers to use complete metadata directly Net result: -45 LOC, O(n) instead of O(n²), impossible to forget enrichment Add debug logging for metadata subscription To diagnose why workspaces aren't loading on reload Simplify metadata loading with proper loading state BEFORE: - Subscription sent metadata piece-by-piece on subscribe - Batching logic to avoid re-renders - Race condition: restore effect ran before batch completed - Checked workspaceMetadata.size === 0 which passed, then validation cleared selection AFTER: - Call workspace.list() once on mount to get all metadata - Set loading: true until complete - All effects wait for metadataLoading === false - Subscription only used for updates (create/rename/delete) Eliminates race conditions and simplifies the loading flow. Fix: Use namedWorkspacePath for terminal in command palette Bug: Command palette 'Open Workspace in Terminal' was passing workspace ID instead of path, causing terminal to open in wrong directory. Fix: - Changed field name from 'workspacePath' to 'workspaceId' (matches actual value) - Look up workspace metadata to get namedWorkspacePath - Pass namedWorkspacePath to openTerminal (user-friendly symlink path) Unify terminal opening: always use workspace ID, not paths BEFORE: - AIView: Called openTerminal(namedWorkspacePath) directly - Command palette 'Open Current': Called callback(namedWorkspacePath) - Command palette 'Open Any': Called callback(workspaceId) then looked up path - App.tsx callback: Took workspacePath parameter Multiple code paths, easy to make mistakes with paths. AFTER: - All callers pass workspace ID to openWorkspaceInTerminal() - Single code path in App.tsx looks up metadata and extracts namedWorkspacePath - Consistent: workspace ID is the universal identifier This eliminates the risk of passing wrong paths (stable vs named). 🤖 Fix macOS terminal opening and clean up legacy code - Fix: macOS Ghostty now called directly with --working-directory flag Previously used 'open -a Ghostty /path' which doesn't set cwd - Remove unused legacy functions from gitService.ts: - createWorktree (duplicate, real one in git.ts) - moveWorktree (unused) - listWorktrees (unused) - isGitRepository (unused) - getMainWorktreeFromWorktree (duplicate) - Update gitService.test.ts to import createWorktree from git.ts - Add logging for successful terminal opens Revert terminal opening changes - original code was correct The 'open -a AppName /directory' command DOES set the cwd correctly. My 'fix' broke it by calling ghostty CLI directly which spawns a background process instead of opening a GUI window. 🤖 Use cwd option instead of passing directory as arg to terminal Set cwd in spawn options rather than passing workspacePath as argument. This is cleaner and more consistent with Linux terminal handling. 🤖 Fix macOS terminal opening with proper --args syntax Problem: Terminals weren't opening at all on macOS Root cause: Using cwd option doesn't work with 'open' command Solution: - Ghostty: Use 'open -a Ghostty --args --working-directory=$path' The --args flag passes remaining arguments to the app itself - Terminal.app: Pass directory path directly (original approach works) Verified manually that both approaches open terminal in correct directory. 🤖 macOS: match main for Ghostty terminal opens (avoid regression) Revert Ghostty invocation to exactly mirror main: use open -a Ghostty <workspacePath> No flags or --args. This avoids any behavior change on macOS and fixes the regression where Ghostty windows didn’t appear. Generated with . 🤖 Log full terminal command invocation in backend Add log.info() before spawning terminal processes on all platforms: - macOS: logs 'open -a Ghostty <path>' or 'open -a Terminal <path>' - Windows: logs 'cmd /c start cmd /K cd /D <path>' - Linux: logs '<terminal> <args>' with optional cwd info This helps debug terminal launching issues by showing exactly what command is being executed. Generated with `cmux`.
1 parent 6ff0ab1 commit e5972fb

File tree

11 files changed

+248
-307
lines changed

11 files changed

+248
-307
lines changed

src/App.tsx

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ function AppInner() {
166166
[setProjects]
167167
);
168168

169-
const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } =
169+
const { workspaceMetadata, loading: metadataLoading, createWorkspace, removeWorkspace, renameWorkspace } =
170170
useWorkspaceManagement({
171171
selectedWorkspace,
172172
onProjectsUpdate: handleProjectsUpdate,
@@ -209,8 +209,10 @@ function AppInner() {
209209
window.history.replaceState(null, "", newHash);
210210
}
211211

212-
// Update window title
213-
const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`;
212+
// Update window title with workspace name
213+
const workspaceName =
214+
workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId;
215+
const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`;
214216
void window.api.window.setTitle(title);
215217
} else {
216218
// Clear hash when no workspace selected
@@ -222,48 +224,76 @@ function AppInner() {
222224
}, [selectedWorkspace]);
223225

224226
// Restore workspace from URL on mount (if valid)
227+
// This effect runs once on mount to restore from hash, which takes priority over localStorage
228+
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);
229+
225230
useEffect(() => {
231+
// Only run once
232+
if (hasRestoredFromHash) return;
233+
234+
// Wait for metadata to finish loading
235+
if (metadataLoading) return;
236+
226237
const hash = window.location.hash;
227238
if (hash.startsWith("#workspace=")) {
228239
const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));
229240

230241
// Find workspace in metadata
231-
const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId);
242+
const metadata = workspaceMetadata.get(workspaceId);
232243

233244
if (metadata) {
234-
// Find project for this workspace (metadata now includes projectPath)
245+
// Restore from hash (overrides localStorage)
235246
setSelectedWorkspace({
236247
workspaceId: metadata.id,
237248
projectPath: metadata.projectPath,
238249
projectName: metadata.projectName,
239-
workspacePath: metadata.stableWorkspacePath,
250+
namedWorkspacePath: metadata.namedWorkspacePath,
240251
});
241252
}
242253
}
243-
// Only run on mount
244-
// eslint-disable-next-line react-hooks/exhaustive-deps
245-
}, []);
254+
255+
setHasRestoredFromHash(true);
256+
}, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]);
246257

247-
// Validate selected workspace exists (clear if workspace was deleted)
258+
// Validate selected workspace exists and has all required fields
248259
useEffect(() => {
249-
if (selectedWorkspace && workspaceMetadata.size > 0) {
250-
const exists = workspaceMetadata.has(selectedWorkspace.workspaceId);
251-
if (!exists) {
260+
// Don't validate until metadata is loaded
261+
if (metadataLoading) return;
262+
263+
if (selectedWorkspace) {
264+
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
265+
266+
if (!metadata) {
267+
// Workspace was deleted
252268
console.warn(
253269
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
254270
);
255271
setSelectedWorkspace(null);
256-
// Also clear URL hash if present
257272
if (window.location.hash) {
258273
window.history.replaceState(null, "", window.location.pathname);
259274
}
275+
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
276+
// Old localStorage entry missing namedWorkspacePath - update it once
277+
console.log(
278+
`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`
279+
);
280+
setSelectedWorkspace({
281+
workspaceId: metadata.id,
282+
projectPath: metadata.projectPath,
283+
projectName: metadata.projectName,
284+
namedWorkspacePath: metadata.namedWorkspacePath,
285+
});
260286
}
261287
}
262-
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
288+
}, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
263289

264-
const openWorkspaceInTerminal = useCallback((workspacePath: string) => {
265-
void window.api.workspace.openTerminal(workspacePath);
266-
}, []);
290+
const openWorkspaceInTerminal = useCallback((workspaceId: string) => {
291+
// Look up workspace metadata to get the named path (user-friendly symlink)
292+
const metadata = workspaceMetadata.get(workspaceId);
293+
if (metadata) {
294+
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
295+
}
296+
}, [workspaceMetadata]);
267297

268298
const handleRemoveProject = useCallback(
269299
async (path: string) => {
@@ -335,18 +365,11 @@ function AppInner() {
335365
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
336366
const sortedWorkspacesByProject = useStableReference(
337367
() => {
338-
// Build path-to-metadata lookup map internally
339-
const pathToMetadata = new Map<string, FrontendWorkspaceMetadata>();
340-
for (const metadata of workspaceMetadata.values()) {
341-
pathToMetadata.set(metadata.stableWorkspacePath, metadata);
342-
pathToMetadata.set(metadata.namedWorkspacePath, metadata);
343-
}
344-
345368
const result = new Map<string, FrontendWorkspaceMetadata[]>();
346369
for (const [projectPath, config] of projects) {
347-
// Transform Workspace[] to FrontendWorkspaceMetadata[] and filter nulls
370+
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
348371
const metadataList = config.workspaces
349-
.map((ws) => pathToMetadata.get(ws.path))
372+
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
350373
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined);
351374

352375
// Sort by recency
@@ -361,11 +384,16 @@ function AppInner() {
361384
return result;
362385
},
363386
(prev, next) => {
364-
// Compare Maps: check if both size and workspace order are the same
387+
// Compare Maps: check if size, workspace order, and metadata content are the same
365388
if (
366389
!compareMaps(prev, next, (a, b) => {
367390
if (a.length !== b.length) return false;
368-
return a.every((metadata, i) => metadata.id === b[i].id);
391+
// Check both ID and name to detect renames
392+
return a.every((metadata, i) => {
393+
const bMeta = b[i];
394+
if (!bMeta) return false;
395+
return metadata.id === bMeta.id && metadata.name === bMeta.name;
396+
});
369397
})
370398
) {
371399
return false;
@@ -403,7 +431,7 @@ function AppInner() {
403431
setSelectedWorkspace({
404432
projectPath: selectedWorkspace.projectPath,
405433
projectName: selectedWorkspace.projectName,
406-
workspacePath: targetMetadata.stableWorkspacePath,
434+
namedWorkspacePath: targetMetadata.namedWorkspacePath,
407435
workspaceId: targetMetadata.id,
408436
});
409437
},
@@ -506,12 +534,7 @@ function AppInner() {
506534
);
507535

508536
const selectWorkspaceFromPalette = useCallback(
509-
(selection: {
510-
projectPath: string;
511-
projectName: string;
512-
workspacePath: string;
513-
workspaceId: string;
514-
}) => {
537+
(selection: WorkspaceSelection) => {
515538
setSelectedWorkspace(selection);
516539
},
517540
[setSelectedWorkspace]
@@ -653,14 +676,14 @@ function AppInner() {
653676
<ContentArea>
654677
{selectedWorkspace ? (
655678
<ErrorBoundary
656-
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
679+
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId}`}
657680
>
658681
<AIView
659682
key={selectedWorkspace.workspaceId}
660683
workspaceId={selectedWorkspace.workspaceId}
661684
projectName={selectedWorkspace.projectName}
662-
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
663-
workspacePath={selectedWorkspace.workspacePath}
685+
branch={selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId}
686+
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
664687
/>
665688
</ErrorBoundary>
666689
) : (

src/components/AIView.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,15 @@ interface AIViewProps {
193193
workspaceId: string;
194194
projectName: string;
195195
branch: string;
196-
workspacePath: string;
196+
namedWorkspacePath: string; // User-friendly path for display and terminal
197197
className?: string;
198198
}
199199

200200
const AIViewInner: React.FC<AIViewProps> = ({
201201
workspaceId,
202202
projectName,
203203
branch,
204-
workspacePath,
204+
namedWorkspacePath,
205205
className,
206206
}) => {
207207
const chatAreaRef = useRef<HTMLDivElement>(null);
@@ -311,8 +311,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
311311
);
312312

313313
const handleOpenTerminal = useCallback(() => {
314-
void window.api.workspace.openTerminal(workspacePath);
315-
}, [workspacePath]);
314+
void window.api.workspace.openTerminal(namedWorkspacePath);
315+
}, [namedWorkspacePath]);
316316

317317
// Auto-scroll when messages update (during streaming)
318318
useEffect(() => {
@@ -443,7 +443,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
443443
tooltipPosition="bottom"
444444
/>
445445
{projectName} / {branch}
446-
<WorkspacePath>{workspacePath}</WorkspacePath>
446+
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
447447
<TooltipWrapper inline>
448448
<TerminalIconButton onClick={handleOpenTerminal}>
449449
<svg viewBox="0 0 16 16" fill="currentColor">

src/components/WorkspaceListItem.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ const WorkspaceRemoveBtn = styled(RemoveBtn)`
123123
export interface WorkspaceSelection {
124124
projectPath: string;
125125
projectName: string;
126-
workspacePath: string;
126+
namedWorkspacePath: string; // User-friendly path (symlink for new workspaces)
127127
workspaceId: string;
128128
}
129129
export interface WorkspaceListItemProps {
@@ -150,7 +150,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
150150
onToggleUnread,
151151
}) => {
152152
// Destructure metadata for convenience
153-
const { id: workspaceId, name: workspaceName, stableWorkspacePath: workspacePath } = metadata;
153+
const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata;
154154
// Subscribe to this specific workspace's sidebar state (streaming status, model, recency)
155155
const sidebarState = useWorkspaceSidebarState(workspaceId);
156156
const gitStatus = useGitStatus(workspaceId);
@@ -246,7 +246,7 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
246246
onSelectWorkspace({
247247
projectPath,
248248
projectName,
249-
workspacePath,
249+
namedWorkspacePath,
250250
workspaceId,
251251
})
252252
}
@@ -256,15 +256,15 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
256256
onSelectWorkspace({
257257
projectPath,
258258
projectName,
259-
workspacePath,
259+
namedWorkspacePath,
260260
workspaceId,
261261
});
262262
}
263263
}}
264264
role="button"
265265
tabIndex={0}
266266
aria-current={isSelected ? "true" : undefined}
267-
data-workspace-path={workspacePath}
267+
data-workspace-path={namedWorkspacePath}
268268
data-workspace-id={workspaceId}
269269
>
270270
<TooltipWrapper inline>

0 commit comments

Comments
 (0)