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
29 changes: 12 additions & 17 deletions mobile/src/utils/slashCommandRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,38 +218,37 @@ async function handleNew(
ctx: SlashCommandRunnerContext,
parsed: Extract<ParsedCommand, { type: "new" }>
): Promise<boolean> {
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");
return true;
}

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");
return true;
}

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,
});
}
Expand All @@ -263,12 +262,8 @@ async function handleNew(

async function resolveTrunkBranch(
ctx: SlashCommandRunnerContext,
projectPath: string,
explicit?: string
projectPath: string
): Promise<string> {
if (explicit) {
return explicit;
}
try {
const { recommendedTrunk, branches } = await ctx.client.projects.listBranches({ projectPath });
return recommendedTrunk ?? branches?.[0] ?? "main";
Expand Down
81 changes: 42 additions & 39 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,11 +750,21 @@ export function parseRuntimeString(
export interface CreateWorkspaceOptions {
client: RouterClient<AppRouter>;
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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<ParsedCommand, { type: "new" }>,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
108 changes: 15 additions & 93 deletions src/browser/utils/slashCommands/new.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
26 changes: 6 additions & 20 deletions src/browser/utils/slashCommands/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});

Expand Down
Loading
Loading