diff --git a/src/browser/App.tsx b/src/browser/App.tsx
index d99d1079f4..27885d39e5 100644
--- a/src/browser/App.tsx
+++ b/src/browser/App.tsx
@@ -17,7 +17,7 @@ import {
readPersistedState,
} from "./hooks/usePersistedState";
import { useResizableSidebar } from "./hooks/useResizableSidebar";
-import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds";
+import { matchesKeybind, KEYBINDS, isEditableElement } from "./utils/ui/keybinds";
import { handleLayoutSlotHotkeys } from "./utils/ui/layoutSlotHotkeys";
import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering";
import { getVisibleWorkspaceIds } from "./utils/ui/workspaceDomNav";
@@ -90,7 +90,7 @@ function AppInner() {
loading,
setWorkspaceMetadata,
removeWorkspace,
- renameWorkspace,
+ updateWorkspaceTitle,
refreshWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
@@ -555,9 +555,9 @@ function AppInner() {
[removeWorkspace]
);
- const renameWorkspaceFromPalette = useCallback(
- async (workspaceId: string, newName: string) => renameWorkspace(workspaceId, newName),
- [renameWorkspace]
+ const updateTitleFromPalette = useCallback(
+ async (workspaceId: string, newTitle: string) => updateWorkspaceTitle(workspaceId, newTitle),
+ [updateWorkspaceTitle]
);
const addProjectFromPalette = useCallback(() => {
@@ -594,7 +594,7 @@ function AppInner() {
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
onRemoveWorkspace: removeWorkspaceFromPalette,
- onRenameWorkspace: renameWorkspaceFromPalette,
+ onUpdateTitle: updateTitleFromPalette,
onAddProject: addProjectFromPalette,
onRemoveProject: removeProjectFromPalette,
onToggleSidebar: toggleSidebarFromPalette,
@@ -659,12 +659,26 @@ function AppInner() {
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.defaultPrevented) {
+ return;
+ }
+
if (matchesKeybind(e, KEYBINDS.NEXT_WORKSPACE)) {
e.preventDefault();
handleNavigateWorkspace("next");
} else if (matchesKeybind(e, KEYBINDS.PREV_WORKSPACE)) {
e.preventDefault();
handleNavigateWorkspace("prev");
+ } else if (
+ matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE_ALT) &&
+ !sidebarCollapsed &&
+ selectedWorkspace &&
+ selectedWorkspace.workspaceId !== MUX_HELP_CHAT_WORKSPACE_ID &&
+ !isEditableElement(e.target)
+ ) {
+ // F2 edits the selected workspace title in the expanded sidebar; skip
+ // command palette handling so both shortcuts don't fire.
+ return;
} else if (
matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE) ||
matchesKeybind(e, KEYBINDS.OPEN_COMMAND_PALETTE_ALT)
@@ -704,10 +718,12 @@ function AppInner() {
handleNavigateWorkspace,
handleOpenMuxChat,
setSidebarCollapsed,
+ sidebarCollapsed,
isCommandPaletteOpen,
closeCommandPalette,
openCommandPalette,
creationProjectPath,
+ selectedWorkspace,
openSettings,
navigate,
]);
diff --git a/src/browser/components/ChatInputToasts.tsx b/src/browser/components/ChatInputToasts.tsx
index 22f2cc2728..b7f1ef48d8 100644
--- a/src/browser/components/ChatInputToasts.tsx
+++ b/src/browser/components/ChatInputToasts.tsx
@@ -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: (
- <>
- Usage:
- /fork <new-name> [optional start message]
-
-
- Examples:
- /fork experiment-branch
-
- /fork refactor Continue with refactoring approach
- >
- ),
- };
-
case "command-missing-args":
return {
id: Date.now().toString(),
diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx
index e920c8ad5d..8319df452c 100644
--- a/src/browser/components/ProjectSidebar.tsx
+++ b/src/browser/components/ProjectSidebar.tsx
@@ -25,7 +25,14 @@ import {
reorderProjects,
normalizeOrder,
} from "@/common/utils/projectOrdering";
-import { matchesKeybind, formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
+import {
+ matchesKeybind,
+ formatKeybind,
+ isEditableElement,
+ KEYBINDS,
+} from "@/browser/utils/ui/keybinds";
+import { useAPI } from "@/browser/contexts/API";
+import { CUSTOM_EVENTS, type CustomEventType } from "@/common/constants/events";
import { PlatformPaths } from "@/common/utils/paths";
import {
partitionWorkspacesByAge,
@@ -47,13 +54,14 @@ import type { Secret } from "@/common/types/secrets";
import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem";
import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator";
-import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext";
+import { TitleEditProvider, useTitleEdit } from "@/browser/contexts/WorkspaceTitleEditContext";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { ChevronRight, CircleHelp, KeyRound } from "lucide-react";
import { MUX_HELP_CHAT_WORKSPACE_ID } from "@/common/constants/muxChat";
import { useWorkspaceActions } from "@/browser/contexts/WorkspaceContext";
import { useRouter } from "@/browser/contexts/RouterContext";
import { usePopoverError } from "@/browser/hooks/usePopoverError";
+import { forkWorkspace } from "@/browser/utils/chatCommands";
import { PopoverError } from "./PopoverError";
import { SectionHeader } from "./SectionHeader";
import { AddSectionButton } from "./AddSectionButton";
@@ -319,6 +327,86 @@ function MuxChatStatusIndicator() {
);
}
+/**
+ * Handles F2 (edit title) and Shift+F2 (generate new title) keybinds.
+ * Rendered inside TitleEditProvider so it can access useTitleEdit().
+ */
+function SidebarTitleEditKeybinds(props: {
+ selectedWorkspace: WorkspaceSelection | undefined;
+ sortedWorkspacesByProject: Map;
+ collapsed: boolean;
+}) {
+ const { requestEdit, wrapGenerateTitle } = useTitleEdit();
+ const { api } = useAPI();
+
+ const regenerateTitleForWorkspace = useCallback(
+ (workspaceId: string) => {
+ if (workspaceId === MUX_HELP_CHAT_WORKSPACE_ID) {
+ return;
+ }
+ wrapGenerateTitle(workspaceId, () => {
+ if (!api) {
+ return Promise.resolve({ success: false, error: "Not connected to server" });
+ }
+ return api.workspace.regenerateTitle({ workspaceId });
+ });
+ },
+ [wrapGenerateTitle, api]
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (props.collapsed) return;
+ if (!props.selectedWorkspace) return;
+ if (isEditableElement(e.target)) return;
+ const wsId = props.selectedWorkspace.workspaceId;
+ if (wsId === MUX_HELP_CHAT_WORKSPACE_ID) return;
+
+ if (matchesKeybind(e, KEYBINDS.EDIT_WORKSPACE_TITLE)) {
+ e.preventDefault();
+ const meta = props.sortedWorkspacesByProject
+ .get(props.selectedWorkspace.projectPath)
+ ?.find((m) => m.id === wsId);
+ const displayTitle = meta?.title ?? meta?.name ?? "";
+ requestEdit(wsId, displayTitle);
+ } else if (matchesKeybind(e, KEYBINDS.GENERATE_WORKSPACE_TITLE)) {
+ e.preventDefault();
+ regenerateTitleForWorkspace(wsId);
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [
+ props.collapsed,
+ props.selectedWorkspace,
+ props.sortedWorkspacesByProject,
+ requestEdit,
+ regenerateTitleForWorkspace,
+ ]);
+
+ useEffect(() => {
+ const handleGenerateTitleRequest: EventListener = (event) => {
+ const customEvent = event as CustomEventType<
+ typeof CUSTOM_EVENTS.WORKSPACE_GENERATE_TITLE_REQUESTED
+ >;
+ regenerateTitleForWorkspace(customEvent.detail.workspaceId);
+ };
+
+ window.addEventListener(
+ CUSTOM_EVENTS.WORKSPACE_GENERATE_TITLE_REQUESTED,
+ handleGenerateTitleRequest
+ );
+ return () => {
+ window.removeEventListener(
+ CUSTOM_EVENTS.WORKSPACE_GENERATE_TITLE_REQUESTED,
+ handleGenerateTitleRequest
+ );
+ };
+ }, [regenerateTitleForWorkspace]);
+
+ return null;
+}
+
interface ProjectSidebarProps {
collapsed: boolean;
onToggleCollapsed: () => void;
@@ -344,7 +432,7 @@ const ProjectSidebarInner: React.FC = ({
setSelectedWorkspace: onSelectWorkspace,
archiveWorkspace: onArchiveWorkspace,
removeWorkspace,
- renameWorkspace: onRenameWorkspace,
+ updateWorkspaceTitle: onUpdateTitle,
refreshWorkspaceMetadata,
pendingNewWorkspaceProject,
pendingNewWorkspaceDraftId,
@@ -356,6 +444,7 @@ const ProjectSidebarInner: React.FC = ({
} = useWorkspaceActions();
const workspaceStore = useWorkspaceStoreRaw();
const { navigateToProject } = useRouter();
+ const { api } = useAPI();
// Get project state and operations from context
const {
@@ -467,6 +556,7 @@ const ProjectSidebarInner: React.FC = ({
const [archivingWorkspaceIds, setArchivingWorkspaceIds] = useState>(new Set());
const [removingWorkspaceIds, setRemovingWorkspaceIds] = useState>(new Set());
const workspaceArchiveError = usePopoverError();
+ const workspaceForkError = usePopoverError();
const workspaceRemoveError = usePopoverError();
const [archiveConfirmation, setArchiveConfirmation] = useState<{
workspaceId: string;
@@ -522,6 +612,40 @@ const ProjectSidebarInner: React.FC = ({
}
};
+ const handleForkWorkspace = useCallback(
+ async (workspaceId: string, buttonElement?: HTMLElement) => {
+ if (!api) {
+ workspaceForkError.showError(workspaceId, "Not connected to server");
+ return;
+ }
+
+ let anchor: { top: number; left: number } | undefined;
+ if (buttonElement) {
+ const rect = buttonElement.getBoundingClientRect();
+ anchor = {
+ top: rect.top + window.scrollY,
+ left: rect.right + 10,
+ };
+ }
+
+ try {
+ const result = await forkWorkspace({
+ client: api,
+ sourceWorkspaceId: workspaceId,
+ });
+ if (result.success) {
+ return;
+ }
+ workspaceForkError.showError(workspaceId, result.error ?? "Failed to fork chat", anchor);
+ } catch (error) {
+ // IPC/transport failures throw instead of returning { success: false }
+ const message = error instanceof Error ? error.message : String(error);
+ workspaceForkError.showError(workspaceId, message, anchor);
+ }
+ },
+ [api, workspaceForkError]
+ );
+
const performArchiveWorkspace = useCallback(
async (workspaceId: string, buttonElement?: HTMLElement) => {
// Mark workspace as being archived for UI feedback
@@ -740,7 +864,12 @@ const ProjectSidebarInner: React.FC = ({
}, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace]);
return (
-
+
+
@@ -1011,6 +1140,7 @@ const ProjectSidebarInner: React.FC = ({
metadata.isRemoving === true
}
onSelectWorkspace={handleSelectWorkspace}
+ onForkWorkspace={handleForkWorkspace}
onArchiveWorkspace={handleArchiveWorkspace}
onCancelCreation={handleCancelWorkspaceCreation}
depth={depthByWorkspaceId[metadata.id] ?? 0}
@@ -1377,6 +1507,11 @@ const ProjectSidebarInner: React.FC = ({
prefix="Failed to archive chat"
onDismiss={workspaceArchiveError.clearError}
/>
+
= ({
/>
-
+
);
};
diff --git a/src/browser/components/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx
index 67743f1e05..025778738b 100644
--- a/src/browser/components/Settings/sections/KeybindsSection.tsx
+++ b/src/browser/components/Settings/sections/KeybindsSection.tsx
@@ -17,6 +17,8 @@ const KEYBIND_LABELS: Record = {
FOCUS_INPUT_I: "Focus input (i)",
FOCUS_INPUT_A: "Focus input (a)",
NEW_WORKSPACE: "New workspace",
+ EDIT_WORKSPACE_TITLE: "Edit workspace title",
+ GENERATE_WORKSPACE_TITLE: "Generate new title",
ARCHIVE_WORKSPACE: "Archive workspace",
JUMP_TO_BOTTOM: "Jump to bottom",
NEXT_WORKSPACE: "Next workspace",
@@ -99,6 +101,8 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array
label: "Navigation",
keys: [
"NEW_WORKSPACE",
+ "EDIT_WORKSPACE_TITLE",
+ "GENERATE_WORKSPACE_TITLE",
"ARCHIVE_WORKSPACE",
"NEXT_WORKSPACE",
"PREV_WORKSPACE",
diff --git a/src/browser/components/WorkspaceListItem.tsx b/src/browser/components/WorkspaceListItem.tsx
index 9eea57ddd6..d76d682c52 100644
--- a/src/browser/components/WorkspaceListItem.tsx
+++ b/src/browser/components/WorkspaceListItem.tsx
@@ -1,4 +1,4 @@
-import { useRename } from "@/browser/contexts/WorkspaceRenameContext";
+import { useTitleEdit } from "@/browser/contexts/WorkspaceTitleEditContext";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import { cn } from "@/common/lib/utils";
import { useGitStatus } from "@/browser/stores/GitStatusStore";
@@ -16,7 +16,7 @@ import { WorkspaceHoverPreview } from "./WorkspaceHoverPreview";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "./ui/hover-card";
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from "./ui/popover";
-import { Pencil, Trash2, Ellipsis, Loader2, Link2 } from "lucide-react";
+import { Pencil, Trash2, Ellipsis, Loader2, Link2, Sparkles, GitBranch } from "lucide-react";
import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator";
import { Shimmer } from "./ai-elements/shimmer";
import { ArchiveIcon } from "./icons/ArchiveIcon";
@@ -24,6 +24,7 @@ import { WORKSPACE_DRAG_TYPE, type WorkspaceDragItem } from "./WorkspaceSectionD
import { useLinkSharingEnabled } from "@/browser/contexts/TelemetryEnabledContext";
import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds";
import { ShareTranscriptDialog } from "./ShareTranscriptDialog";
+import { useAPI } from "@/browser/contexts/API";
const RADIX_PORTAL_WRAPPER_SELECTOR = "[data-radix-popper-content-wrapper]" as const;
@@ -75,6 +76,7 @@ export interface WorkspaceListItemProps extends WorkspaceListItemBaseProps {
/** Section ID this workspace belongs to (for drag-drop targeting) */
sectionId?: string;
onSelectWorkspace: (selection: WorkspaceSelection) => void;
+ onForkWorkspace: (workspaceId: string, button: HTMLElement) => Promise;
onArchiveWorkspace: (workspaceId: string, button: HTMLElement) => Promise;
onCancelCreation: (workspaceId: string) => Promise;
}
@@ -348,6 +350,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
depth,
sectionId,
onSelectWorkspace,
+ onForkWorkspace,
onArchiveWorkspace,
onCancelCreation,
} = props;
@@ -362,8 +365,17 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
const { isUnread } = useWorkspaceUnread(workspaceId);
const gitStatus = useGitStatus(workspaceId);
- // Get title edit context (renamed from rename context since we now edit titles, not names)
- const { editingWorkspaceId, requestRename, confirmRename, cancelRename } = useRename();
+ // Get title edit context — manages inline title editing state across the sidebar
+ const {
+ editingWorkspaceId,
+ requestEdit,
+ confirmEdit,
+ cancelEdit,
+ generatingTitleWorkspaceIds,
+ wrapGenerateTitle,
+ } = useTitleEdit();
+ const isGeneratingTitle = generatingTitleWorkspaceIds.has(workspaceId);
+ const { api } = useAPI();
// Local state for title editing
const [editingTitle, setEditingTitle] = useState("");
@@ -394,6 +406,15 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
}
}, [isEditing]);
+ const wasEditingRef = useRef(false);
+ useEffect(() => {
+ if (isEditing && !wasEditingRef.current) {
+ setEditingTitle(displayTitle);
+ setTitleError(null);
+ }
+ wasEditingRef.current = isEditing;
+ }, [isEditing, displayTitle]);
+
// Clean up long-press timer on unmount
useEffect(() => {
return () => {
@@ -407,7 +428,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
// so it works even when the sidebar is collapsed and list items are unmounted.
const startEditing = () => {
- if (requestRename(workspaceId, displayTitle)) {
+ if (requestEdit(workspaceId, displayTitle)) {
setEditingTitle(displayTitle);
setTitleError(null);
}
@@ -419,7 +440,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
return;
}
- const result = await confirmRename(workspaceId, editingTitle);
+ const result = await confirmEdit(workspaceId, editingTitle);
if (!result.success) {
setTitleError(result.error ?? "Failed to update title");
} else {
@@ -428,7 +449,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
};
const handleCancelEdit = () => {
- cancelRename();
+ cancelEdit();
setEditingTitle("");
setTitleError(null);
};
@@ -695,9 +716,53 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) {
>
- Edit chat title
+ Edit chat title{" "}
+
+ ({formatKeybind(KEYBINDS.EDIT_WORKSPACE_TITLE)})
+
+
+
+
+ {!isMuxHelpChat && (
+ // Expose fork in the row menu so users can quickly branch off a chat
+ // without needing to remember the /fork slash command.
+
+ )}
{/* Share transcript link (gated on telemetry/link-sharing being enabled). */}
{linkSharingEnabled === true && !isMuxHelpChat && (