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
14 changes: 12 additions & 2 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,9 @@ function AppInner() {
currentMetadata?.name ??
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
selectedWorkspace.workspaceId;
// Use live metadata path (updates on rename) with fallback to initial path
const workspacePath =
currentMetadata?.namedWorkspacePath ?? selectedWorkspace.namedWorkspacePath ?? "";
return (
<ErrorBoundary
workspaceInfo={`${selectedWorkspace.projectName}/${workspaceName}`}
Expand All @@ -586,9 +589,10 @@ function AppInner() {
projectPath={selectedWorkspace.projectPath}
projectName={selectedWorkspace.projectName}
branch={workspaceName}
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
namedWorkspacePath={workspacePath}
runtimeConfig={currentMetadata?.runtimeConfig}
incompatibleRuntime={currentMetadata?.incompatibleRuntime}
status={currentMetadata?.status}
/>
</ErrorBoundary>
);
Expand All @@ -609,7 +613,13 @@ function AppInner() {
onProviderConfig={handleProviderConfig}
onReady={handleCreationChatReady}
onWorkspaceCreated={(metadata) => {
// Add to workspace metadata map
// IMPORTANT: Add workspace to store FIRST (synchronous) to ensure
// the store knows about it before React processes the state updates.
// This prevents race conditions where the UI tries to access the
// workspace before the store has created its aggregator.
workspaceStore.addWorkspace(metadata);

// Add to workspace metadata map (triggers React state update)
setWorkspaceMetadata((prev) =>
new Map(prev).set(metadata.id, metadata)
);
Expand Down
4 changes: 4 additions & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ interface AIViewProps {
className?: string;
/** If set, workspace is incompatible (from newer mux version) and this error should be displayed */
incompatibleRuntime?: string;
/** If 'creating', workspace is still being set up (git operations in progress) */
status?: "creating";
}

const AIViewInner: React.FC<AIViewProps> = ({
Expand All @@ -65,6 +67,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
namedWorkspacePath,
runtimeConfig,
className,
status,
}) => {
const { api } = useAPI();
const chatAreaRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -637,6 +640,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
onStartResize={isReviewTabActive ? startResize : undefined} // Pass resize handler when Review active
isResizing={isResizing} // Pass resizing state
onReviewNote={handleReviewNote} // Pass review note handler to append to chat
isCreating={status === "creating"} // Workspace still being set up
/>
</div>
);
Expand Down
8 changes: 8 additions & 0 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import {
getInputKey,
getModelKey,
getModeKey,
getPendingScopeId,
getProjectScopeId,
Expand All @@ -27,6 +28,13 @@ interface UseCreationWorkspaceOptions {
function syncCreationPreferences(projectPath: string, workspaceId: string): void {
const projectScopeId = getProjectScopeId(projectPath);

// Sync model from project scope to workspace scope
// This ensures the model used for creation is persisted for future resumes
const projectModel = readPersistedState<string | null>(getModelKey(projectScopeId), null);
if (projectModel) {
updatePersistedState(getModelKey(workspaceId), projectModel);
}

const projectMode = readPersistedState<UIMode | null>(getModeKey(projectScopeId), null);
if (projectMode) {
updatePersistedState(getModeKey(workspaceId), projectMode);
Expand Down
4 changes: 4 additions & 0 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ interface RightSidebarProps {
isResizing?: boolean;
/** Callback when user adds a review note from Code Review tab */
onReviewNote?: (note: string) => void;
/** Workspace is still being created (git operations in progress) */
isCreating?: boolean;
}

const RightSidebarComponent: React.FC<RightSidebarProps> = ({
Expand All @@ -95,6 +97,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
onStartResize,
isResizing = false,
onReviewNote,
isCreating = false,
}) => {
// Global tab preference (not per-workspace)
const [selectedTab, setSelectedTab] = usePersistedState<TabType>("right-sidebar-tab", "costs");
Expand Down Expand Up @@ -298,6 +301,7 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
workspacePath={workspacePath}
onReviewNote={onReviewNote}
focusTrigger={focusTrigger}
isCreating={isCreating}
/>
</div>
)}
Expand Down
22 changes: 20 additions & 2 deletions src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ interface ReviewPanelProps {
onReviewNote?: (note: string) => void;
/** Trigger to focus panel (increment to trigger) */
focusTrigger?: number;
/** Workspace is still being created (git operations in progress) */
isCreating?: boolean;
}

interface ReviewSearchState {
Expand Down Expand Up @@ -120,6 +122,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
workspacePath,
onReviewNote,
focusTrigger,
isCreating = false,
}) => {
const { api } = useAPI();
const panelRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -191,7 +194,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({

// Load file tree - when workspace, diffBase, or refreshTrigger changes
useEffect(() => {
if (!api) return;
// Skip data loading while workspace is being created
if (!api || isCreating) return;
let cancelled = false;

const loadFileTree = async () => {
Expand Down Expand Up @@ -239,11 +243,13 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
filters.diffBase,
filters.includeUncommitted,
refreshTrigger,
isCreating,
]);

// Load diff hunks - when workspace, diffBase, selected path, or refreshTrigger changes
useEffect(() => {
if (!api) return;
// Skip data loading while workspace is being created
if (!api || isCreating) return;
let cancelled = false;

const loadDiff = async () => {
Expand Down Expand Up @@ -333,6 +339,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
filters.includeUncommitted,
selectedFilePath,
refreshTrigger,
isCreating,
]);

// Persist diffBase when it changes
Expand Down Expand Up @@ -618,6 +625,17 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);

// Show loading state while workspace is being created
if (isCreating) {
return (
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
<div className="mb-4 text-2xl">⏳</div>
<p className="text-secondary text-sm">Setting up workspace...</p>
<p className="text-secondary mt-1 text-xs">Review will be available once ready</p>
</div>
);
}

return (
<div
ref={panelRef}
Expand Down
22 changes: 22 additions & 0 deletions src/browser/utils/messages/StreamingMessageAggregator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,28 @@ export class StreamingMessageAggregator {
// Clean up stream-scoped state (active stream tracking, TODOs)
this.cleanupStreamState(data.messageId);
this.invalidateCache();
} else {
// Pre-stream error (e.g., API key not configured before streaming starts)
// Create a synthetic error message since there's no active stream to attach to
// Get the highest historySequence from existing messages so this appears at the end
const maxSequence = Math.max(
0,
...Array.from(this.messages.values()).map((m) => m.metadata?.historySequence ?? 0)
);
const errorMessage: MuxMessage = {
id: data.messageId,
role: "assistant",
parts: [],
metadata: {
partial: true,
error: data.error,
errorType: data.errorType,
timestamp: Date.now(),
historySequence: maxSequence + 1,
},
};
this.messages.set(data.messageId, errorMessage);
this.invalidateCache();
}
}

Expand Down
21 changes: 16 additions & 5 deletions src/node/runtime/WorktreeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,25 @@ export class WorktreeRuntime extends LocalBaseRuntime {
const newPath = this.getWorkspacePath(projectPath, newName);

try {
// Use git worktree move to rename the worktree directory
// This updates git's internal worktree metadata correctly
using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
await proc.result;
// Move the worktree directory (updates git's internal worktree metadata)
using moveProc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`);
await moveProc.result;

// Rename the git branch to match the new workspace name
// In mux, branch name and workspace name are always kept in sync.
// Run from the new worktree path since that's where the branch is checked out.
// Best-effort: ignore errors (e.g., branch might have a different name in test scenarios).
try {
using branchProc = execAsync(`git -C "${newPath}" branch -m "${oldName}" "${newName}"`);
await branchProc.result;
} catch {
// Branch rename failed - this is fine, the directory was still moved
// This can happen if the branch name doesn't match the old directory name
}

return { success: true, oldPath, newPath };
} catch (error) {
return { success: false, error: `Failed to move worktree: ${getErrorMessage(error)}` };
return { success: false, error: `Failed to rename workspace: ${getErrorMessage(error)}` };
}
}

Expand Down
69 changes: 69 additions & 0 deletions src/node/services/utils/sendMessageError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
import { formatSendMessageError, createUnknownSendMessageError } from "./sendMessageError";

describe("formatSendMessageError", () => {
test("formats api_key_not_found with authentication errorType", () => {
const result = formatSendMessageError({
type: "api_key_not_found",
provider: "anthropic",
});

expect(result.errorType).toBe("authentication");
expect(result.message).toContain("anthropic");
expect(result.message).toContain("API key");
});

test("formats provider_not_supported", () => {
const result = formatSendMessageError({
type: "provider_not_supported",
provider: "unsupported-provider",
});

expect(result.errorType).toBe("unknown");
expect(result.message).toContain("unsupported-provider");
expect(result.message).toContain("not supported");
});

test("formats invalid_model_string with model_not_found errorType", () => {
const result = formatSendMessageError({
type: "invalid_model_string",
message: "Invalid model format: foo",
});

expect(result.errorType).toBe("model_not_found");
expect(result.message).toBe("Invalid model format: foo");
});

test("formats incompatible_workspace", () => {
const result = formatSendMessageError({
type: "incompatible_workspace",
message: "Workspace is incompatible",
});

expect(result.errorType).toBe("unknown");
expect(result.message).toBe("Workspace is incompatible");
});

test("formats unknown errors", () => {
const result = formatSendMessageError({
type: "unknown",
raw: "Something went wrong",
});

expect(result.errorType).toBe("unknown");
expect(result.message).toBe("Something went wrong");
});
});

describe("createUnknownSendMessageError", () => {
test("creates unknown error with trimmed message", () => {
const result = createUnknownSendMessageError(" test error ");

expect(result).toEqual({ type: "unknown", raw: "test error" });
});

test("throws on empty message", () => {
expect(() => createUnknownSendMessageError("")).toThrow();
expect(() => createUnknownSendMessageError(" ")).toThrow();
});
});
38 changes: 37 additions & 1 deletion src/node/services/utils/sendMessageError.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "@/common/utils/assert";
import type { SendMessageError } from "@/common/types/errors";
import type { SendMessageError, StreamErrorType } from "@/common/types/errors";

/**
* Helper to wrap arbitrary errors into SendMessageError structures.
Expand All @@ -15,3 +15,39 @@ export const createUnknownSendMessageError = (raw: string): SendMessageError =>
raw: trimmed,
};
};

/**
* Formats a SendMessageError into a user-visible message and StreamErrorType
* for display in the chat UI as a stream-error event.
*/
export const formatSendMessageError = (
error: SendMessageError
): { message: string; errorType: StreamErrorType } => {
switch (error.type) {
case "api_key_not_found":
return {
message: `API key not configured for ${error.provider}. Please add your API key in settings.`,
errorType: "authentication",
};
case "provider_not_supported":
return {
message: `Provider "${error.provider}" is not supported.`,
errorType: "unknown",
};
case "invalid_model_string":
return {
message: error.message,
errorType: "model_not_found",
};
case "incompatible_workspace":
return {
message: error.message,
errorType: "unknown",
};
case "unknown":
return {
message: error.raw,
errorType: "unknown",
};
}
};
Loading