Skip to content
Closed
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
15 changes: 14 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,10 @@ function shouldRedirectInputToComposer(event: Event): boolean {
return true;
}

/**
* Determines whether an unhandled single-character keypress should redirect
* focus to the thread composer, skipping shortcuts owned by active surfaces.
*/
function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
if (event.isComposing) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
Expand All @@ -695,7 +699,15 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
const launcherKeys = document
.querySelector("[data-surface-launcher-keys]")
?.getAttribute("data-surface-launcher-keys");
if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false;
if (launcherKeys) {
const layoutKey = event.key.toLowerCase();
const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase();
const normalizedKeys = launcherKeys.toLowerCase();
if (normalizedKeys.includes(layoutKey)) return false;
if (letterCode && !/^[a-z]$/.test(layoutKey) && normalizedKeys.includes(letterCode)) {
return false;
}
}

return true;
}
Expand Down Expand Up @@ -10382,6 +10394,7 @@ export default function ChatView(props: ChatViewProps) {
>
<RightPanelTabs
mode="sheet"
open={rightPanelOpen}
// Same effective inset as the closed-state titlebar controls
// (pr-3 in the tab bar plus this pixel equals the absolute
// right inset plus mr-px), so the cluster does not creep when
Expand Down
135 changes: 116 additions & 19 deletions apps/web/src/components/ConfirmDialogHost.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useEffect, useSyncExternalStore } from "react";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import type { ConfirmDialogCheckboxOptions, ConfirmDialogVariant } from "@t3tools/contracts";

import {
completeConfirmDialogClose,
Expand All @@ -17,12 +24,16 @@ import {
AlertDialogTitle,
} from "./ui/alert-dialog";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";

type ConfirmationCopy = {
readonly title: string;
readonly description: string | null;
};

/**
* Splits a multiline confirmation message into a concise title and description.
*/
function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
const normalizedMessage = message.trim();
const lines = normalizedMessage.split("\n");
Expand Down Expand Up @@ -51,6 +62,99 @@ function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
};
}

type ConfirmDialogBodyProps = {
readonly message: string;
readonly variant: ConfirmDialogVariant;
readonly checkbox?: ConfirmDialogCheckboxOptions | undefined;
readonly onConfirm: () => void;
};

/**
* Dialog content body with its own local checkbox state and keyboard arrow navigation.
*/
function ConfirmDialogBody({ message, variant, checkbox, onConfirm }: ConfirmDialogBodyProps) {
const confirmButtonRef = useRef<HTMLButtonElement | null>(null);
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
const checkboxRef = useRef<HTMLButtonElement | null>(null);

const [checkboxChecked, setCheckboxChecked] = useState(checkbox?.checked ?? false);

const copy = resolveConfirmDialogCopy(message);

const handleKeyDown = (event: ReactKeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
if (target === confirmButtonRef.current) {
event.preventDefault();
cancelButtonRef.current?.focus();
} else if (target === cancelButtonRef.current) {
event.preventDefault();
confirmButtonRef.current?.focus();
}
} else if (event.key === "ArrowUp") {
if (
checkboxRef.current &&
(target === confirmButtonRef.current || target === cancelButtonRef.current)
) {
event.preventDefault();
checkboxRef.current.focus();
}
} else if (event.key === "ArrowDown") {
if (checkboxRef.current && target === checkboxRef.current) {
event.preventDefault();
confirmButtonRef.current?.focus();
}
}
};

return (
<AlertDialogPopup initialFocus={confirmButtonRef}>
<AlertDialogHeader>
<AlertDialogTitle>{copy.title}</AlertDialogTitle>
{copy.description ? (
<AlertDialogDescription className="whitespace-pre-line">
{copy.description}
</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter
className={
checkbox ? "flex-col-reverse sm:flex-row sm:items-center sm:justify-between" : undefined
}
onKeyDown={handleKeyDown}
>
{checkbox ? (
<div className="mb-2 sm:mb-0">
<label className="flex cursor-pointer items-center gap-2 self-start text-xs text-muted-foreground select-none sm:self-center">
<Checkbox
ref={checkboxRef}
checked={checkboxChecked}
onCheckedChange={(checked) => {
const next = checked === true;
setCheckboxChecked(next);
checkbox.onCheckedChange?.(next);
}}
/>
<span>{checkbox.label}</span>
</label>
</div>
) : null}
<div className="flex w-full items-center justify-end gap-2 sm:w-auto">
<AlertDialogClose ref={cancelButtonRef} render={<Button variant="outline" />}>
Cancel
</AlertDialogClose>
<Button ref={confirmButtonRef} autoFocus variant={variant} onClick={onConfirm}>
Confirm
</Button>
</div>
</AlertDialogFooter>
</AlertDialogPopup>
);
}

/**
* Top-level host for imperative confirmation dialogs across the application.
*/
export function ConfirmDialogHost() {
const state = useSyncExternalStore(
subscribeConfirmDialog,
Expand All @@ -60,11 +164,11 @@ export function ConfirmDialogHost() {

useEffect(() => registerConfirmDialogHost(), []);

const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message);
const confirmVariant = state.status === "idle" ? "default" : state.variant;
const onCancel = () => respondToConfirmDialog(false);
const onConfirm = () => respondToConfirmDialog(true);

const isMounted = state.status === "confirming" || state.status === "closing";

return (
<AlertDialog
open={state.status === "confirming"}
Expand All @@ -75,22 +179,15 @@ export function ConfirmDialogHost() {
if (!open) completeConfirmDialogClose();
}}
>
<AlertDialogPopup>
<AlertDialogHeader>
<AlertDialogTitle>{copy.title}</AlertDialogTitle>
{copy.description ? (
<AlertDialogDescription className="whitespace-pre-line">
{copy.description}
</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
<Button variant={confirmVariant} onClick={onConfirm}>
Confirm
</Button>
</AlertDialogFooter>
</AlertDialogPopup>
{isMounted ? (
<ConfirmDialogBody
key={state.id}
message={state.message}
variant={state.variant}
checkbox={state.checkbox}
onConfirm={onConfirm}
/>
) : null}
</AlertDialog>
);
}
27 changes: 27 additions & 0 deletions apps/web/src/components/RightPanelTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,33 @@ describe("surface shortcuts", () => {
expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]);
});

it("matches surface shortcuts by physical key code on non-Latin layouts", () => {
expect(
surfaceShortcutActionForKey(actions, {
...shortcutEvent("и"),
code: "KeyB",
}),
).toBe(actions[0]);
});

it("does not match by physical code when the layout key is a different Latin letter", () => {
expect(
surfaceShortcutActionForKey(actions, {
...shortcutEvent("d"),
code: "KeyB",
}),
).toBeNull();
});

it("does not activate shortcuts on dead key events", () => {
expect(
surfaceShortcutActionForKey(actions, {
...shortcutEvent("Dead"),
code: "KeyB",
}),
).toBeNull();
});

it("does not activate unavailable surfaces", () => {
expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull();
});
Expand Down
53 changes: 40 additions & 13 deletions apps/web/src/components/RightPanelTabs.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High components/RightPanelTabs.tsx:437

Rendering RightPanelEmptyState throws a temporal-dead-zone ReferenceError before the launcher renders, because the focus effect evaluates its dependency array while shortcutActionsRef is still uninitialized. Move the ref declaration above that effect.

  const containerRef = useRef<HTMLDivElement | null>(null);
+  const shortcutActionsRef = useRef(availableActions);
 
   useEffect(() => {
     if (isPanelOpen) {
       containerRef.current?.focus();
@@
-  const shortcutActionsRef = useRef(availableActions);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/RightPanelTabs.tsx around lines 437-448:

Rendering `RightPanelEmptyState` throws a temporal-dead-zone `ReferenceError` before the launcher renders, because the focus effect evaluates its dependency array while `shortcutActionsRef` is still uninitialized. Move the ref declaration above that effect.

Original file line number Diff line number Diff line change
Expand Up @@ -246,17 +246,33 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
type SurfaceShortcutEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey"
>;
> & { readonly code?: string };

/**
* Resolves the surface shortcut action matching a keyboard event, checking both
* the active keyboard layout character and physical key code fallbacks for non-Latin layouts.
*/
export function surfaceShortcutActionForKey<
const Action extends { available: boolean; shortcut: string },
>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {
if (event.defaultPrevented || event.isComposing) return null;
if (event.metaKey || event.ctrlKey || event.altKey) return null;
const layoutKey = event.key.toLowerCase();
const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]?.toLowerCase();
return (
actions.find(
(action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),
) ?? null
actions.find((action) => {
if (!action.available) return false;
const targetShortcut = action.shortcut.toLowerCase();
if (targetShortcut === layoutKey) return true;
if (
letterCode &&
layoutKey.length === 1 &&
!/^[a-z]$/.test(layoutKey) &&
targetShortcut === letterCode
)
return true;
return false;
}) ?? null
);
}

Expand Down Expand Up @@ -314,8 +330,11 @@ function SurfaceMenuItem(props: {
* outside a typing context, and arrows plus Enter work while the launcher is
* focused. The highlight only appears on hover or arrow use. Unavailable
* surfaces stay visible with a one-line reason.
*
* Empty launcher state rendered inside the right panel when no surface is active.
*/
function RightPanelEmptyState(props: {
open?: boolean | undefined;
onAddBrowser: () => void;
onAddBrowserInProfile: (profileId: string) => void;
browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>;
Expand Down Expand Up @@ -425,11 +444,21 @@ function RightPanelEmptyState(props: {
// is focused; focus moves around too easily (stray clicks) to carry them.
// Capture phase so app-level key handlers cannot swallow the event first;
// typing contexts and already-handled events are left alone.
const isPanelOpen = props.open !== false;
const containerRef = useRef<HTMLDivElement | null>(null);
const shortcutActionsRef = useRef(availableActions);

useEffect(() => {
shortcutActionsRef.current = availableActions;
});

useEffect(() => {
if (isPanelOpen) {
containerRef.current?.focus();
}
}, [isPanelOpen]);
useEffect(() => {
if (!isPanelOpen) return;
const handler = (event: KeyboardEvent) => {
const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
if (!action) return;
Expand All @@ -442,9 +471,10 @@ function RightPanelEmptyState(props: {
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, []);
}, [isPanelOpen]);

const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (!isPanelOpen) return;
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
if (availableActions.length === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
Expand All @@ -471,12 +501,6 @@ function RightPanelEmptyState(props: {
}
};

// Stable identity so React only runs this callback ref on mount/unmount;
// an inline arrow would re-attach and re-focus on every render.
const focusOnMount = useCallback((node: HTMLDivElement | null) => {
node?.focus();
}, []);

const isHighlighted = (action: SurfaceAction) =>
highlightIndex !== -1 && availableActions[highlightIndex] === action;

Expand All @@ -499,11 +523,13 @@ function RightPanelEmptyState(props: {

return (
<div
ref={focusOnMount}
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label="Open a surface"
data-surface-launcher-keys={availableActions.map((action) => action.shortcut).join("")}
data-surface-launcher-keys={
isPanelOpen ? availableActions.map((action) => action.shortcut).join("") : undefined
}
className={cn(
"flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none",
// The panel topbar sits above this container; matching bottom padding
Expand Down Expand Up @@ -1405,6 +1431,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
<div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
{props.activeSurfaceId === null ? (
<RightPanelEmptyState
open={props.open}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium components/RightPanelTabs.tsx:1425

When the sheet is closed and has no surfaces, RightPanelEmptyState still handles launcher keys such as T, creating a surface and consuming the keystroke. The sheet-mode caller does not provide open, so this prop is undefined and is treated as open even while RightPanelSheet keeps the empty state mounted; pass rightPanelOpen through to RightPanelTabs.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/RightPanelTabs.tsx around line 1425:

When the sheet is closed and has no surfaces, `RightPanelEmptyState` still handles launcher keys such as `T`, creating a surface and consuming the keystroke. The sheet-mode caller does not provide `open`, so this prop is `undefined` and is treated as open even while `RightPanelSheet` keeps the empty state mounted; pass `rightPanelOpen` through to `RightPanelTabs`.

onAddBrowser={props.onAddBrowser}
onAddBrowserInProfile={props.onAddBrowserInProfile}
browserProfiles={browserProfiles}
Expand Down
Loading
Loading