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
28 changes: 22 additions & 6 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,7 +90,7 @@ function AppInner() {
loading,
setWorkspaceMetadata,
removeWorkspace,
renameWorkspace,
updateWorkspaceTitle,
refreshWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -594,7 +594,7 @@ function AppInner() {
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
onRemoveWorkspace: removeWorkspaceFromPalette,
onRenameWorkspace: renameWorkspaceFromPalette,
onUpdateTitle: updateTitleFromPalette,
onAddProject: addProjectFromPalette,
onRemoveProject: removeProjectFromPalette,
onToggleSidebar: toggleSidebarFromPalette,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -704,10 +718,12 @@ function AppInner() {
handleNavigateWorkspace,
handleOpenMuxChat,
setSidebarCollapsed,
sidebarCollapsed,
isCommandPaletteOpen,
closeCommandPalette,
openCommandPalette,
creationProjectPath,
selectedWorkspace,
openSettings,
navigate,
]);
Expand Down
6 changes: 6 additions & 0 deletions src/browser/components/AppLoader.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { cleanup, render } from "@testing-library/react";
import { useTheme } from "../contexts/ThemeContext";
import { installDom } from "../../../tests/ui/dom";

// tests/ui/dom bootstraps only once per module cache. Re-install a baseline DOM here
// in case a previous test suite tore globals down to undefined before this file loads.
if (typeof globalThis.document === "undefined") {
installDom();
}

let cleanupDom: (() => void) | null = null;

let apiStatus: "auth_required" | "connecting" | "error" = "auth_required";
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
108 changes: 103 additions & 5 deletions src/browser/components/ProjectSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -319,6 +326,55 @@ 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<string, FrontendWorkspaceMetadata[]>;
collapsed: boolean;
}) {
const { requestEdit, wrapGenerateTitle } = useTitleEdit();
const { api } = useAPI();

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();
wrapGenerateTitle(
wsId,
() => api?.workspace.regenerateTitle({ workspaceId: wsId }) ?? Promise.resolve()
);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
props.collapsed,
props.selectedWorkspace,
props.sortedWorkspacesByProject,
requestEdit,
wrapGenerateTitle,
api,
]);

return null;
}

interface ProjectSidebarProps {
collapsed: boolean;
onToggleCollapsed: () => void;
Expand All @@ -344,7 +400,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
setSelectedWorkspace: onSelectWorkspace,
archiveWorkspace: onArchiveWorkspace,
removeWorkspace,
renameWorkspace: onRenameWorkspace,
updateWorkspaceTitle: onUpdateTitle,
refreshWorkspaceMetadata,
pendingNewWorkspaceProject,
pendingNewWorkspaceDraftId,
Expand All @@ -356,6 +412,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
} = useWorkspaceActions();
const workspaceStore = useWorkspaceStoreRaw();
const { navigateToProject } = useRouter();
const { api } = useAPI();

// Get project state and operations from context
const {
Expand Down Expand Up @@ -467,6 +524,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
const [archivingWorkspaceIds, setArchivingWorkspaceIds] = useState<Set<string>>(new Set());
const [removingWorkspaceIds, setRemovingWorkspaceIds] = useState<Set<string>>(new Set());
const workspaceArchiveError = usePopoverError();
const workspaceForkError = usePopoverError();
const workspaceRemoveError = usePopoverError();
const [archiveConfirmation, setArchiveConfirmation] = useState<{
workspaceId: string;
Expand Down Expand Up @@ -522,6 +580,35 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
}
};

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
Expand Down Expand Up @@ -740,7 +827,12 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
}, [selectedWorkspace, handleAddWorkspace, handleArchiveWorkspace]);

return (
<RenameProvider onRenameWorkspace={onRenameWorkspace}>
<TitleEditProvider onUpdateTitle={onUpdateTitle}>
<SidebarTitleEditKeybinds
selectedWorkspace={selectedWorkspace ?? undefined}
sortedWorkspacesByProject={sortedWorkspacesByProject}
collapsed={collapsed}
/>
<DndProvider backend={HTML5Backend}>
<ProjectDragLayer />
<WorkspaceDragLayer />
Expand Down Expand Up @@ -1011,6 +1103,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
metadata.isRemoving === true
}
onSelectWorkspace={handleSelectWorkspace}
onForkWorkspace={handleForkWorkspace}
onArchiveWorkspace={handleArchiveWorkspace}
onCancelCreation={handleCancelWorkspaceCreation}
depth={depthByWorkspaceId[metadata.id] ?? 0}
Expand Down Expand Up @@ -1377,6 +1470,11 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
prefix="Failed to archive chat"
onDismiss={workspaceArchiveError.clearError}
/>
<PopoverError
error={workspaceForkError.error}
prefix="Failed to fork chat"
onDismiss={workspaceForkError.clearError}
/>
<PopoverError
error={workspaceRemoveError.error}
prefix="Failed to cancel workspace creation"
Expand All @@ -1394,7 +1492,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
/>
</div>
</DndProvider>
</RenameProvider>
</TitleEditProvider>
);
};

Expand Down
14 changes: 1 addition & 13 deletions src/browser/components/RightSidebar/PlanFileDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,6 @@ void mock.module("@/browser/components/ui/dialog", () => ({
),
}));

void mock.module("@/browser/components/Messages/MarkdownCore", () => ({
MarkdownCore: (props: { content: string }) => (
<div data-testid="plan-markdown-core">{props.content}</div>
),
}));

void mock.module("@/browser/components/Messages/MarkdownRenderer", () => ({
PlanMarkdownContainer: (props: { children: ReactNode }) => (
<div data-testid="plan-markdown-container">{props.children}</div>
),
}));

void mock.module("@/browser/contexts/API", () => ({
useAPI: () => ({
api: mockApi,
Expand Down Expand Up @@ -101,7 +89,7 @@ describe("PlanFileDialog", () => {
});

await waitFor(() => {
expect(view.getByTestId("plan-markdown-core").textContent).toContain("# Plan title");
expect(view.getByRole("heading", { name: "Plan title", level: 1 })).toBeTruthy();
});

expect(view.getByText("/tmp/plan.md")).toBeTruthy();
Expand Down
4 changes: 4 additions & 0 deletions src/browser/components/Settings/sections/KeybindsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const KEYBIND_LABELS: Record<keyof typeof KEYBINDS, string> = {
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",
Expand Down Expand Up @@ -99,6 +101,8 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array<keyof typeof KEYBINDS>
label: "Navigation",
keys: [
"NEW_WORKSPACE",
"EDIT_WORKSPACE_TITLE",
"GENERATE_WORKSPACE_TITLE",
"ARCHIVE_WORKSPACE",
"NEXT_WORKSPACE",
"PREV_WORKSPACE",
Expand Down
Loading
Loading