From c7ed7bb6d7db67601b4c8831b9037d6be1c6396c Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 3 May 2026 10:37:00 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20simplify=20/new=20to=20m?= =?UTF-8?q?irror=20/fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /new no longer requires a workspace name or accepts -t/-r flags. The backend auto-generates a "workspace-N" branch name from the project's trunk branch, and when a start message is provided the workspace title is filled in from that message via pendingAutoTitle (same path /fork uses). --- _Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `max` • Cost: `$23.30`_ --- mobile/src/utils/slashCommandRunner.ts | 29 ++-- src/browser/utils/chatCommands.ts | 81 +++++----- src/browser/utils/slashCommands/new.test.ts | 108 ++----------- .../utils/slashCommands/parser.test.ts | 26 +-- src/browser/utils/slashCommands/registry.ts | 93 +---------- src/browser/utils/slashCommands/types.ts | 8 +- src/common/constants/slashCommandHints.ts | 2 +- src/common/orpc/schemas/api.ts | 12 +- src/node/acp/agent.ts | 27 ++-- src/node/acp/slashCommands.ts | 150 +----------------- src/node/orpc/router.ts | 3 +- src/node/services/workspaceService.test.ts | 136 ++++++++++++++++ src/node/services/workspaceService.ts | 61 +++++-- tests/ipc/acp.slashCommands.test.ts | 99 +++--------- 14 files changed, 327 insertions(+), 508 deletions(-) diff --git a/mobile/src/utils/slashCommandRunner.ts b/mobile/src/utils/slashCommandRunner.ts index c0b31dfaee..82e8c990ee 100644 --- a/mobile/src/utils/slashCommandRunner.ts +++ b/mobile/src/utils/slashCommandRunner.ts @@ -218,11 +218,6 @@ async function handleNew( ctx: SlashCommandRunnerContext, parsed: Extract ): Promise { - if (!parsed.workspaceName) { - ctx.showError("New workspace", "Please provide a name, e.g. /new feature-branch"); - return true; - } - const projectPath = ctx.metadata?.projectPath; if (!projectPath) { ctx.showError("New workspace", "Current workspace project path unknown"); @@ -230,13 +225,17 @@ async function handleNew( } try { - const trunkBranch = await resolveTrunkBranch(ctx, projectPath, parsed.trunkBranch); - const runtimeConfig = parseRuntimeStringForMobile(parsed.runtime); + const trunkBranch = await resolveTrunkBranch(ctx, projectPath); + // Coerce blank/whitespace-only payloads to undefined; pendingAutoTitle only + // makes sense when there is real content for the LLM to title from. + const trimmedStartMessage = parsed.startMessage?.trim() ?? ""; + const startMessage = trimmedStartMessage.length > 0 ? trimmedStartMessage : undefined; + // Mirror /fork: backend auto-generates the workspace name; pendingAutoTitle + // tells it to derive the title from the start message via LLM. const result = await ctx.client.workspace.create({ projectPath, - branchName: parsed.workspaceName, trunkBranch, - runtimeConfig, + pendingAutoTitle: Boolean(startMessage), }); if (!result.success) { ctx.showError("New workspace", result.error ?? "Failed to create workspace"); @@ -244,12 +243,12 @@ async function handleNew( } ctx.onNavigateToWorkspace(result.metadata.id); - ctx.showInfo("New workspace", `Created ${result.metadata.name}`); + ctx.showInfo("New workspace", `Created ${result.metadata.title ?? result.metadata.name}`); - if (parsed.startMessage) { + if (startMessage) { await ctx.client.workspace.sendMessage({ workspaceId: result.metadata.id, - message: parsed.startMessage, + message: startMessage, options: ctx.sendMessageOptions, }); } @@ -263,12 +262,8 @@ async function handleNew( async function resolveTrunkBranch( ctx: SlashCommandRunnerContext, - projectPath: string, - explicit?: string + projectPath: string ): Promise { - if (explicit) { - return explicit; - } try { const { recommendedTrunk, branches } = await ctx.client.projects.listBranches({ projectPath }); return recommendedTrunk ?? branches?.[0] ?? "main"; diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index b7b32349ad..bb51128e7d 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -750,11 +750,21 @@ export function parseRuntimeString( export interface CreateWorkspaceOptions { client: RouterClient; projectPath: string; - workspaceName: string; + /** + * Workspace branch name. When omitted, the backend auto-generates one + * (e.g., "workspace-1", "workspace-2") so /new can mirror /fork's + * seamless creation flow. + */ + workspaceName?: string; trunkBranch?: string; runtime?: string; startMessage?: string; sendMessageOptions?: SendMessageOptions; + /** + * When true, ask the backend to mark the workspace with `pendingAutoTitle` + * so the start message drives LLM-based title generation (mirrors /fork). + */ + pendingAutoTitle?: boolean; } export interface CreateWorkspaceResult { @@ -791,14 +801,20 @@ export async function createNewWorkspace( } } - // Parse runtime config if provided - const runtimeConfig = parseRuntimeString(effectiveRuntime, options.workspaceName); + // Parse runtime config if provided. Use a placeholder when no caller-provided + // workspace name is available (auto-name path); parseRuntimeString only uses + // the name for error reporting context. + const runtimeConfig = parseRuntimeString( + effectiveRuntime, + options.workspaceName ?? "(auto-generated)" + ); const result = await options.client.workspace.create({ projectPath: options.projectPath, branchName: options.workspaceName, trunkBranch: effectiveTrunk, runtimeConfig, + pendingAutoTitle: options.pendingAutoTitle, }); if (!result.success) { @@ -1011,7 +1027,12 @@ export interface CommandHandlerResult { } /** - * Handle /new command execution + * Handle /new command execution. + * + * Mirrors /fork's seamless flow: no modal, no required workspace name. The + * backend auto-generates a branch name, and when a start message is supplied + * we ask it to fill in the workspace title from that message via + * `pendingAutoTitle`. */ export async function handleNewCommand( parsed: Extract, @@ -1026,52 +1047,32 @@ export async function handleNewCommand( setToast, } = context; - // Open modal if no workspace name provided - if (!parsed.workspaceName) { - setInput(""); - - // Get workspace info to extract projectPath for the modal - const workspaceInfo = await client.workspace.getInfo({ workspaceId }); - if (!workspaceInfo) { - setToast({ - id: Date.now().toString(), - type: "error", - title: "Error", - message: "Failed to get workspace info", - }); - return { clearInput: false, toastShown: true }; - } - - // Dispatch event with start message, model, and optional preferences - const event = createCustomEvent(CUSTOM_EVENTS.START_WORKSPACE_CREATION, { - projectPath: workspaceInfo.projectPath, - startMessage: parsed.startMessage ?? "", - model: sendMessageOptions.model, - trunkBranch: parsed.trunkBranch, - runtime: parsed.runtime, - }); - window.dispatchEvent(event); - return { clearInput: true, toastShown: false }; - } - - setInput(""); + setInput(""); // Clear input immediately, like /fork. setSendingState(true); try { - // Get workspace info to extract projectPath + // Get workspace info to extract projectPath. /new is a workspace-only + // command, so the parent workspace's project becomes the new workspace's + // project. const workspaceInfo = await client.workspace.getInfo({ workspaceId }); if (!workspaceInfo) { throw new Error("Failed to get workspace info"); } + // Treat blank/whitespace-only payloads the same as no message — pendingAutoTitle + // only makes sense when there is real content for the LLM to title from. + const trimmedStartMessage = parsed.startMessage?.trim() ?? ""; + const startMessage = trimmedStartMessage.length > 0 ? trimmedStartMessage : undefined; + const createResult = await createNewWorkspace({ client, projectPath: workspaceInfo.projectPath, - workspaceName: parsed.workspaceName, - trunkBranch: parsed.trunkBranch, - runtime: parsed.runtime, - startMessage: parsed.startMessage, + // workspaceName intentionally omitted — backend auto-generates (like /fork). + startMessage, sendMessageOptions, + // Match /fork: only flag pendingAutoTitle when there is a message to + // generate the title from. + pendingAutoTitle: Boolean(startMessage), }); if (!createResult.success) { @@ -1087,10 +1088,12 @@ export async function handleNewCommand( } trackCommandUsed("new"); + const displayName = + createResult.workspaceInfo?.title ?? createResult.workspaceInfo?.name ?? "new workspace"; setToast({ id: Date.now().toString(), type: "success", - message: `Created workspace "${parsed.workspaceName}"`, + message: `Created workspace "${displayName}"`, }); return { clearInput: true, toastShown: true }; } catch (error) { diff --git a/src/browser/utils/slashCommands/new.test.ts b/src/browser/utils/slashCommands/new.test.ts index d0f46bdb9a..e3d1fed13f 100644 --- a/src/browser/utils/slashCommands/new.test.ts +++ b/src/browser/utils/slashCommands/new.test.ts @@ -1,113 +1,35 @@ import { parseCommand } from "./parser"; +// /new mirrors /fork: there is no required workspace name. Everything after +// `/new` becomes the optional start message; the backend auto-generates the +// branch name and (when a start message is provided) fills in the title. describe("/new command", () => { - it("should return undefined workspaceName when no arguments provided (opens modal)", () => { - const result = parseCommand("/new"); - expect(result).toEqual({ - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - startMessage: undefined, - }); + it("parses bare /new as a no-arg seamless creation", () => { + expect(parseCommand("/new")).toEqual({ type: "new" }); }); - it("should parse /new with workspace name", () => { - const result = parseCommand("/new feature-branch"); - expect(result).toEqual({ - type: "new", - workspaceName: "feature-branch", - trunkBranch: undefined, - startMessage: undefined, - }); + it("treats trailing whitespace as no start message", () => { + expect(parseCommand("/new ")).toEqual({ type: "new" }); }); - it("should parse /new with workspace name and trunk via -t flag", () => { - const result = parseCommand("/new feature-branch -t main"); - expect(result).toEqual({ + it("captures the rest of the line as the start message", () => { + expect(parseCommand("/new Build authentication system")).toEqual({ type: "new", - workspaceName: "feature-branch", - trunkBranch: "main", - startMessage: undefined, + startMessage: "Build authentication system", }); }); - it("should parse /new with workspace name and start message", () => { - const result = parseCommand("/new feature-branch\nStart implementing feature X"); - expect(result).toEqual({ + it("preserves multiline start messages", () => { + expect(parseCommand("/new Build feature X\nWith follow-up details")).toEqual({ type: "new", - workspaceName: "feature-branch", - trunkBranch: undefined, - startMessage: "Start implementing feature X", + startMessage: "Build feature X\nWith follow-up details", }); }); - it("should parse /new with workspace name, trunk via -t, and start message", () => { - const result = parseCommand("/new feature-branch -t main\nStart implementing feature X"); - expect(result).toEqual({ + it("supports start messages on the line below /new", () => { + expect(parseCommand("/new\nStart implementing feature X")).toEqual({ type: "new", - workspaceName: "feature-branch", - trunkBranch: "main", startMessage: "Start implementing feature X", }); }); - - it("should handle multiline start messages", () => { - const result = parseCommand("/new feature-branch\nLine 1\nLine 2\nLine 3"); - expect(result).toEqual({ - type: "new", - workspaceName: "feature-branch", - trunkBranch: undefined, - startMessage: "Line 1\nLine 2\nLine 3", - }); - }); - - it("should return undefined workspaceName for extra positional arguments (opens modal)", () => { - const result = parseCommand("/new feature-branch extra"); - expect(result).toEqual({ - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - startMessage: undefined, - }); - }); - - it("should handle quoted workspace names", () => { - const result = parseCommand('/new "my feature"'); - expect(result).toEqual({ - type: "new", - workspaceName: "my feature", - trunkBranch: undefined, - startMessage: undefined, - }); - }); - - it("should return undefined workspaceName for unknown flags (opens modal)", () => { - const result = parseCommand("/new feature-branch -x invalid"); - expect(result).toEqual({ - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - startMessage: undefined, - }); - }); - - it("should handle -t flag with quoted branch name", () => { - const result = parseCommand('/new feature-branch -t "release/v1.0"'); - expect(result).toEqual({ - type: "new", - workspaceName: "feature-branch", - trunkBranch: "release/v1.0", - startMessage: undefined, - }); - }); - - it("should handle -t flag before workspace name", () => { - const result = parseCommand("/new -t main feature-branch"); - expect(result).toEqual({ - type: "new", - workspaceName: "feature-branch", - trunkBranch: "main", - startMessage: undefined, - }); - }); }); diff --git a/src/browser/utils/slashCommands/parser.test.ts b/src/browser/utils/slashCommands/parser.test.ts index 435a4fe3d4..3f17b33249 100644 --- a/src/browser/utils/slashCommands/parser.test.ts +++ b/src/browser/utils/slashCommands/parser.test.ts @@ -257,33 +257,19 @@ describe("thinking oneshot (/model+level syntax)", () => { }); }); -it("should preserve start message when no workspace name provided", () => { +it("treats text after /new as the start message (no workspace name required)", () => { expectParse("/new\nBuild authentication system", { type: "new", - workspaceName: undefined, - trunkBranch: undefined, - runtime: undefined, startMessage: "Build authentication system", }); }); -it("should preserve start message and flags when no workspace name", () => { - expectParse("/new -t develop\nImplement feature X", { +it("collapses multiline /new input into a single start message", () => { + // /new now mirrors /fork: the entire payload is the start message and the + // backend handles workspace naming + (optional) auto-title generation. + expectParse("/new Build feature X\nWith follow-up details", { type: "new", - workspaceName: undefined, - trunkBranch: "develop", - runtime: undefined, - startMessage: "Implement feature X", - }); -}); - -it("should preserve start message with runtime flag when no workspace name", () => { - expectParse('/new -r "ssh dev.example.com"\nDeploy to staging', { - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - runtime: "ssh dev.example.com", - startMessage: "Deploy to staging", + startMessage: "Build feature X\nWith follow-up details", }); }); diff --git a/src/browser/utils/slashCommands/registry.ts b/src/browser/utils/slashCommands/registry.ts index 8493ae7743..c832056974 100644 --- a/src/browser/utils/slashCommands/registry.ts +++ b/src/browser/utils/slashCommands/registry.ts @@ -316,96 +316,19 @@ const forkCommandDefinition: SlashCommandDefinition = { const newCommandDefinition: SlashCommandDefinition = { key: "new", description: - "Create new workspace with optional trunk branch and runtime. Use -t to specify trunk, -r for remote execution (e.g., 'ssh hostname' or 'ssh user@host'). Add start message on lines after the command.", + "Create a new workspace from the project's trunk branch. Optionally include a start message.", inputHint: SLASH_COMMAND_HINTS.new, handler: ({ rawInput }): ParsedCommand => { - const { - tokens: firstLineTokens, - message: remainingLines, - hasMultiline, - } = parseMultilineCommand(rawInput); - - // Parse flags from first line using minimist - const parsed = minimist(firstLineTokens, { - string: ["t", "r"], - unknown: (arg: string) => { - // Unknown flags starting with - are errors - if (arg.startsWith("-")) { - return false; - } - return true; - }, - }); - - // Check for unknown flags - return undefined workspaceName to open modal - const unknownFlags = firstLineTokens.filter( - (token) => token.startsWith("-") && token !== "-t" && token !== "-r" - ); - if (unknownFlags.length > 0) { - return { - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - runtime: undefined, - startMessage: undefined, - }; - } - - // No workspace name provided - return undefined to open modal - if (parsed._.length === 0) { - // Get trunk branch from -t flag - let trunkBranch: string | undefined; - if (parsed.t !== undefined && typeof parsed.t === "string" && parsed.t.trim().length > 0) { - trunkBranch = parsed.t.trim(); - } - - // Get runtime from -r flag - let runtime: string | undefined; - if (parsed.r !== undefined && typeof parsed.r === "string" && parsed.r.trim().length > 0) { - runtime = parsed.r.trim(); - } - - return { - type: "new", - workspaceName: undefined, - trunkBranch, - runtime, - startMessage: remainingLines, - }; - } - - // Get workspace name (first positional argument) - const workspaceName = String(parsed._[0]); - - // Reject extra positional arguments - return undefined to open modal - if (parsed._.length > 1 && !hasMultiline) { - return { - type: "new", - workspaceName: undefined, - trunkBranch: undefined, - runtime: undefined, - startMessage: undefined, - }; - } - - // Get trunk branch from -t flag - let trunkBranch: string | undefined; - if (parsed.t !== undefined && typeof parsed.t === "string" && parsed.t.trim().length > 0) { - trunkBranch = parsed.t.trim(); - } - - // Get runtime from -r flag - let runtime: string | undefined; - if (parsed.r !== undefined && typeof parsed.r === "string" && parsed.r.trim().length > 0) { - runtime = parsed.r.trim(); + // Mirror /fork: everything after /new is the optional start message. + // The workspace branch name is auto-generated by the backend (like /fork), + // and the title is filled in from the start message when one is provided. + const trimmed = rawInput.trim(); + if (trimmed.length === 0) { + return { type: "new" }; } - return { type: "new", - workspaceName, - trunkBranch, - runtime, - startMessage: remainingLines, + startMessage: trimmed, }; }, }; diff --git a/src/browser/utils/slashCommands/types.ts b/src/browser/utils/slashCommands/types.ts index dcfd85d8b1..7778c78032 100644 --- a/src/browser/utils/slashCommands/types.ts +++ b/src/browser/utils/slashCommands/types.ts @@ -27,13 +27,7 @@ export type ParsedCommand = | { type: "truncate"; percentage: number } | { type: "compact"; maxOutputTokens?: number; continueMessage?: string; model?: string } | { type: "fork"; startMessage?: string } - | { - type: "new"; - workspaceName?: string; - trunkBranch?: string; - runtime?: string; - startMessage?: string; - } + | { type: "new"; startMessage?: string } | { type: "vim-toggle" } | { type: "plan-show" } | { type: "plan-open" } diff --git a/src/common/constants/slashCommandHints.ts b/src/common/constants/slashCommandHints.ts index e72e3e6f45..e936de64cf 100644 --- a/src/common/constants/slashCommandHints.ts +++ b/src/common/constants/slashCommandHints.ts @@ -8,7 +8,7 @@ export const SLASH_COMMAND_HINTS = { compact: "[-t ] [-m ] [continue message]", model: "", fork: "[start message]", - new: " [-t ] [-r ] [start message]", + new: "[start message]", idle: "|off", heartbeat: "|off", } as const satisfies Readonly>; diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8ff5e1796b..6e70424a86 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -995,7 +995,12 @@ export const workspace = { create: { input: z.object({ projectPath: z.string(), - branchName: z.string(), + /** + * Workspace branch name. When omitted, the backend auto-generates a name + * based on the trunk branch (e.g., "main-1", "main-2"), similar to how + * /fork derives a name from the parent workspace. + */ + branchName: z.string().optional(), /** Trunk branch to fork from - only required for worktree/SSH runtimes, ignored for local */ trunkBranch: z.string().optional(), /** Human-readable title (e.g., "Fix plan mode over SSH") - optional for backwards compat */ @@ -1003,6 +1008,11 @@ export const workspace = { runtimeConfig: RuntimeConfigSchema.optional(), /** Section ID to assign the new workspace to (optional) */ sectionId: z.string().optional(), + /** + * When true, mark the workspace with `pendingAutoTitle` so the first user + * message triggers an LLM-generated title (mirrors the /fork flow). + */ + pendingAutoTitle: z.boolean().optional(), }), output: z.discriminatedUnion("success", [ z.object({ success: z.literal(true), metadata: FrontendWorkspaceMetadataSchema }), diff --git a/src/node/acp/agent.ts b/src/node/acp/agent.ts index b2147f30d8..d90178c571 100644 --- a/src/node/acp/agent.ts +++ b/src/node/acp/agent.ts @@ -902,19 +902,23 @@ export class MuxAgent implements Agent { ); } - let trunkBranch = parsedCommand.trunkBranch; - if (trunkBranch == null || trunkBranch.trim().length === 0) { - const branchInfo = await this.server.client.projects.listBranches({ - projectPath: workspaceInfo.projectPath, - }); - trunkBranch = branchInfo.recommendedTrunk ?? DEFAULT_TRUNK_BRANCH; - } + // Resolve trunk from project defaults — /new no longer accepts overrides + // (it now mirrors /fork's seamless flow). + const branchInfo = await this.server.client.projects.listBranches({ + projectPath: workspaceInfo.projectPath, + }); + const trunkBranch = branchInfo.recommendedTrunk ?? DEFAULT_TRUNK_BRANCH; + + const hasStartMessage = + parsedCommand.startMessage != null && parsedCommand.startMessage.trim().length > 0; const createResult = await this.server.client.workspace.create({ projectPath: workspaceInfo.projectPath, - branchName: parsedCommand.workspaceName, + // branchName intentionally omitted — backend auto-generates (like /fork). trunkBranch, - runtimeConfig: parsedCommand.runtimeConfig, + // Mirror /fork: when a start message accompanies /new, defer the title + // selection until the first message can drive LLM-based generation. + pendingAutoTitle: hasStartMessage, }); if (!createResult.success) { @@ -925,9 +929,10 @@ export class MuxAgent implements Agent { } const newWorkspaceId = createResult.metadata.id; - let response = `Created workspace \`${parsedCommand.workspaceName}\` (id: \`${newWorkspaceId}\`).`; + const displayName = createResult.metadata.title ?? createResult.metadata.name; + let response = `Created workspace \`${displayName}\` (id: \`${newWorkspaceId}\`).`; - if (parsedCommand.startMessage != null && parsedCommand.startMessage.trim().length > 0) { + if (hasStartMessage && parsedCommand.startMessage != null) { const startMessageResult = await this.server.client.workspace.sendMessage({ workspaceId: newWorkspaceId, message: parsedCommand.startMessage, diff --git a/src/node/acp/slashCommands.ts b/src/node/acp/slashCommands.ts index d2d451d93b..99c0d834b9 100644 --- a/src/node/acp/slashCommands.ts +++ b/src/node/acp/slashCommands.ts @@ -2,11 +2,6 @@ import assert from "node:assert/strict"; import type { AvailableCommand } from "@agentclientprotocol/sdk"; import type { AgentSkillDescriptor } from "@/common/types/agentSkill"; import { SLASH_COMMAND_HINTS } from "@/common/constants/slashCommandHints"; -import { - buildRuntimeConfig, - parseRuntimeModeAndHost, - type RuntimeConfig, -} from "@/common/types/runtime"; import { getExplicitGatewayPrefix, isValidModelFormat, @@ -23,7 +18,6 @@ const NEW_COMMAND_NAME = "new"; const TRUNCATE_USAGE = `/truncate ${SLASH_COMMAND_HINTS.truncate}`; const COMPACT_USAGE = `/compact ${SLASH_COMMAND_HINTS.compact}`; -const NEW_USAGE = `/new ${SLASH_COMMAND_HINTS.new}`; interface ServerCommandDefinition { name: string; @@ -55,7 +49,7 @@ const SERVER_COMMAND_DEFINITIONS: readonly ServerCommandDefinition[] = [ { name: NEW_COMMAND_NAME, description: - "Create a new workspace from the current project. Supports -t , -r , and multiline start messages.", + "Create a new workspace in the current project from its trunk branch. Optionally include a start message.", inputHint: SLASH_COMMAND_HINTS.new, }, ] as const; @@ -81,13 +75,7 @@ export type ParsedAcpSlashCommand = continueMessage?: string; } | { kind: "fork"; startMessage?: string } - | { - kind: "new"; - workspaceName: string; - trunkBranch?: string; - runtimeConfig?: RuntimeConfig; - startMessage?: string; - } + | { kind: "new"; startMessage?: string } | { kind: "skill"; descriptor: AgentSkillDescriptor; @@ -333,97 +321,14 @@ function parseForkCommand(rawInput: string): ParsedAcpSlashCommand { } function parseNewCommand(rawInput: string): ParsedAcpSlashCommand { - const { tokens: firstLineTokens, message: multilineMessage } = parseMultilineCommand(rawInput); - - const parsed = minimist(firstLineTokens, { - string: ["t", "r"], - unknown: (arg: string) => { - if (arg.startsWith("-")) { - return false; - } - return true; - }, - }); - - const unknownFlags = firstLineTokens.filter( - (token) => token.startsWith("-") && token !== "-t" && token !== "-r" - ); - if (unknownFlags.length > 0) { - return { - kind: "invalid", - message: `Unknown flag "${unknownFlags[0]}". Usage: ${NEW_USAGE}`, - }; - } - - const workspaceNameToken = coercePositionalTokenToText(parsed._[0], NEW_USAGE); - if (workspaceNameToken.error != null) { - return { - kind: "invalid", - message: workspaceNameToken.error, - }; - } - - if (workspaceNameToken.text == null) { - return { - kind: "invalid", - message: `Missing workspace name. Usage: ${NEW_USAGE}`, - }; - } - - const workspaceName = workspaceNameToken.text; - const trunkBranch = - typeof parsed.t === "string" && parsed.t.trim().length > 0 ? parsed.t.trim() : undefined; - - let startMessageTokens = parsed._.slice(1); - let runtimeConfig: RuntimeConfig | undefined; - if (parsed.r != null) { - if (typeof parsed.r !== "string" || parsed.r.trim().length === 0) { - return { - kind: "invalid", - message: `-r expects a runtime (e.g., "ssh user@host" or "docker ubuntu:22.04").`, - }; - } - - const runtimeInput = parsed.r.trim(); - let parsedRuntime = parseRuntimeInput(runtimeInput); - - // Allow unquoted two-token runtime values (e.g., `-r ssh user@host`). - // minimist captures only the first token for `-r`, so consume the first - // positional token as a runtime suffix when doing so resolves parsing. - const runtimeSuffixToken = coercePositionalTokenToText(startMessageTokens[0], NEW_USAGE); - if (parsedRuntime.error != null && runtimeSuffixToken.text != null) { - const combinedRuntimeInput = `${runtimeInput} ${runtimeSuffixToken.text}`; - const combinedParsedRuntime = parseRuntimeInput(combinedRuntimeInput); - if (combinedParsedRuntime.error == null) { - parsedRuntime = combinedParsedRuntime; - startMessageTokens = startMessageTokens.slice(1); - } - } - - if (parsedRuntime.error != null) { - return { - kind: "invalid", - message: parsedRuntime.error, - }; - } - - runtimeConfig = parsedRuntime.runtimeConfig; - } - - const inlineStartMessage = joinPositionalMessageTokens(startMessageTokens, NEW_USAGE); - if (inlineStartMessage.error != null) { - return { - kind: "invalid", - message: inlineStartMessage.error, - }; - } + // Mirror /fork: everything after /new is the optional start message. + // The backend auto-generates the workspace name (and the title from the + // start message) so users no longer have to provide one. + const startMessage = rawInput.trim(); return { kind: "new", - workspaceName, - trunkBranch, - runtimeConfig, - startMessage: joinMultilineAndInlineMessage(multilineMessage, inlineStartMessage.message), + startMessage: startMessage.length > 0 ? startMessage : undefined, }; } @@ -453,47 +358,6 @@ function parseSkillCommand( }; } -function parseRuntimeInput(runtime: string): { runtimeConfig?: RuntimeConfig; error?: string } { - const parsed = parseRuntimeModeAndHost(runtime); - if (parsed == null) { - const trimmed = runtime.trim().toLowerCase(); - if (trimmed === "ssh" || trimmed.startsWith("ssh ")) { - return { - error: 'SSH runtime requires host (e.g., "ssh hostname" or "ssh user@host").', - }; - } - - if (trimmed === "docker" || trimmed.startsWith("docker ")) { - return { - error: 'Docker runtime requires image (e.g., "docker ubuntu:22.04").', - }; - } - - if (trimmed === "devcontainer" || trimmed.startsWith("devcontainer")) { - return { - error: - 'Dev container runtime requires a config path (e.g., "devcontainer .devcontainer/devcontainer.json").', - }; - } - - return { - error: - "Unknown runtime type. Use one of: worktree, local, ssh , docker , devcontainer ", - }; - } - - if (parsed.mode === "devcontainer" && parsed.configPath.trim().length === 0) { - return { - error: - 'Dev container runtime requires a config path (e.g., "devcontainer .devcontainer/devcontainer.json").', - }; - } - - return { - runtimeConfig: buildRuntimeConfig(parsed), - }; -} - function normalizeModelForCommand(modelInput: string): string | null { const trimmed = modelInput.trim(); if (trimmed.length === 0) { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index ce5da5db4b..938e4e0f98 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -3110,7 +3110,8 @@ export const router = (authToken?: string) => { input.trunkBranch, input.title, input.runtimeConfig, - input.sectionId + input.sectionId, + input.pendingAutoTitle ); if (!result.success) { return { success: false, error: result.error }; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b913a3342f..4d4084ad4e 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -5650,6 +5650,142 @@ describe("WorkspaceService init cancellation", () => { createRuntimeSpy.mockRestore(); } }); + + test("create() auto-generates a workspace branch name when none is provided", async () => { + // /new mirrors /fork's seamless flow: callers no longer have to invent a + // workspace name. The backend should derive the next "workspace-N" slot + // and persist `pendingAutoTitle` so the first message can title the workspace. + const workspaceId = "ws-auto-named"; + const projectPath = "/tmp/proj-auto"; + const workspacePath = "/tmp/proj-auto/workspace-3"; + + const initStates = new Map(); + const mockInitStateManager: Partial = { + on: mock(() => undefined as unknown as InitStateManager), + startInit: mock((id: string) => { + initStates.set(id, { + status: "running", + hookPath: projectPath, + startTime: 0, + lines: [], + exitCode: null, + endTime: null, + }); + }), + getInitState: mock((id: string) => initStates.get(id)), + clearInMemoryState: mock((id: string) => { + initStates.delete(id); + }), + }; + + const configState: ProjectsConfig = { projects: new Map() }; + + const mockMetadata: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "workspace-3", + projectName: "proj-auto", + projectPath, + createdAt: "2026-01-01T00:00:00.000Z", + namedWorkspacePath: workspacePath, + runtimeConfig: { type: "local" }, + pendingAutoTitle: true, + }; + + const mockConfig: Partial = { + rootDir: "/tmp/mux-root", + srcDir: "/tmp/src", + generateStableId: mock(() => workspaceId), + editConfig: mock((editFn: (config: ProjectsConfig) => ProjectsConfig) => { + editFn(configState); + return Promise.resolve(); + }), + getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), + getEffectiveSecrets: mock(() => []), + getSessionDir: mock(() => "/tmp/test/sessions"), + findWorkspace: mock(() => null), + // Two pre-existing workspaces — auto-naming should skip past them. + loadConfigOrDefault: mock(() => ({ + projects: new Map([ + [ + projectPath, + { + workspaces: [ + { id: "x", name: "workspace-1", path: "/tmp/proj-auto/workspace-1" }, + { id: "y", name: "workspace-2", path: "/tmp/proj-auto/workspace-2" }, + ], + trusted: true, + }, + ], + ]), + })), + }; + + const mockAIService = { + isStreaming: mock(() => false), + // eslint-disable-next-line @typescript-eslint/no-empty-function + on: mock(() => {}), + // eslint-disable-next-line @typescript-eslint/no-empty-function + off: mock(() => {}), + } as unknown as AIService; + const createWorkspaceMock = mock(() => + Promise.resolve({ success: true as const, workspacePath }) + ); + + const createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ + createWorkspace: createWorkspaceMock, + } as unknown as ReturnType); + + try { + const workspaceService = new WorkspaceService( + mockConfig as Config, + historyService, + mockAIService, + mockInitStateManager as InitStateManager, + mockExtensionMetadataService as ExtensionMetadataService, + mockBackgroundProcessManager as BackgroundProcessManager + ); + + const removingWorkspaces = ( + workspaceService as unknown as { removingWorkspaces: Set } + ).removingWorkspaces; + // Skip the background init path so the test stays focused on auto-naming/persistence. + removingWorkspaces.add(workspaceId); + + const result = await workspaceService.create( + projectPath, + // No branchName — backend should auto-generate workspace-3. + undefined, + undefined, + undefined, + { type: "local" }, + undefined, + // pendingAutoTitle: true mirrors the /fork-with-message flow. + true + ); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + + // Backend picked the next "workspace-N" slot and threaded it through to + // both the runtime call and the persisted config entry. + expect(createWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + branchName: "workspace-3", + directoryName: "workspace-3", + }) + ); + + const persisted = configState.projects.get(projectPath)?.workspaces ?? []; + const newEntry = persisted.find((entry) => entry.id === workspaceId); + expect(newEntry?.name).toBe("workspace-3"); + expect(newEntry?.pendingAutoTitle).toBe(true); + } finally { + createRuntimeSpy.mockRestore(); + } + }); + test("remove() aborts init and clears state before teardown", async () => { const workspaceId = "ws-remove-aborts"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index e41970871e..0ea3deb56d 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -194,6 +194,13 @@ import { getErrorMessage } from "@/common/utils/errors"; /** Maximum number of retry attempts when workspace name collides */ const MAX_WORKSPACE_NAME_COLLISION_RETRIES = 3; +/** + * Base name used when /new auto-generates a branch name. Numbered suffixes + * (`workspace-1`, `workspace-2`, ...) come from {@link generateForkBranchName} + * so the existing fork-style numbering helpers stay the single source of truth. + */ +const AUTO_NEW_WORKSPACE_BASE_NAME = "workspace"; + // Keep short to feel instant, but debounce bursts of file_edit_* tool calls. // Shared type for workspace-scoped AI settings (model + thinking) @@ -2094,18 +2101,13 @@ export class WorkspaceService extends EventEmitter { async create( projectPath: string, - branchName: string, + branchName: string | undefined, trunkBranch: string | undefined, title?: string, runtimeConfig?: RuntimeConfig, - sectionId?: string + sectionId?: string, + pendingAutoTitle?: boolean ): Promise> { - // Validate workspace name - const validation = validateWorkspaceName(branchName); - if (!validation.valid) { - return Err(validation.error ?? "Invalid workspace name"); - } - // Trust gate: block workspace creation for untrusted projects. // The frontend shows a confirmation dialog before reaching here, // but this guards secondary paths (slash commands, forking). @@ -2118,6 +2120,40 @@ export class WorkspaceService extends EventEmitter { ); } + // Auto-generate a branch name when the caller omits one (used by /new to + // mirror /fork's seamless creation flow). Mirrors fork's auto-naming: scan + // existing workspace names AND local git branches so numbering is stable. + let resolvedBranchName: string; + if (branchName == null) { + const existingNamesSet = new Set(); + for (const entry of projectConfig.workspaces ?? []) { + if (typeof entry.name === "string") { + existingNamesSet.add(entry.name); + } + } + try { + for (const localBranch of await listLocalBranches(projectPath)) { + existingNamesSet.add(localBranch); + } + } catch (error) { + log.debug("Failed to list local branches for /new auto-name preflight", { + projectPath, + error: getErrorMessage(error), + }); + } + resolvedBranchName = generateForkBranchName(AUTO_NEW_WORKSPACE_BASE_NAME, [ + ...existingNamesSet, + ]); + } else { + resolvedBranchName = branchName; + } + + // Validate workspace name (covers both caller-provided and auto-generated names) + const validation = validateWorkspaceName(resolvedBranchName); + if (!validation.valid) { + return Err(validation.error ?? "Invalid workspace name"); + } + // Generate stable workspace ID const workspaceId = this.config.generateStableId(); @@ -2175,7 +2211,7 @@ export class WorkspaceService extends EventEmitter { try { // Create workspace with automatic collision retry - let finalBranchName = branchName; + let finalBranchName = resolvedBranchName; let createResult: { success: boolean; workspacePath?: string; error?: string }; // If runtime uses config-level collision detection (e.g., Coder - can't reach host), @@ -2192,7 +2228,7 @@ export class WorkspaceService extends EventEmitter { i++ ) { log.debug(`Workspace name collision for "${finalBranchName}", adding suffix`); - finalBranchName = appendCollisionSuffix(branchName); + finalBranchName = appendCollisionSuffix(resolvedBranchName); } } @@ -2221,7 +2257,7 @@ export class WorkspaceService extends EventEmitter { attempt < MAX_WORKSPACE_NAME_COLLISION_RETRIES ) { log.debug(`Workspace name collision for "${finalBranchName}", retrying with suffix`); - finalBranchName = appendCollisionSuffix(branchName); + finalBranchName = appendCollisionSuffix(resolvedBranchName); continue; } break; @@ -2281,6 +2317,9 @@ export class WorkspaceService extends EventEmitter { createdAt: metadata.createdAt, runtimeConfig: finalRuntimeConfig, sectionId, + // Mirror /fork: when /new is invoked with a start message, defer title + // selection until the first message can drive LLM-based generation. + ...(pendingAutoTitle === true ? { pendingAutoTitle: true } : {}), }); return config; }); diff --git a/tests/ipc/acp.slashCommands.test.ts b/tests/ipc/acp.slashCommands.test.ts index 7d299c433f..20e1f9483f 100644 --- a/tests/ipc/acp.slashCommands.test.ts +++ b/tests/ipc/acp.slashCommands.test.ts @@ -135,89 +135,30 @@ describe("ACP slash command support", () => { expect(parsed.continueMessage).toBe("continue in 2 steps"); }); - it("rejects invalid /new runtime arguments", () => { - const invalid = parseAcpSlashCommand("/new feature-branch -r ssh", mapSkillsByName(skills)); - expect(invalid?.kind).toBe("invalid"); - - const parsed = parseAcpSlashCommand( - '/new feature-branch -t main -r "ssh user@example.com"\nStart by summarizing the branch', - mapSkillsByName(skills) - ); - - expect(parsed?.kind).toBe("new"); - if (parsed == null || parsed.kind !== "new") { - throw new Error("Expected /new command to parse"); - } - - expect(parsed.workspaceName).toBe("feature-branch"); - expect(parsed.trunkBranch).toBe("main"); - expect(parsed.runtimeConfig?.type).toBe("ssh"); - if (parsed.runtimeConfig?.type === "ssh") { - expect(parsed.runtimeConfig.host).toBe("user@example.com"); - } - expect(parsed.startMessage).toBe("Start by summarizing the branch"); - }); - - it("parses /new with one-line start message", () => { - const parsed = parseAcpSlashCommand( - '/new feature-branch -t main -r "ssh user@example.com" start by summarizing the branch', - mapSkillsByName(skills) - ); - - expect(parsed?.kind).toBe("new"); - if (parsed == null || parsed.kind !== "new") { - throw new Error("Expected one-line /new command to parse"); - } - - expect(parsed.workspaceName).toBe("feature-branch"); - expect(parsed.trunkBranch).toBe("main"); - expect(parsed.runtimeConfig?.type).toBe("ssh"); - expect(parsed.startMessage).toBe("start by summarizing the branch"); - }); - - it("parses /new with unquoted two-token runtime and one-line start message", () => { - const parsed = parseAcpSlashCommand( - "/new feature-branch -r ssh user@example.com start by summarizing the branch", - mapSkillsByName(skills) - ); - - expect(parsed?.kind).toBe("new"); - if (parsed == null || parsed.kind !== "new") { - throw new Error("Expected unquoted two-token /new runtime to parse"); - } - - expect(parsed.workspaceName).toBe("feature-branch"); - expect(parsed.runtimeConfig?.type).toBe("ssh"); - if (parsed.runtimeConfig?.type === "ssh") { - expect(parsed.runtimeConfig.host).toBe("user@example.com"); - } - expect(parsed.startMessage).toBe("start by summarizing the branch"); + // /new now mirrors /fork — there is no workspace name argument and no + // -t/-r flags. Everything after `/new` is the optional start message and + // the backend handles auto-naming + pendingAutoTitle. + it("parses /new with no arguments", () => { + expect(parseAcpSlashCommand("/new", mapSkillsByName(skills))).toEqual({ + kind: "new", + startMessage: undefined, + }); }); - it("parses /new one-line start message containing numbers", () => { - const parsed = parseAcpSlashCommand( - "/new feature-branch start with step 1", - mapSkillsByName(skills) - ); - - expect(parsed?.kind).toBe("new"); - if (parsed == null || parsed.kind !== "new") { - throw new Error("Expected numeric one-line /new command to parse"); - } - - expect(parsed.workspaceName).toBe("feature-branch"); - expect(parsed.startMessage).toBe("start with step 1"); + it("captures the rest of the input as the start message", () => { + expect( + parseAcpSlashCommand("/new Start by summarizing the branch", mapSkillsByName(skills)) + ).toEqual({ + kind: "new", + startMessage: "Start by summarizing the branch", + }); }); - it("parses /new with numeric workspace name", () => { - const parsed = parseAcpSlashCommand("/new 123", mapSkillsByName(skills)); - - expect(parsed?.kind).toBe("new"); - if (parsed == null || parsed.kind !== "new") { - throw new Error("Expected numeric workspace name in /new to parse"); - } - - expect(parsed.workspaceName).toBe("123"); + it("preserves multiline start messages", () => { + expect(parseAcpSlashCommand("/new\nLine one\nLine two", mapSkillsByName(skills))).toEqual({ + kind: "new", + startMessage: "Line one\nLine two", + }); }); it("maps skill slash commands to formatted prompts", () => {