Skip to content

Commit 1148d6d

Browse files
ammar-agentammario
andauthored
🤖 Add /fork command for workspace cloning (#291)
Adds `/fork <new-name>` slash command to clone workspaces during development, enabling quick exploration of alternative approaches without losing the current context. ## What Changed **Command syntax:** ``` /fork new-workspace-name Optional multiline start message ``` **Clones entire workspace:** - Git worktree with independent branch - Chat history (committed messages only) - UI preferences (model, mode, thinking level, etc.) **Seamless mid-stream forking:** If a stream is active when forking, the partial response is automatically committed to history first. Both workspaces get the response content up to the fork point, then continue independently. ## Implementation **Backend (`src/services/ipcMain.ts`):** - Validates workspace name - Auto-commits streaming responses before fork - Creates git worktree from current branch (not trunk) - Copies `chat.jsonl`, skips `partial.json` - Generates stable workspace ID (10-char hex) **Frontend (`src/components/ChatInput.tsx`):** - Parses `/fork` command with optional start message - Copies workspace storage to new workspace - Switches UI to forked workspace - Optionally sends start message after 300ms **Type safety:** - Imports `WorkspaceMetadata` in IPC layer - Uses `metadata.name` for directory paths (not ID) ## Testing 6 integration tests covering: - Name validation (invalid formats rejected) - Successful workspace creation - Chat history copying - Config updates - Independent git branches - `partial.json` handling (not copied) All tests run concurrently for speed. ## Technical Notes **Stable IDs architecture:** Workspaces have separate ID (stable, 10-char hex) and name (mutable, user-facing). Directories use names, metadata uses IDs. Fork handler correctly uses `metadata.name` for path computation via `getWorkspacePath()`. **Auto-commit rationale:** `PartialService.commitToHistory()` is idempotent and file-locked, making it safe to call during streaming. This preserves work without forcing manual interruption (ESC → fork workflow). _Generated with `cmux`_ --------- Co-authored-by: Ammar Bandukwala <ammar@ammar.io>
1 parent 67a80d8 commit 1148d6d

File tree

21 files changed

+982
-215
lines changed

21 files changed

+982
-215
lines changed

docs/SUMMARY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# Features
1010

1111
- [Workspaces](./workspaces.md)
12+
- [Forking](./fork.md)
1213
- [Models](./models.md)
1314
- [Keyboard Shortcuts](./keybinds.md)
1415
- [Vim Mode](./vim-mode.md)

docs/fork.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Forking Workspaces
2+
3+
Use `/fork` to clone a workspace with its full conversation history and UI state. The forked workspace gets a new git worktree on a new branch.
4+
5+
Usage:
6+
7+
```
8+
/fork <new-workspace-name>
9+
10+
[start-message (optional)]
11+
```
12+
13+
## Use cases
14+
15+
- **Explore alternatives**: Fork mid-conversation to try a different implementation
16+
approach.
17+
- **Isolate tangents**: See an unrelated bug or opportunity in the course of a change? Fork to keep the main conversation on track.
18+
- **Create backup**: Fork to keep a copy of an old conversation before a risky change in direction.
19+
20+
## What happens when you fork
21+
22+
The new workspace:
23+
24+
- Appears at the top of the workspace list (most recent)
25+
- Gets a unique auto-generated name (customize via rename after creation)
26+
- Branches from the current workspace's HEAD commit
27+
28+
**What's copied**: Conversation history, model selection, thinking level, auto-retry setting, UI mode (plan/exec), chat input text
29+
30+
**What's not copied**: Uncommitted file changes (new branch starts from HEAD)

src/App.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour
2929

3030
import type { ThinkingLevel } from "./types/thinking";
3131
import { CUSTOM_EVENTS } from "./constants/events";
32+
import { isWorkspaceForkSwitchEvent } from "./utils/workspaceFork";
3233
import { getThinkingLevelKey } from "./constants/storage";
3334
import type { BranchListResult } from "./types/ipc";
3435
import { useTelemetry } from "./hooks/useTelemetry";
@@ -201,6 +202,7 @@ function AppInner() {
201202

202203
const {
203204
workspaceMetadata,
205+
setWorkspaceMetadata,
204206
loading: metadataLoading,
205207
createWorkspace,
206208
removeWorkspace,
@@ -727,6 +729,45 @@ function AppInner() {
727729
openCommandPalette,
728730
]);
729731

732+
// Handle workspace fork switch event
733+
useEffect(() => {
734+
const handleForkSwitch = (e: Event) => {
735+
if (!isWorkspaceForkSwitchEvent(e)) return;
736+
737+
const workspaceInfo = e.detail;
738+
739+
// Find the project in config
740+
const project = projects.get(workspaceInfo.projectPath);
741+
if (!project) {
742+
console.error(`Project not found for path: ${workspaceInfo.projectPath}`);
743+
return;
744+
}
745+
746+
// Update metadata Map immediately (don't wait for async metadata event)
747+
// This ensures the title bar effect has the workspace name available
748+
setWorkspaceMetadata((prev) => {
749+
const updated = new Map(prev);
750+
updated.set(workspaceInfo.id, workspaceInfo);
751+
return updated;
752+
});
753+
754+
// Switch to the new workspace
755+
setSelectedWorkspace({
756+
workspaceId: workspaceInfo.id,
757+
projectPath: workspaceInfo.projectPath,
758+
projectName: workspaceInfo.projectName,
759+
namedWorkspacePath: workspaceInfo.namedWorkspacePath,
760+
});
761+
};
762+
763+
window.addEventListener(CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH, handleForkSwitch as EventListener);
764+
return () =>
765+
window.removeEventListener(
766+
CUSTOM_EVENTS.WORKSPACE_FORK_SWITCH,
767+
handleForkSwitch as EventListener
768+
);
769+
}, [projects, setSelectedWorkspace, setWorkspaceMetadata]);
770+
730771
return (
731772
<>
732773
<GlobalColors />

src/components/ChatInput.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useMode } from "@/contexts/ModeContext";
1111
import { ChatToggles } from "./ChatToggles";
1212
import { useSendMessageOptions } from "@/hooks/useSendMessageOptions";
1313
import { getModelKey, getInputKey } from "@/constants/storage";
14+
import { forkWorkspace } from "@/utils/workspaceFork";
1415
import { ToggleGroup } from "./ToggleGroup";
1516
import { CUSTOM_EVENTS } from "@/constants/events";
1617
import type { UIMode } from "@/types/mode";
@@ -237,6 +238,26 @@ const createCommandToast = (parsed: ParsedCommand): Toast | null => {
237238
),
238239
};
239240

241+
case "fork-help":
242+
return {
243+
id: Date.now().toString(),
244+
type: "error",
245+
title: "Fork Command",
246+
message: "Fork current workspace with a new name",
247+
solution: (
248+
<>
249+
<SolutionLabel>Usage:</SolutionLabel>
250+
/fork &lt;new-name&gt; [optional start message]
251+
<br />
252+
<br />
253+
<SolutionLabel>Examples:</SolutionLabel>
254+
/fork experiment-branch
255+
<br />
256+
/fork refactor Continue with refactoring approach
257+
</>
258+
),
259+
};
260+
240261
case "unknown-command": {
241262
const cmd = "/" + parsed.command + (parsed.subcommand ? " " + parsed.subcommand : "");
242263
return {
@@ -738,6 +759,52 @@ export const ChatInput: React.FC<ChatInputProps> = ({
738759
return;
739760
}
740761

762+
// Handle /fork command
763+
if (parsed.type === "fork") {
764+
setInput(""); // Clear input immediately
765+
setIsSending(true);
766+
767+
try {
768+
const forkResult = await forkWorkspace({
769+
sourceWorkspaceId: workspaceId,
770+
newName: parsed.newName,
771+
startMessage: parsed.startMessage,
772+
sendMessageOptions,
773+
});
774+
775+
if (!forkResult.success) {
776+
const errorMsg = forkResult.error ?? "Failed to fork workspace";
777+
console.error("Failed to fork workspace:", errorMsg);
778+
setToast({
779+
id: Date.now().toString(),
780+
type: "error",
781+
title: "Fork Failed",
782+
message: errorMsg,
783+
});
784+
setInput(messageText); // Restore input on error
785+
} else {
786+
setToast({
787+
id: Date.now().toString(),
788+
type: "success",
789+
message: `Forked to workspace "${parsed.newName}"`,
790+
});
791+
}
792+
} catch (error) {
793+
const errorMsg = error instanceof Error ? error.message : "Failed to fork workspace";
794+
console.error("Fork error:", error);
795+
setToast({
796+
id: Date.now().toString(),
797+
type: "error",
798+
title: "Fork Failed",
799+
message: errorMsg,
800+
});
801+
setInput(messageText); // Restore input on error
802+
}
803+
804+
setIsSending(false);
805+
return;
806+
}
807+
741808
// Handle all other commands - show display toast
742809
const commandToast = createCommandToast(parsed);
743810
if (commandToast) {

src/constants/events.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export const CUSTOM_EVENTS = {
3434
* useResumeManager handles this idempotently - safe to emit multiple times
3535
*/
3636
RESUME_CHECK_REQUESTED: "cmux:resumeCheckRequested",
37+
38+
/**
39+
* Event to switch to a different workspace after fork
40+
* Detail: { workspaceId: string, projectPath: string, projectName: string, workspacePath: string, branch: string }
41+
*/
42+
WORKSPACE_FORK_SWITCH: "cmux:workspaceForkSwitch",
3743
} as const;
3844

3945
/**

src/constants/ipc-constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const IPC_CHANNELS = {
2424
WORKSPACE_CREATE: "workspace:create",
2525
WORKSPACE_REMOVE: "workspace:remove",
2626
WORKSPACE_RENAME: "workspace:rename",
27+
WORKSPACE_FORK: "workspace:fork",
2728
WORKSPACE_STREAM_META: "workspace:streamMeta",
2829
WORKSPACE_SEND_MESSAGE: "workspace:sendMessage",
2930
WORKSPACE_RESUME_STREAM: "workspace:resumeStream",

src/constants/storage.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,29 @@ export const USE_1M_CONTEXT_KEY = "use1MContext";
6969
export function getCompactContinueMessageKey(workspaceId: string): string {
7070
return `compactContinueMessage:${workspaceId}`;
7171
}
72+
73+
/**
74+
* Copy all workspace-specific localStorage keys from source to destination workspace
75+
* This includes: model, input, mode, thinking level, auto-retry, retry state
76+
*/
77+
export function copyWorkspaceStorage(sourceWorkspaceId: string, destWorkspaceId: string): void {
78+
// List of key-generating functions to copy
79+
// Note: We deliberately skip getCompactContinueMessageKey as it's ephemeral
80+
const keyFunctions: Array<(workspaceId: string) => string> = [
81+
getModelKey,
82+
getInputKey,
83+
getModeKey,
84+
getThinkingLevelKey,
85+
getAutoRetryKey,
86+
getRetryStateKey,
87+
];
88+
89+
for (const getKey of keyFunctions) {
90+
const sourceKey = getKey(sourceWorkspaceId);
91+
const destKey = getKey(destWorkspaceId);
92+
const value = localStorage.getItem(sourceKey);
93+
if (value !== null) {
94+
localStorage.setItem(destKey, value);
95+
}
96+
}
97+
}

src/git.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function listLocalBranches(projectPath: string): Promise<string[]>
2727
.sort((a, b) => a.localeCompare(b));
2828
}
2929

30-
async function getCurrentBranch(projectPath: string): Promise<string | null> {
30+
export async function getCurrentBranch(projectPath: string): Promise<string | null> {
3131
try {
3232
using proc = execAsync(`git -C "${projectPath}" rev-parse --abbrev-ref HEAD`);
3333
const { stdout } = await proc.result;

src/hooks/useWorkspaceManagement.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,25 @@ export function useWorkspaceManagement({
5555
(event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => {
5656
setWorkspaceMetadata((prev) => {
5757
const updated = new Map(prev);
58+
const isNewWorkspace = !prev.has(event.workspaceId) && event.metadata !== null;
59+
5860
if (event.metadata === null) {
5961
// Workspace deleted - remove from map
6062
updated.delete(event.workspaceId);
6163
} else {
6264
updated.set(event.workspaceId, event.metadata);
6365
}
66+
67+
// If this is a new workspace (e.g., from fork), reload projects
68+
// to ensure the sidebar shows the updated workspace list
69+
if (isNewWorkspace) {
70+
void (async () => {
71+
const projectsList = await window.api.projects.list();
72+
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
73+
onProjectsUpdate(loadedProjects);
74+
})();
75+
}
76+
6477
return updated;
6578
});
6679
}
@@ -69,7 +82,7 @@ export function useWorkspaceManagement({
6982
return () => {
7083
unsubscribe();
7184
};
72-
}, []);
85+
}, [onProjectsUpdate]);
7386

7487
const createWorkspace = async (projectPath: string, branchName: string, trunkBranch: string) => {
7588
console.assert(
@@ -164,6 +177,7 @@ export function useWorkspaceManagement({
164177

165178
return {
166179
workspaceMetadata,
180+
setWorkspaceMetadata,
167181
loading,
168182
createWorkspace,
169183
removeWorkspace,

src/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const api: IPCApi = {
5555
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_REMOVE, workspaceId, options),
5656
rename: (workspaceId: string, newName: string) =>
5757
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_RENAME, workspaceId, newName),
58+
fork: (sourceWorkspaceId: string, newName: string) =>
59+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_FORK, sourceWorkspaceId, newName),
5860
sendMessage: (workspaceId, message, options) =>
5961
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_SEND_MESSAGE, workspaceId, message, options),
6062
resumeStream: (workspaceId, options) =>

0 commit comments

Comments
 (0)