mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
feat(chat): toggle the floating chat window from the keyboard (MUL-5522) (#6162)
Adds a `toggleChat` shortcut action (default Mod+J, rebindable in Settings -> Shortcuts) so the pop-up chat window can be opened and dismissed without a mouse, and focuses the composer whenever the window opens so you can start typing immediately. The shortcut deliberately does not claim the chord where the overlay cannot exist -- on the Chat tab, or when the Settings -> Chat preference is off -- since flipping a hidden `isOpen` would read as a dead keypress and then surprise the user on the next navigation. That route rule now lives in one predicate shared with the overlay itself. Focus is requested only on a real closed -> open transition: ChatWindow stays mounted while closed and `isOpen` is restored from storage, so treating mount as an open event would steal focus on page load. Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
isReservedShortcut,
|
||||
parseLegacyShortcut,
|
||||
SHORTCUT_ACTIONS,
|
||||
SHORTCUT_ACTION_BY_ID,
|
||||
shortcutChordEquals,
|
||||
shortcutFromEvent,
|
||||
shortcutMatchesEvent,
|
||||
} from "./definitions";
|
||||
@@ -58,6 +60,39 @@ describe("keyboard shortcut definitions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("ships at most one action per default binding", () => {
|
||||
const seen: { id: string; shortcut: ReturnType<typeof createShortcutChord> }[] = [];
|
||||
for (const action of SHORTCUT_ACTIONS) {
|
||||
const shortcut = action.defaultShortcut;
|
||||
if (!shortcut) continue;
|
||||
const clash = seen.find((other) => shortcutChordEquals(other.shortcut, shortcut));
|
||||
expect(clash?.id, `${action.id} duplicates ${clash?.id}`).toBeUndefined();
|
||||
seen.push({ id: action.id, shortcut });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the floating chat toggle usable on every platform and runtime", () => {
|
||||
const action = SHORTCUT_ACTION_BY_ID.toggleChat;
|
||||
expect(action.defaultShortcut).toEqual(
|
||||
createShortcutChord("J", { primary: true }),
|
||||
);
|
||||
for (const platform of ["macos", "windows", "linux"] as const) {
|
||||
for (const runtime of ["web", "desktop"] as const) {
|
||||
expect(
|
||||
isShortcutAllowedForAction(
|
||||
"toggleChat",
|
||||
createShortcutChord("J", { primary: true }),
|
||||
platform,
|
||||
runtime,
|
||||
),
|
||||
`Mod+J must stay assignable on ${platform}/${runtime}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
// Dismissing chat has to work with the caret inside its own composer.
|
||||
expect(action.allowInEditable).toBe(true);
|
||||
});
|
||||
|
||||
it("strictly distinguishes Command and Control on macOS", () => {
|
||||
const commandF = createShortcutChord("F", { primary: true });
|
||||
const controlF = createShortcutChord("F", { control: true });
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ShortcutActionId =
|
||||
| "openSearch"
|
||||
| "createIssue"
|
||||
| "toggleSidebar"
|
||||
| "toggleChat"
|
||||
| "findInIssue"
|
||||
| "send"
|
||||
| "goInbox"
|
||||
@@ -75,6 +76,13 @@ export const SHORTCUT_ACTIONS: readonly ShortcutActionDefinition[] = [
|
||||
{ id: "openSearch", category: "general", defaultShortcut: primary("K"), allowInEditable: true },
|
||||
{ id: "createIssue", category: "general", defaultShortcut: createShortcutChord("C"), allowInEditable: false },
|
||||
{ id: "toggleSidebar", category: "general", defaultShortcut: primary("B"), allowInEditable: false },
|
||||
// Mod+J follows the "toggle a docked panel" convention, and is one of the few
|
||||
// letters this module's own policy leaves free on every platform and runtime:
|
||||
// it is neither app-owned (PRIMARY_RESERVED_KEYS) nor browser-owned
|
||||
// (BROWSER_ONLY_PRIMARY_RESERVED_KEYS). `allowInEditable` because the point of
|
||||
// the binding is reaching — and dismissing — chat without a mouse, which has
|
||||
// to keep working while the caret sits in the chat composer itself.
|
||||
{ id: "toggleChat", category: "general", defaultShortcut: primary("J"), allowInEditable: true },
|
||||
{ id: "findInIssue", category: "general", defaultShortcut: primary("F"), allowInEditable: true },
|
||||
{ id: "send", category: "general", defaultShortcut: primary("Enter"), allowInEditable: true },
|
||||
{ id: "goInbox", category: "navigation", defaultShortcut: null, allowInEditable: false },
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
} from "@multica/core/chat/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { useShortcut } from "@multica/core/shortcuts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ShortcutKeycaps } from "../../common/shortcut-keycaps";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
@@ -25,6 +27,10 @@ export function ChatFab() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const toggle = useChatStore((s) => s.toggle);
|
||||
// The keyboard route to this button is only useful if it's discoverable, so
|
||||
// the tooltip carries the current binding (Settings → Shortcuts can rebind or
|
||||
// clear it, hence the null case).
|
||||
const shortcut = useShortcut("toggleChat");
|
||||
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
|
||||
// FAB only needs a boolean "is anything running", and only while the window
|
||||
// is closed (when open, ChatWindow owns the detailed pending query). Gating
|
||||
@@ -69,7 +75,10 @@ export function ChatFab() {
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>{tooltip}</TooltipContent>
|
||||
<TooltipContent side="top" sideOffset={10}>
|
||||
{tooltip}
|
||||
{shortcut ? <ShortcutKeycaps shortcut={shortcut} className="ml-1.5" /> : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { removeChatMessageFromCaches } from "@multica/core/realtime";
|
||||
import { useChatDraftRestore } from "./use-chat-draft-restore";
|
||||
import { useChatInputFocus } from "./use-chat-input-focus";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
@@ -171,14 +172,9 @@ export function ChatWindow() {
|
||||
const appForeground = useAppForeground();
|
||||
const { restoreDraftRequest, enqueueLocalRestore, handleRestoreDraftApplied } =
|
||||
useChatDraftRestore(activeSessionId, isOpen && appForeground);
|
||||
// Nonce handed to ChatInput to pull focus into the compose box when a new
|
||||
// chat starts (⊕ or switching agent). 0 is inert so opening the window on an
|
||||
// existing session never steals focus.
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
const requestInputFocus = useCallback(
|
||||
() => setFocusRequest((n) => n + 1),
|
||||
[],
|
||||
);
|
||||
// Nonce handed to ChatInput to pull focus into the compose box: when a new
|
||||
// chat starts (⊕ or switching agent), and whenever the window itself opens.
|
||||
const { focusRequest, requestInputFocus } = useChatInputFocus(isOpen);
|
||||
|
||||
// Legacy archived sessions (the old soft-archive feature was removed but
|
||||
// pre-existing rows with status='archived' may still exist) are excluded
|
||||
|
||||
42
packages/views/chat/components/use-chat-input-focus.test.ts
Normal file
42
packages/views/chat/components/use-chat-input-focus.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useChatInputFocus } from "./use-chat-input-focus";
|
||||
|
||||
describe("useChatInputFocus", () => {
|
||||
it("stays inert on mount, whether the window starts closed or open", () => {
|
||||
expect(renderHook(() => useChatInputFocus(false)).result.current.focusRequest).toBe(0);
|
||||
// A persisted "open" preference must not steal focus from the page the user
|
||||
// just loaded — ChatWindow is mounted (hidden) even while closed.
|
||||
expect(renderHook(() => useChatInputFocus(true)).result.current.focusRequest).toBe(0);
|
||||
});
|
||||
|
||||
it("requests focus on every closed → open transition", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ isOpen }: { isOpen: boolean }) => useChatInputFocus(isOpen),
|
||||
{ initialProps: { isOpen: false } },
|
||||
);
|
||||
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.focusRequest).toBe(1);
|
||||
|
||||
// Re-renders that don't change `isOpen` must not re-focus: the user may
|
||||
// have clicked into the message list or the agent picker since.
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.focusRequest).toBe(1);
|
||||
|
||||
rerender({ isOpen: false });
|
||||
expect(result.current.focusRequest).toBe(1);
|
||||
|
||||
rerender({ isOpen: true });
|
||||
expect(result.current.focusRequest).toBe(2);
|
||||
});
|
||||
|
||||
it("lets callers bump the nonce for new chats and agent switches", () => {
|
||||
const { result } = renderHook(() => useChatInputFocus(true));
|
||||
|
||||
act(() => result.current.requestInputFocus());
|
||||
expect(result.current.focusRequest).toBe(1);
|
||||
act(() => result.current.requestInputFocus());
|
||||
expect(result.current.focusRequest).toBe(2);
|
||||
});
|
||||
});
|
||||
38
packages/views/chat/components/use-chat-input-focus.ts
Normal file
38
packages/views/chat/components/use-chat-input-focus.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Owns the floating window's composer-focus nonce.
|
||||
*
|
||||
* `focusRequest` is handed to ChatInput, which pulls keyboard focus into the
|
||||
* editor every time the number changes; `0` is inert. Callers bump it for the
|
||||
* moments that mean "you are about to type something new" — a fresh chat, an
|
||||
* agent switch, a project-context change.
|
||||
*
|
||||
* Opening the window is one of those moments (MUL-5522): the whole point of the
|
||||
* toggle shortcut is reaching chat without a mouse, so landing in a window you
|
||||
* still have to click into would defeat it — and the same courtesy applies to
|
||||
* opening it from the FAB.
|
||||
*
|
||||
* The ref is seeded with the mount-time value so only a real closed → open
|
||||
* transition focuses. ChatWindow stays mounted while closed and `isOpen` is
|
||||
* restored from storage, so treating mount as an open event would let a
|
||||
* persisted "open" preference steal focus from whatever page the user loaded.
|
||||
*/
|
||||
export function useChatInputFocus(isOpen: boolean): {
|
||||
focusRequest: number;
|
||||
requestInputFocus: () => void;
|
||||
} {
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
const requestInputFocus = useCallback(() => setFocusRequest((n) => n + 1), []);
|
||||
|
||||
const wasOpenRef = useRef(isOpen);
|
||||
useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
wasOpenRef.current = isOpen;
|
||||
if (isOpen && !wasOpen) requestInputFocus();
|
||||
}, [isOpen, requestInputFocus]);
|
||||
|
||||
return { focusRequest, requestInputFocus };
|
||||
}
|
||||
18
packages/views/chat/floating-chat-visibility.test.ts
Normal file
18
packages/views/chat/floating-chat-visibility.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isFloatingChatRouteSuppressed } from "./floating-chat-visibility";
|
||||
|
||||
describe("floating chat route suppression", () => {
|
||||
it("suppresses the overlay on the Chat tab and its sub-routes", () => {
|
||||
expect(isFloatingChatRouteSuppressed("/acme/chat", "/acme/chat")).toBe(true);
|
||||
expect(isFloatingChatRouteSuppressed("/acme/chat/session-1", "/acme/chat")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the overlay on every other route, including prefix look-alikes", () => {
|
||||
expect(isFloatingChatRouteSuppressed("/acme/issues", "/acme/chat")).toBe(false);
|
||||
// A sibling route that merely starts with the same characters is not the
|
||||
// Chat tab — matching on the bare prefix would hide chat from it.
|
||||
expect(isFloatingChatRouteSuppressed("/acme/chatter", "/acme/chat")).toBe(false);
|
||||
// Another workspace's chat tab is a different route too.
|
||||
expect(isFloatingChatRouteSuppressed("/other/chat", "/acme/chat")).toBe(false);
|
||||
});
|
||||
});
|
||||
16
packages/views/chat/floating-chat-visibility.ts
Normal file
16
packages/views/chat/floating-chat-visibility.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Route gate for the floating chat overlay, shared by the overlay itself and by
|
||||
* the `toggleChat` keyboard shortcut so the two can never disagree.
|
||||
*
|
||||
* On the Chat tab the full-page surface already owns the same `activeSessionId`,
|
||||
* so a floating copy would be pure duplication — `FloatingChat` renders nothing
|
||||
* there. The shortcut has to honour the same rule: flipping `isOpen` on a route
|
||||
* where the overlay cannot mount reads as a dead keypress, and then surprises
|
||||
* the user with a window that pops open (or vanishes) on the next navigation.
|
||||
*/
|
||||
export function isFloatingChatRouteSuppressed(
|
||||
pathname: string,
|
||||
chatPath: string,
|
||||
): boolean {
|
||||
return pathname === chatPath || pathname.startsWith(`${chatPath}/`);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { ChatFab } from "./components/chat-fab";
|
||||
import { ChatWindow } from "./components/chat-window";
|
||||
import { isFloatingChatRouteSuppressed } from "./floating-chat-visibility";
|
||||
|
||||
/**
|
||||
* Mount point for the floating chat overlay (FAB + window). Rendered once in
|
||||
@@ -24,9 +25,7 @@ export function FloatingChat() {
|
||||
|
||||
if (!enabled) return null;
|
||||
// Suppress on the Chat tab — it renders the same conversation full-page.
|
||||
if (pathname === wsPaths.chat() || pathname.startsWith(`${wsPaths.chat()}/`)) {
|
||||
return null;
|
||||
}
|
||||
if (isFloatingChatRouteSuppressed(pathname, wsPaths.chat())) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
139
packages/views/layout/global-shortcuts.chat.test.tsx
Normal file
139
packages/views/layout/global-shortcuts.chat.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { configureShortcutPlatform } from "@multica/core/shortcuts";
|
||||
import { GlobalShortcuts } from "./global-shortcuts";
|
||||
|
||||
// The floating chat overlay is reachable from the keyboard (MUL-5522). What
|
||||
// matters is not just "the chord calls toggle", but that it stays a no-op —
|
||||
// without swallowing the keypress — everywhere the overlay cannot exist.
|
||||
const h = vi.hoisted(() => ({
|
||||
chat: { floatingChatEnabled: true, toggle: vi.fn() },
|
||||
navigation: { pathname: "/acme/issues", push: vi.fn() },
|
||||
toggleSidebar: vi.fn(),
|
||||
searchToggle: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/chat", () => ({
|
||||
useChatStore: Object.assign(
|
||||
(selector: (state: typeof h.chat) => unknown) => selector(h.chat),
|
||||
{ getState: () => h.chat },
|
||||
),
|
||||
}));
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
openCreateIssueWithPreference: vi.fn(),
|
||||
}));
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: { getState: () => ({ modal: null }) },
|
||||
}));
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
useWorkspacePaths: () => ({
|
||||
inbox: () => "/acme/inbox",
|
||||
chat: () => "/acme/chat",
|
||||
myIssues: () => "/acme/my-issues",
|
||||
issues: () => "/acme/issues",
|
||||
projects: () => "/acme/projects",
|
||||
autopilots: () => "/acme/autopilots",
|
||||
agents: () => "/acme/agents",
|
||||
squads: () => "/acme/squads",
|
||||
usage: () => "/acme/usage",
|
||||
runtimes: () => "/acme/runtimes",
|
||||
skills: () => "/acme/skills",
|
||||
settings: () => "/acme/settings",
|
||||
}),
|
||||
}));
|
||||
vi.mock("@multica/ui/components/ui/sidebar", () => ({
|
||||
useSidebar: () => ({ toggleSidebar: h.toggleSidebar }),
|
||||
}));
|
||||
vi.mock("../navigation", () => ({ useNavigation: () => h.navigation }));
|
||||
vi.mock("../search/search-store", () => ({
|
||||
useSearchStore: { getState: () => ({ toggle: h.searchToggle }) },
|
||||
}));
|
||||
|
||||
/** Mod+J on macOS, dispatched the way a real keypress reaches the document. */
|
||||
function pressToggleChat(target: EventTarget = document): boolean {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "j",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Pin the platform: jsdom's user agent reports the host OS, so Command vs
|
||||
// Control would otherwise depend on where the suite runs.
|
||||
configureShortcutPlatform("macos");
|
||||
h.chat.floatingChatEnabled = true;
|
||||
h.navigation.pathname = "/acme/issues";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
configureShortcutPlatform(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("chat toggle shortcut", () => {
|
||||
it("toggles the floating window and consumes the chord", () => {
|
||||
render(<GlobalShortcuts />);
|
||||
|
||||
expect(pressToggleChat()).toBe(true);
|
||||
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still fires while the caret is inside a text input", () => {
|
||||
render(<GlobalShortcuts />);
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
expect(pressToggleChat(input)).toBe(true);
|
||||
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
|
||||
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it("leaves the chord alone on the Chat tab, where the overlay is suppressed", () => {
|
||||
h.navigation.pathname = "/acme/chat";
|
||||
render(<GlobalShortcuts />);
|
||||
|
||||
// Not prevented: an action that cannot run must not swallow the keypress.
|
||||
expect(pressToggleChat()).toBe(false);
|
||||
expect(h.chat.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the chord alone when the floating window is turned off", () => {
|
||||
h.chat.floatingChatEnabled = false;
|
||||
render(<GlobalShortcuts />);
|
||||
|
||||
expect(pressToggleChat()).toBe(false);
|
||||
expect(h.chat.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads the preference at press time, not at mount", () => {
|
||||
render(<GlobalShortcuts />);
|
||||
|
||||
h.chat.floatingChatEnabled = false;
|
||||
expect(pressToggleChat()).toBe(false);
|
||||
|
||||
h.chat.floatingChatEnabled = true;
|
||||
expect(pressToggleChat()).toBe(true);
|
||||
expect(h.chat.toggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not confuse the chat chord with the other global bindings", () => {
|
||||
render(<GlobalShortcuts />);
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "b",
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
expect(h.toggleSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(h.chat.toggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
useShortcutStore,
|
||||
type ShortcutActionId,
|
||||
} from "@multica/core/shortcuts";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { openCreateIssueWithPreference } from "@multica/core/issues/stores";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { isImeComposing } from "@multica/core/utils";
|
||||
import { isFloatingChatRouteSuppressed } from "../chat/floating-chat-visibility";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useSearchStore } from "../search/search-store";
|
||||
|
||||
@@ -21,6 +23,7 @@ const GLOBAL_ACTIONS: readonly ShortcutActionId[] = [
|
||||
"openSearch",
|
||||
"createIssue",
|
||||
"toggleSidebar",
|
||||
"toggleChat",
|
||||
"goInbox",
|
||||
"goChat",
|
||||
"goMyIssues",
|
||||
@@ -50,9 +53,10 @@ export function GlobalShortcuts() {
|
||||
const overrides = useShortcutStore((state) => state.overrides);
|
||||
|
||||
useEffect(() => {
|
||||
const chatPath = workspacePaths.chat();
|
||||
const destinations: Partial<Record<ShortcutActionId, string>> = {
|
||||
goInbox: workspacePaths.inbox(),
|
||||
goChat: workspacePaths.chat(),
|
||||
goChat: chatPath,
|
||||
goMyIssues: workspacePaths.myIssues(),
|
||||
goIssues: workspacePaths.issues(),
|
||||
goProjects: workspacePaths.projects(),
|
||||
@@ -65,6 +69,15 @@ export function GlobalShortcuts() {
|
||||
goSettings: workspacePaths.settings(),
|
||||
};
|
||||
|
||||
// Read at press time rather than subscribing: the preference only matters
|
||||
// the instant the chord fires, and the overlay is gone from the Chat tab.
|
||||
// An unavailable overlay must not claim the chord either — returning false
|
||||
// from the finder leaves the keypress its outside-the-app meaning instead of
|
||||
// swallowing it for an action that would visibly do nothing.
|
||||
const canToggleFloatingChat = () =>
|
||||
useChatStore.getState().floatingChatEnabled &&
|
||||
!isFloatingChatRouteSuppressed(navigation.pathname, chatPath);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Component/editor handlers run before this document-level listener.
|
||||
// Respect their preventDefault instead of double-triggering a product
|
||||
@@ -76,6 +89,7 @@ export function GlobalShortcuts() {
|
||||
if (!action.allowInEditable && isEditableShortcutTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
if (candidate === "toggleChat" && !canToggleFloatingChat()) return false;
|
||||
return shortcutMatchesEvent(getShortcut(candidate), event);
|
||||
});
|
||||
if (!actionId) return;
|
||||
@@ -85,6 +99,10 @@ export function GlobalShortcuts() {
|
||||
useSearchStore.getState().toggle();
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleChat") {
|
||||
useChatStore.getState().toggle();
|
||||
return;
|
||||
}
|
||||
if (actionId === "toggleSidebar") {
|
||||
toggleSidebar();
|
||||
return;
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"openSearch": { "label": "Open search", "description": "Search and run commands from anywhere." },
|
||||
"createIssue": { "label": "Create issue", "description": "Open your preferred issue creation flow." },
|
||||
"toggleSidebar": { "label": "Toggle sidebar", "description": "Show or hide the main sidebar." },
|
||||
"toggleChat": { "label": "Toggle chat window", "description": "Open or close the floating chat window. Opening it focuses the message input." },
|
||||
"findInIssue": { "label": "Find in issue", "description": "Search within the open issue detail." },
|
||||
"send": { "label": "Send", "description": "Send chat messages, comments, replies, feedback, and prompts, and create issues in the create dialog." },
|
||||
"goInbox": { "label": "Go to Inbox", "description": "Open the workspace inbox." },
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"openSearch": { "label": "検索を開く", "description": "どこからでも検索やコマンドを実行します。" },
|
||||
"createIssue": { "label": "Issueを作成", "description": "優先するissue作成フローを開きます。" },
|
||||
"toggleSidebar": { "label": "サイドバーを切り替え", "description": "メインサイドバーの表示を切り替えます。" },
|
||||
"toggleChat": { "label": "チャットウィンドウを切り替え", "description": "フローティングチャットを開閉します。開いたときは入力欄にフォーカスします。" },
|
||||
"findInIssue": { "label": "Issue内を検索", "description": "開いているissueの詳細を検索します。" },
|
||||
"send": { "label": "送信", "description": "チャット、コメント、返信、フィードバック、プロンプトを送信し、作成ダイアログで issue を作成します。" },
|
||||
"goInbox": { "label": "受信トレイへ移動", "description": "ワークスペースの受信トレイを開きます。" },
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"openSearch": { "label": "검색 열기", "description": "어디서나 검색하고 명령을 실행합니다." },
|
||||
"createIssue": { "label": "Issue 만들기", "description": "선호하는 issue 만들기 흐름을 엽니다." },
|
||||
"toggleSidebar": { "label": "사이드바 전환", "description": "기본 사이드바를 표시하거나 숨깁니다." },
|
||||
"toggleChat": { "label": "채팅 창 전환", "description": "플로팅 채팅 창을 열거나 닫습니다. 열면 메시지 입력란에 포커스합니다." },
|
||||
"findInIssue": { "label": "Issue에서 찾기", "description": "열린 issue 상세 내용에서 검색합니다." },
|
||||
"send": { "label": "보내기", "description": "채팅, 댓글, 답글, 피드백 및 프롬프트를 보내고, 생성 대화상자에서 이슈를 만듭니다." },
|
||||
"goInbox": { "label": "받은 편지함으로 이동", "description": "워크스페이스 받은 편지함을 엽니다." },
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"openSearch": { "label": "打开搜索", "description": "随时搜索内容或运行命令。" },
|
||||
"createIssue": { "label": "新建 issue", "description": "打开你常用的 issue 创建流程。" },
|
||||
"toggleSidebar": { "label": "显示或隐藏侧边栏", "description": "切换主侧边栏的显示状态。" },
|
||||
"toggleChat": { "label": "显示或隐藏聊天窗", "description": "打开或关闭悬浮聊天窗,打开时自动聚焦输入框。" },
|
||||
"findInIssue": { "label": "在 issue 中查找", "description": "搜索当前打开的 issue 详情。" },
|
||||
"send": { "label": "发送", "description": "发送聊天消息、评论、回复、反馈和提示词,并在创建弹窗中创建 issue。" },
|
||||
"goInbox": { "label": "前往收件箱", "description": "打开工作区收件箱。" },
|
||||
|
||||
@@ -28,13 +28,13 @@ describe("KeyboardShortcutsTab", () => {
|
||||
});
|
||||
|
||||
fireEvent.click(recorder);
|
||||
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
|
||||
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
|
||||
|
||||
expect(getShortcut("openSearch")).toEqual(
|
||||
createShortcutChord("J", { primary: true }),
|
||||
createShortcutChord("E", { primary: true }),
|
||||
);
|
||||
expect(within(recorder).getByTitle("Ctrl")).toHaveTextContent("Ctrl");
|
||||
expect(within(recorder).getByTitle("J")).toHaveTextContent("J");
|
||||
expect(within(recorder).getByTitle("E")).toHaveTextContent("E");
|
||||
});
|
||||
|
||||
it("only captures keys while the recorder is active", () => {
|
||||
@@ -44,22 +44,22 @@ describe("KeyboardShortcutsTab", () => {
|
||||
});
|
||||
|
||||
recorder.focus();
|
||||
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
|
||||
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
|
||||
expect(getShortcut("openSearch")).toEqual(
|
||||
createShortcutChord("K", { primary: true }),
|
||||
);
|
||||
|
||||
fireEvent.click(recorder);
|
||||
fireEvent.keyDown(recorder, { key: "j", ctrlKey: true });
|
||||
fireEvent.keyDown(recorder, { key: "e", ctrlKey: true });
|
||||
expect(getShortcut("openSearch")).toEqual(
|
||||
createShortcutChord("J", { primary: true }),
|
||||
createShortcutChord("E", { primary: true }),
|
||||
);
|
||||
|
||||
// Focus remains on the button after a successful recording. Tab must move
|
||||
// focus normally instead of silently replacing the shortcut with Tab.
|
||||
fireEvent.keyDown(recorder, { key: "Tab" });
|
||||
expect(getShortcut("openSearch")).toEqual(
|
||||
createShortcutChord("J", { primary: true }),
|
||||
createShortcutChord("E", { primary: true }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user