Skip to content
Open
20 changes: 13 additions & 7 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,8 +459,13 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
if (variant === "creation") {
// Creation variant: simple message send + workspace creation
setIsSending(true);
setInput(""); // Clear input immediately (will be restored by parent if creation fails)
await creationState.handleSend(messageText);
const ok = await creationState.handleSend(messageText);
if (ok) {
setInput("");
if (inputRef.current) {
inputRef.current.style.height = "36px";
}
}
setIsSending(false);
return;
}
Expand Down Expand Up @@ -887,11 +892,12 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
data-component="ChatInputSection"
>
<div className="mx-auto w-full max-w-4xl">
{/* Creation error toast */}
{variant === "creation" && creationState?.error && (
<div className="mb-2 rounded border border-red-700 bg-red-900/20 px-3 py-2 text-sm text-red-400">
{creationState.error}
</div>
{/* Creation toast */}
{variant === "creation" && (
<ChatInputToast
toast={creationState.toast}
onDismiss={() => creationState.setToast(null)}
/>
)}

{/* Workspace toast */}
Expand Down
41 changes: 27 additions & 14 deletions src/browser/components/ChatInput/useCreationWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSett
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
import { getModeKey, getProjectScopeId, getThinkingLevelKey } from "@/common/constants/storage";
import { extractErrorMessage } from "./utils";
import type { Toast } from "@/browser/components/ChatInputToast";
import { createErrorToast } from "@/browser/components/ChatInputToasts";

interface UseCreationWorkspaceOptions {
projectPath: string;
Expand Down Expand Up @@ -39,10 +40,10 @@ interface UseCreationWorkspaceReturn {
runtimeMode: RuntimeMode;
sshHost: string;
setRuntimeOptions: (mode: RuntimeMode, host: string) => void;
error: string | null;
setError: (error: string | null) => void;
toast: Toast | null;
setToast: (toast: Toast | null) => void;
isSending: boolean;
handleSend: (message: string) => Promise<void>;
handleSend: (message: string) => Promise<boolean>;
}

/**
Expand All @@ -58,7 +59,7 @@ export function useCreationWorkspace({
}: UseCreationWorkspaceOptions): UseCreationWorkspaceReturn {
const [branches, setBranches] = useState<string[]>([]);
const [recommendedTrunk, setRecommendedTrunk] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<Toast | null>(null);
const [isSending, setIsSending] = useState(false);

// Centralized draft workspace settings with automatic persistence
Expand Down Expand Up @@ -88,11 +89,11 @@ export function useCreationWorkspace({
}, [projectPath]);

const handleSend = useCallback(
async (message: string) => {
if (!message.trim() || isSending) return;
async (message: string): Promise<boolean> => {
if (!message.trim() || isSending) return false;

setIsSending(true);
setError(null);
setToast(null);

try {
// Get runtime config from options
Expand All @@ -110,9 +111,9 @@ export function useCreationWorkspace({
});

if (!result.success) {
setError(extractErrorMessage(result.error));
setToast(createErrorToast(result.error));
setIsSending(false);
return;
return false;
}

// Check if this is a workspace creation result (has metadata field)
Expand All @@ -121,15 +122,27 @@ export function useCreationWorkspace({
// Settings are already persisted via useDraftWorkspaceSettings
// Notify parent to switch workspace (clears input via parent unmount)
onWorkspaceCreated(result.metadata);
setIsSending(false);
return true;
} else {
// This shouldn't happen for null workspaceId, but handle gracefully
setError("Unexpected response from server");
setToast({
id: Date.now().toString(),
type: "error",
message: "Unexpected response from server",
});
setIsSending(false);
return false;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
setError(`Failed to create workspace: ${errorMessage}`);
setToast({
id: Date.now().toString(),
type: "error",
message: `Failed to create workspace: ${errorMessage}`,
});
setIsSending(false);
return false;
}
},
[
Expand All @@ -149,8 +162,8 @@ export function useCreationWorkspace({
runtimeMode: settings.runtimeMode,
sshHost: settings.sshHost,
setRuntimeOptions,
error,
setError,
toast,
setToast,
isSending,
handleSend,
};
Expand Down
4 changes: 4 additions & 0 deletions src/common/telemetry/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { initTelemetry, trackEvent, isTelemetryInitialized } from "./client";

describe("Telemetry", () => {
describe("in test environment", () => {
beforeAll(() => {
process.env.NODE_ENV = "test";
});

it("should not initialize PostHog", () => {
initTelemetry();
expect(isTelemetryInitialized()).toBe(false);
Expand Down
2 changes: 1 addition & 1 deletion src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export class AIService extends EventEmitter {
* constructor, ensuring automatic parity with Vercel AI SDK - any configuration options
* supported by the provider will work without modification.
*/
private async createModel(
async createModel(
modelString: string,
muxProviderOptions?: MuxProviderOptions
): Promise<Result<LanguageModel, SendMessageError>> {
Expand Down
73 changes: 55 additions & 18 deletions src/node/services/ipcMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { SUPPORTED_PROVIDERS } from "@/common/constants/providers";
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
import type { SendMessageError } from "@/common/types/errors";
import type { SendMessageOptions, DeleteMessage, ImagePart } from "@/common/types/ipc";
import { Ok, Err } from "@/common/types/result";
import { Ok, Err, type Result } from "@/common/types/result";
import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation";
import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { createBashTool } from "@/node/services/tools/bash";
Expand Down Expand Up @@ -168,11 +168,33 @@ export class IpcMain {
}
): Promise<
| { success: true; workspaceId: string; metadata: FrontendWorkspaceMetadata }
| { success: false; error: string }
| Result<void, SendMessageError>
> {
try {
// 1. Generate workspace branch name using AI (use same model as message)
const branchName = await generateWorkspaceName(message, options.model, this.config);
let branchName: string;
{
const isErrLike = (v: unknown): v is { type: string } =>
typeof v === "object" && v !== null && "type" in v;
const nameResult = await generateWorkspaceName(message, options.model, this.aiService);
if (!nameResult.success) {
const err = nameResult.error;
if (isErrLike(err)) {
return Err(err);
}
const toSafeString = (v: unknown): string => {
if (v instanceof Error) return v.message;
try {
return JSON.stringify(v);
} catch {
return String(v);
}
};
const msg = toSafeString(err);
return Err({ type: "unknown", raw: `Failed to generate workspace name: ${msg}` });
}
branchName = nameResult.data;
}

log.debug("Generated workspace name", { branchName });

Expand Down Expand Up @@ -205,7 +227,7 @@ export class IpcMain {
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return { success: false, error: errorMsg };
return Err({ type: "unknown", raw: `Failed to prepare runtime: ${errorMsg}` });
}

const session = this.getOrCreateSession(workspaceId);
Expand All @@ -222,7 +244,7 @@ export class IpcMain {
});

if (!createResult.success || !createResult.workspacePath) {
return { success: false, error: createResult.error ?? "Failed to create workspace" };
return Err({ type: "unknown", raw: createResult.error ?? "Failed to create workspace" });
}

const projectName =
Expand Down Expand Up @@ -255,7 +277,7 @@ export class IpcMain {
const allMetadata = await this.config.getAllWorkspaceMetadata();
const completeMetadata = allMetadata.find((m) => m.id === workspaceId);
if (!completeMetadata) {
return { success: false, error: "Failed to retrieve workspace metadata" };
return Err({ type: "unknown", raw: "Failed to retrieve workspace metadata" });
}

session.emitMetadata(completeMetadata);
Expand Down Expand Up @@ -286,7 +308,7 @@ export class IpcMain {
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error("Unexpected error in createWorkspaceForFirstMessage:", error);
return { success: false, error: `Failed to create workspace: ${errorMessage}` };
return Err({ type: "unknown", raw: `Failed to create workspace: ${errorMessage}` });
}
}

Expand Down Expand Up @@ -852,20 +874,35 @@ export class IpcMain {
const messageText = textParts.map((p) => p.text).join(" ");

if (messageText.trim()) {
const branchName = await generateWorkspaceName(
const nameResult = await generateWorkspaceName(
messageText,
"anthropic:claude-sonnet-4-5", // Use reasonable default model
this.config
this.aiService
);

// Update config with regenerated name
await this.config.updateWorkspaceMetadata(workspaceId, {
name: branchName,
});

// Return updated metadata
metadata.name = branchName;
log.info(`Regenerated workspace name: ${branchName}`);
if (nameResult.success) {
const branchName = nameResult.data;
// Update config with regenerated name
await this.config.updateWorkspaceMetadata(workspaceId, {
name: branchName,
});

// Return updated metadata
metadata.name = branchName;
log.info(`Regenerated workspace name: ${branchName}`);
} else {
log.info(
`Skipping title regeneration for ${workspaceId}: ${
(
nameResult.error as {
type?: string;
provider?: string;
message?: string;
raw?: string;
}
).type ?? "unknown"
}`
);
}
}
}
} catch (error) {
Expand Down
15 changes: 15 additions & 0 deletions src/node/services/systemMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ describe("buildSystemMessage", () => {
let globalDir: string;
let mockHomedir: Mock<typeof os.homedir>;
let runtime: LocalRuntime;
let originalMuxRoot: string | undefined;

beforeEach(async () => {
// Snapshot any existing MUX_ROOT so we can restore it after the test.
originalMuxRoot = process.env.MUX_ROOT;

// Create temp directory for test
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "systemMessage-test-"));
projectDir = path.join(tempDir, "project");
Expand All @@ -29,13 +33,24 @@ describe("buildSystemMessage", () => {
mockHomedir = spyOn(os, "homedir");
mockHomedir.mockReturnValue(tempDir);

// Force mux home to our test .mux directory regardless of host MUX_ROOT.
process.env.MUX_ROOT = globalDir;

// Create a local runtime for tests
runtime = new LocalRuntime(tempDir);
});

afterEach(async () => {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });

// Restore environment override
if (originalMuxRoot === undefined) {
delete process.env.MUX_ROOT;
} else {
process.env.MUX_ROOT = originalMuxRoot;
}

// Restore the original homedir
mockHomedir?.mockRestore();
});
Expand Down
Loading
Loading