diff --git a/.server-changes/esc-to-close-fixes.md b/.server-changes/esc-to-close-fixes.md
new file mode 100644
index 00000000000..70e464ba106
--- /dev/null
+++ b/.server-changes/esc-to-close-fixes.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: fix
+---
+
+Esc now closes the authenticator app setup window and the chat panel, and side sheets now show a close button with their Esc shortcut. Esc in the chat panel's message box just leaves the box, so an unsent message survives and a second Esc closes the panel. Closing a dialog with Esc no longer also closes the panel behind it, and the Vercel setup modal no longer shows a close button that does nothing.
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
index 308a87ebab1..32f56c11a27 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx
@@ -31,6 +31,11 @@ export function DashboardAgentComposer({
e.preventDefault();
onSubmit();
}
+ // Esc leaves the composer, so a second Esc closes the panel and an
+ // unsent message survives the first one.
+ if (e.key === "Escape") {
+ e.currentTarget.blur();
+ }
}}
placeholder="Type a message…"
className={cn(
diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
index 11d486bc1d3..fb125aedd7a 100644
--- a/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
+++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx
@@ -1,4 +1,5 @@
import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { Button } from "~/components/primitives/Buttons";
import { cn } from "~/utils/cn";
export function DashboardAgentHeader({
@@ -23,7 +24,15 @@ export function DashboardAgentHeader({
onClick={onToggleHistory}
active={view === "history"}
/>
-
+
);
diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
index b126e77b34b..1b4c370ca83 100644
--- a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
+++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx
@@ -7,7 +7,6 @@ import { useFetcher, useNavigate } from "@remix-run/react";
import { SlackIcon } from "@trigger.dev/companyicons";
import { Fragment, useEffect, useRef, useState } from "react";
import { z } from "zod";
-import { ExitIcon } from "~/assets/icons/ExitIcon";
import { InlineCode } from "~/components/code/InlineCode";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout, variantClasses } from "~/components/primitives/Callout";
@@ -116,18 +115,10 @@ export function ConfigureErrorAlerts({
return (
diff --git a/apps/webapp/app/hooks/useShortcutKeys.tsx b/apps/webapp/app/hooks/useShortcutKeys.tsx
index 319a91cad84..d94db224f3d 100644
--- a/apps/webapp/app/hooks/useShortcutKeys.tsx
+++ b/apps/webapp/app/hooks/useShortcutKeys.tsx
@@ -1,3 +1,4 @@
+import { type RefObject } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useOperatingSystem } from "~/components/primitives/OperatingSystemProvider";
import { useShortcuts } from "~/components/primitives/ShortcutsProvider";
@@ -22,14 +23,46 @@ type useShortcutKeysProps = {
shortcut: ShortcutDefinition | undefined;
action: (event: KeyboardEvent) => void;
disabled?: boolean;
+ /**
+ * Shortcuts are registered on `document`, so this fires the shortcut from every
+ * input on the page, not just the ones near it. Don't set it on an Escape
+ * close shortcut: let the focused field handle Escape and close on the second press.
+ */
enabledOnInputElements?: boolean;
+ /**
+ * The element this shortcut belongs to. When set, an Escape shortcut is ignored
+ * while the element sits behind an open overlay, so one Escape can't close both
+ * a dialog and the panel behind it. Other shortcuts are unaffected.
+ */
+ elementRef?: RefObject;
};
+/** Layered surfaces that own the keyboard while they're open. */
+const OVERLAY_ROLES = '[role="dialog"],[role="alertdialog"],[role="listbox"],[role="menu"]';
+
+const ESCAPE_KEYS = ["esc", "escape"];
+
+function isEscapeShortcut(shortcut: Shortcut | undefined) {
+ return !!shortcut && ESCAPE_KEYS.includes(shortcut.key.toLowerCase());
+}
+
+function isBlockedByOverlay(event: KeyboardEvent, element: HTMLElement | null) {
+ // Radix marks everything outside an open modal `aria-hidden`, which covers
+ // modals that don't move focus into themselves.
+ if (element?.closest('[aria-hidden="true"]')) return true;
+
+ const target = event.target instanceof Element ? event.target : null;
+ const overlay = target?.closest(OVERLAY_ROLES);
+
+ return !!overlay && (!element || !overlay.contains(element));
+}
+
export function useShortcutKeys({
shortcut,
action,
disabled = false,
enabledOnInputElements,
+ elementRef,
}: useShortcutKeysProps) {
const { platform } = useOperatingSystem();
const { areShortcutsEnabled } = useShortcuts();
@@ -40,13 +73,17 @@ export function useShortcutKeys({
const keys = createKeysFromShortcut(relevantShortcut);
const isEnabled = !disabled && areShortcutsEnabled && relevantShortcut?.enabled !== false;
+ const guardAgainstOverlays = isEscapeShortcut(relevantShortcut);
useHotkeys(
keys,
(event) => {
- if (!event.repeat) {
- action(event);
+ if (event.repeat) return;
+ if (guardAgainstOverlays && elementRef && isBlockedByOverlay(event, elementRef.current)) {
+ return;
}
+
+ action(event);
},
{
enabled: isEnabled,
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
index 0a1b448564f..f65507e8dfa 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
@@ -569,6 +569,8 @@ export default function Page() {
{editorProps && (
0) {
+ // Deliberately not dismissible: MFA is already enabled at this point and the
+ // codes are shown once, so the only way out is confirming they're saved.
return (