diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 3f7a001158..9f9a7ba366 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -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, 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..d9a632bcf4 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -25,7 +25,13 @@ 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 { PlatformPaths } from "@/common/utils/paths"; import { partitionWorkspacesByAge, @@ -47,13 +53,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 +326,52 @@ 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; +}) { + const { requestEdit, wrapGenerateTitle } = useTitleEdit(); + const { api } = useAPI(); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + 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(); + wrapGenerateTitle( + wsId, + () => api?.workspace.regenerateTitle({ workspaceId: wsId }) ?? Promise.resolve() + ); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + props.selectedWorkspace, + props.sortedWorkspacesByProject, + requestEdit, + wrapGenerateTitle, + api, + ]); + + return null; +} + interface ProjectSidebarProps { collapsed: boolean; onToggleCollapsed: () => void; @@ -344,7 +397,7 @@ const ProjectSidebarInner: React.FC = ({ setSelectedWorkspace: onSelectWorkspace, archiveWorkspace: onArchiveWorkspace, removeWorkspace, - renameWorkspace: onRenameWorkspace, + updateWorkspaceTitle: onUpdateTitle, refreshWorkspaceMetadata, pendingNewWorkspaceProject, pendingNewWorkspaceDraftId, @@ -356,6 +409,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 +521,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 +577,35 @@ const ProjectSidebarInner: React.FC = ({ } }; + const handleForkWorkspace = useCallback( + async (workspaceId: string, buttonElement?: HTMLElement) => { + if (!api) { + workspaceForkError.showError(workspaceId, "Not connected to server"); + return; + } + + const result = await forkWorkspace({ + client: api, + sourceWorkspaceId: workspaceId, + }); + if (result.success) { + return; + } + + let anchor: { top: number; left: number } | undefined; + if (buttonElement) { + const rect = buttonElement.getBoundingClientRect(); + anchor = { + top: rect.top + window.scrollY, + left: rect.right + 10, + }; + } + + workspaceForkError.showError(workspaceId, result.error ?? "Failed to fork chat", anchor); + }, + [api, workspaceForkError] + ); + const performArchiveWorkspace = useCallback( async (workspaceId: string, buttonElement?: HTMLElement) => { // Mark workspace as being archived for UI feedback @@ -740,7 +824,11 @@ const ProjectSidebarInner: React.FC = ({ }, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace]); return ( - + + @@ -1011,6 +1099,7 @@ const ProjectSidebarInner: React.FC = ({ metadata.isRemoving === true } onSelectWorkspace={handleSelectWorkspace} + onForkWorkspace={handleForkWorkspace} onArchiveWorkspace={handleArchiveWorkspace} onCancelCreation={handleCancelWorkspaceCreation} depth={depthByWorkspaceId[metadata.id] ?? 0} @@ -1377,6 +1466,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 8946c20e91..ea587d222e 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", @@ -98,6 +100,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..4d25ba76b6 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, + generatingTitleWorkspaceId, + wrapGenerateTitle, + } = useTitleEdit(); + const isGeneratingTitle = generatingTitleWorkspaceId === workspaceId; + const { api } = useAPI(); // Local state for title editing const [editingTitle, setEditingTitle] = useState(""); @@ -407,7 +419,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 +431,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 +440,7 @@ function RegularWorkspaceListItemInner(props: WorkspaceListItemProps) { }; const handleCancelEdit = () => { - cancelRename(); + cancelEdit(); setEditingTitle(""); setTitleError(null); }; @@ -695,9 +707,48 @@ 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 && (