Skip to content
Open
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
22 changes: 16 additions & 6 deletions docs/workspaces/fork.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,25 @@ title: Forking Workspaces
description: Clone workspaces with conversation history to explore alternatives
---

Use `/fork` to clone a workspace with its full conversation history and UI state. The forked workspace gets a new workspace on a new branch (using the same backend as the current workspace).
Use `/fork` to clone a workspace with its full conversation history and UI state. Forking creates a new workspace on a new branch (using the same backend as the current workspace).

Usage:
You can fork either:

```
/fork <new-workspace-name>
- By running `/fork` in chat
- By clicking the **Fork** button on any completed assistant message

## Usage

[start-message (optional)]
```
/fork [new-workspace-name]
```

If you omit the name, mux will auto-generate one based on the current workspace:

- `<name>-fork`
- `<name>-fork-2`
- `<name>-fork-3`
- ...

## Use cases

Expand All @@ -24,7 +34,7 @@ Usage:
The new workspace:

- Appears at the top of the workspace list (most recent)
- Uses the provided workspace name (you can rename later)
- Uses the provided workspace name (or an auto-generated one; you can rename later)
- Branches from the current workspace's HEAD commit

**What's copied**: Conversation history, model selection, thinking level, auto-retry setting, UI mode (plan/exec), chat input text
Expand Down
14 changes: 0 additions & 14 deletions mobile/src/utils/slashCommandRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ export async function executeSlashCommand(
return true;
case "fork":
return handleFork(ctx, parsed);
case "fork-help":
ctx.showInfo(
"/fork",
"Usage: /fork <new-workspace-name>. Optionally add text on new lines to send as the first message."
);
return true;
case "new":
return handleNew(ctx, parsed);
case "truncate":
Expand Down Expand Up @@ -199,14 +193,6 @@ async function handleFork(

ctx.onNavigateToWorkspace(result.metadata.id);
ctx.showInfo("Fork", `Switched to ${result.metadata.name}`);

if (parsed.startMessage) {
await ctx.client.workspace.sendMessage({
workspaceId: result.metadata.id,
message: parsed.startMessage,
options: ctx.sendMessageOptions,
});
}
return true;
} catch (error) {
ctx.showError("Fork", getErrorMessage(error));
Expand Down
20 changes: 0 additions & 20 deletions src/browser/components/ChatInputToasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,6 @@ export const createCommandToast = (parsed: ParsedCommand): Toast | null => {
),
};

case "fork-help":
return {
id: Date.now().toString(),
type: "error",
title: "Fork Command",
message: "Fork current workspace with a new name",
solution: (
<>
<SolutionLabel>Usage:</SolutionLabel>
/fork &lt;new-name&gt; [optional start message]
<br />
<br />
<SolutionLabel>Examples:</SolutionLabel>
/fork experiment-branch
<br />
/fork refactor Continue with refactoring approach
</>
),
};

case "command-missing-args":
return {
id: Date.now().toString(),
Expand Down
42 changes: 41 additions & 1 deletion src/browser/components/Messages/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@ import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
import { useStartHere } from "@/browser/hooks/useStartHere";
import type { DisplayedMessage } from "@/common/types/message";
import { copyToClipboard } from "@/browser/utils/clipboard";
import { Clipboard, ClipboardCheck, FileText, ListStart, Moon, Package } from "lucide-react";
import {
Clipboard,
ClipboardCheck,
FileText,
GitFork,
ListStart,
Moon,
Package,
} from "lucide-react";
import { ShareMessagePopover } from "@/browser/components/ShareMessagePopover";
import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import { useAPI } from "@/browser/contexts/API";
import { forkWorkspace } from "@/browser/utils/chatCommands";
import { Button } from "../ui/button";
import React, { useState } from "react";
import { CompactingMessageContent } from "./CompactingMessageContent";
Expand All @@ -31,6 +41,8 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
clipboardWriteText = copyToClipboard,
}) => {
const [showRaw, setShowRaw] = useState(false);
const { api } = useAPI();
const [isForking, setIsForking] = useState(false);
const workspaceContext = useOptionalWorkspaceContext();

// Get workspace name from context for share filename
Expand Down Expand Up @@ -75,6 +87,34 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
tooltip: "Start a new context from this message and preserve earlier chat history",
icon: <ListStart />,
});
buttons.push({
label: "Fork",
onClick: () => {
if (!api || !workspaceId) return;

setIsForking(true);

void forkWorkspace({
client: api,
sourceWorkspaceId: workspaceId,
})
.then((result) => {
if (!result.success) {
console.error("Failed to fork workspace:", result.error);
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
console.error("Failed to fork workspace:", message);
})
.finally(() => {
setIsForking(false);
});
},
disabled: !api || !workspaceId || isForking,
tooltip: "Fork workspace into a new workspace",
icon: <GitFork />,
});
buttons.push({
label: "Share",
component: (
Expand Down
56 changes: 8 additions & 48 deletions src/browser/utils/chatCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@ const BUILT_IN_MODEL_SET = new Set<string>(Object.values(KNOWN_MODELS).map((mode
export interface ForkOptions {
client: RouterClient<AppRouter>;
sourceWorkspaceId: string;
newName: string;
startMessage?: string;
sendMessageOptions?: SendMessageOptions;
newName?: string;
}

export interface ForkResult {
Expand All @@ -74,10 +72,9 @@ export interface ForkResult {
}

/**
* Fork a workspace and switch to it
* Handles copying storage, dispatching switch event, and optionally sending start message
* Fork a workspace and switch to it.
*
* Caller is responsible for error handling, logging, and showing toasts
* Caller is responsible for error handling, logging, and showing toasts.
*/
export async function forkWorkspace(options: ForkOptions): Promise<ForkResult> {
const { client } = options;
Expand All @@ -93,37 +90,10 @@ export async function forkWorkspace(options: ForkOptions): Promise<ForkResult> {
// Copy UI state to the new workspace
copyWorkspaceStorage(options.sourceWorkspaceId, result.metadata.id);

// Get workspace info for switching
const workspaceInfo = await client.workspace.getInfo({ workspaceId: result.metadata.id });
if (!workspaceInfo) {
return { success: false, error: "Failed to get workspace info after fork" };
}

// Dispatch event to switch workspace
dispatchWorkspaceSwitch(workspaceInfo);
dispatchWorkspaceSwitch(result.metadata);

// If there's a start message, defer until React finishes rendering and WorkspaceStore subscribes
// Using requestAnimationFrame ensures we wait for:
// 1. React to process the workspace switch and update state
// 2. Effects to run (workspaceStore.syncWorkspaces in App.tsx)
// 3. WorkspaceStore to subscribe to the new workspace's IPC channel
const startMessage = options.startMessage;
const sendMessageOptions = options.sendMessageOptions;
if (startMessage && sendMessageOptions) {
requestAnimationFrame(() => {
client.workspace
.sendMessage({
workspaceId: result.metadata.id,
message: startMessage,
options: sendMessageOptions,
})
.catch(() => {
// Best-effort: the user can send the message manually if this fails.
});
});
}

return { success: true, workspaceInfo };
return { success: true, workspaceInfo: result.metadata };
}

export interface SlashCommandContext extends Omit<CommandHandlerContext, "workspaceId" | "api"> {
Expand Down Expand Up @@ -328,8 +298,6 @@ export async function processSlashCommand(
case "command-invalid-args":
case "unknown-command":
return parsed.command;
case "fork-help":
return "fork";
default:
return null;
}
Expand Down Expand Up @@ -489,14 +457,7 @@ async function handleForkCommand(
parsed: Extract<ParsedCommand, { type: "fork" }>,
context: SlashCommandContext
): Promise<CommandHandlerResult> {
const {
api: client,
workspaceId,
sendMessageOptions,
setInput,
setSendingState,
setToast,
} = context;
const { api: client, workspaceId, setInput, setSendingState, setToast } = context;

setInput(""); // Clear input immediately
setSendingState(true);
Expand All @@ -511,8 +472,6 @@ async function handleForkCommand(
client,
sourceWorkspaceId: workspaceId,
newName: parsed.newName,
startMessage: parsed.startMessage,
sendMessageOptions,
});

if (!forkResult.success) {
Expand All @@ -527,10 +486,11 @@ async function handleForkCommand(
return { clearInput: false, toastShown: true };
} else {
trackCommandUsed("fork");
const forkedName = forkResult.workspaceInfo?.name ?? parsed.newName;
setToast({
id: Date.now().toString(),
type: "success",
message: `Forked to workspace "${parsed.newName}"`,
message: forkedName ? `Forked to workspace "${forkedName}"` : "Forked workspace",
});
return { clearInput: true, toastShown: true };
}
Expand Down
29 changes: 8 additions & 21 deletions src/browser/utils/slashCommands/fork.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { parseCommand } from "./parser";

describe("/fork command", () => {
it("should parse /fork without arguments to show help", () => {
it("should parse /fork without arguments to fork with autogenerated name", () => {
const result = parseCommand("/fork");
expect(result).toEqual({
type: "fork-help",
type: "fork",
newName: undefined,
});
});

Expand All @@ -13,34 +14,22 @@ describe("/fork command", () => {
expect(result).toEqual({
type: "fork",
newName: "new-workspace",
startMessage: undefined,
});
});

it("should parse /fork with name and start message on same line", () => {
it("should ignore extra content after name (legacy continue message)", () => {
const result = parseCommand("/fork new-workspace Continue with feature X");
expect(result).toEqual({
type: "fork",
newName: "new-workspace",
startMessage: "Continue with feature X",
});
});

it("should parse /fork with name and multiline start message", () => {
it("should ignore multiline content after name (legacy continue message)", () => {
const result = parseCommand("/fork new-workspace\nContinue with feature X");
expect(result).toEqual({
type: "fork",
newName: "new-workspace",
startMessage: "Continue with feature X",
});
});

it("should prefer multiline content over same-line tokens", () => {
const result = parseCommand("/fork new-workspace same line\nMultiline content");
expect(result).toEqual({
type: "fork",
newName: "new-workspace",
startMessage: "Multiline content",
});
});

Expand All @@ -49,16 +38,14 @@ describe("/fork command", () => {
expect(result).toEqual({
type: "fork",
newName: "my workspace",
startMessage: undefined,
});
});

it("should handle multiline messages with multiple lines", () => {
const result = parseCommand("/fork new-workspace\nLine 1\nLine 2\nLine 3");
it("should ignore multiline content when no name is provided", () => {
const result = parseCommand("/fork\nContinue with feature X");
expect(result).toEqual({
type: "fork",
newName: "new-workspace",
startMessage: "Line 1\nLine 2\nLine 3",
newName: undefined,
});
});
});
8 changes: 3 additions & 5 deletions src/browser/utils/slashCommands/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,20 +147,18 @@ describe("commandParser", () => {
expectParse("/fork feature-branch", {
type: "fork",
newName: "feature-branch",
startMessage: undefined,
});
});

it("should parse /fork command with start message", () => {
it("should ignore extra content after name (legacy continue message)", () => {
expectParse("/fork feature-branch let's go", {
type: "fork",
newName: "feature-branch",
startMessage: "let's go",
});
});

it("should show /fork help when missing args", () => {
expectParse("/fork", { type: "fork-help" });
it("should parse /fork without a name", () => {
expectParse("/fork", { type: "fork", newName: undefined });
});
});
});
Expand Down
Loading
Loading