diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx
index 5be82be0af9..6290d3f8a82 100644
--- a/ui/src/components/NewIssueDialog.test.tsx
+++ b/ui/src/components/NewIssueDialog.test.tsx
@@ -14,6 +14,7 @@ const dialogState = vi.hoisted(() => ({
}));
const dialogContentState = vi.hoisted(() => ({
+ onEscapeKeyDown: null as null | ((event: KeyboardEvent) => void),
onPointerDownOutside: null as null | ((event: {
detail: { originalEvent: { target: EventTarget | null } };
preventDefault: () => void;
@@ -193,14 +194,15 @@ vi.mock("@/components/ui/dialog", () => ({
DialogContent: ({
children,
showCloseButton: _showCloseButton,
- onEscapeKeyDown: _onEscapeKeyDown,
+ onEscapeKeyDown,
onPointerDownOutside,
...props
}: ComponentProps<"div"> & {
showCloseButton?: boolean;
- onEscapeKeyDown?: (event: unknown) => void;
+ onEscapeKeyDown?: (event: KeyboardEvent) => void;
onPointerDownOutside?: (event: unknown) => void;
}) => {
+ dialogContentState.onEscapeKeyDown = onEscapeKeyDown ?? null;
dialogContentState.onPointerDownOutside = onPointerDownOutside as typeof dialogContentState.onPointerDownOutside;
return
{children}
;
},
@@ -315,6 +317,7 @@ describe("NewIssueDialog", () => {
dialogState.newIssueOpen = true;
dialogState.newIssueDefaults = {};
dialogState.closeNewIssue.mockReset();
+ dialogContentState.onEscapeKeyDown = null;
dialogContentState.onPointerDownOutside = null;
toastState.pushToast.mockReset();
mockIssuesApi.create.mockReset();
@@ -849,7 +852,7 @@ describe("NewIssueDialog", () => {
await act(async () => {
modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
bubbles: true,
- code: "Period",
+ code: "",
key: ".",
metaKey: true,
}));
@@ -879,6 +882,63 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
+ it("cycles work modes when iOS reports cmd-period as Escape", async () => {
+ const { root } = renderDialog(container);
+ await flush();
+
+ const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
+ expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
+ expect(dialogContentState.onEscapeKeyDown).not.toBeNull();
+
+ const commandPeriodAsEscape = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Escape",
+ metaKey: true,
+ });
+ await act(async () => {
+ dialogContentState.onEscapeKeyDown?.(commandPeriodAsEscape);
+ });
+
+ expect(commandPeriodAsEscape.defaultPrevented).toBe(true);
+ expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
+ expect(dialogState.closeNewIssue).not.toHaveBeenCalled();
+
+ const plainEscape = new KeyboardEvent("keydown", {
+ bubbles: true,
+ cancelable: true,
+ key: "Escape",
+ });
+ await act(async () => {
+ dialogContentState.onEscapeKeyDown?.(plainEscape);
+ });
+
+ expect(plainEscape.defaultPrevented).toBe(false);
+ expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
+
+ act(() => root.unmount());
+ });
+
+ it("cycles work modes with ctrl-period", async () => {
+ const { root } = renderDialog(container);
+ await flush();
+
+ const modeChip = () => container.querySelector("[data-issue-work-mode-chip]");
+ expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("standard");
+
+ await act(async () => {
+ modeChip()?.dispatchEvent(new KeyboardEvent("keydown", {
+ bubbles: true,
+ code: "Period",
+ key: ".",
+ ctrlKey: true,
+ }));
+ });
+ expect(modeChip()?.getAttribute("data-issue-work-mode-chip")).toBe("planning");
+
+ act(() => root.unmount());
+ });
+
it("submits the parent assignee when a sub-issue opens with inherited defaults", async () => {
dialogState.newIssueDefaults = {
parentId: "issue-1",
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx
index bcbda2d457d..96f0d938576 100644
--- a/ui/src/components/NewIssueDialog.tsx
+++ b/ui/src/components/NewIssueDialog.tsx
@@ -268,6 +268,15 @@ function defaultExecutionWorkspaceModeForIssueDefaults(
: defaultExecutionWorkspaceModeForProject(project);
}
+function isWorkModePeriodShortcut(e: Pick) {
+ const isPeriod = e.code === "Period" || e.key === ".";
+ return (e.metaKey || e.ctrlKey) && isPeriod;
+}
+
+function isWorkModeEscapeShortcut(e: Pick) {
+ return (e.metaKey || e.ctrlKey) && e.key === "Escape";
+}
+
const IssueTitleTextarea = memo(function IssueTitleTextarea({
value,
pending,
@@ -1006,7 +1015,7 @@ export function NewIssueDialog() {
}
function handleKeyDown(e: React.KeyboardEvent) {
- if ((e.metaKey || e.ctrlKey) && e.code === "Period") {
+ if (isWorkModePeriodShortcut(e)) {
e.preventDefault();
setWorkMode((current) => nextWorkMode(current));
return;
@@ -1236,6 +1245,15 @@ export function NewIssueDialog() {
)}
onKeyDown={handleKeyDown}
onEscapeKeyDown={(event) => {
+ if (event.defaultPrevented) return;
+ // iOS Safari maps command-period to Escape for hardware keyboards.
+ // Treat modifier-Escape as the same mode-cycle shortcut so the
+ // dialog does not dismiss before the shortcut can run.
+ if (isWorkModeEscapeShortcut(event)) {
+ event.preventDefault();
+ setWorkMode((current) => nextWorkMode(current));
+ return;
+ }
if (createIssue.isPending) {
event.preventDefault();
}