From 6be7adcb6b8e5a5e45518631bf02a7ee72fec897 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:22:47 +0800 Subject: [PATCH] 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 Co-authored-by: multica-agent --- packages/core/shortcuts/definitions.test.ts | 35 +++++ packages/core/shortcuts/definitions.ts | 8 + packages/views/chat/components/chat-fab.tsx | 11 +- .../views/chat/components/chat-window.tsx | 12 +- .../components/use-chat-input-focus.test.ts | 42 ++++++ .../chat/components/use-chat-input-focus.ts | 38 +++++ .../chat/floating-chat-visibility.test.ts | 18 +++ .../views/chat/floating-chat-visibility.ts | 16 ++ packages/views/chat/floating-chat.tsx | 5 +- .../layout/global-shortcuts.chat.test.tsx | 139 ++++++++++++++++++ packages/views/layout/global-shortcuts.tsx | 20 ++- packages/views/locales/en/settings.json | 1 + packages/views/locales/ja/settings.json | 1 + packages/views/locales/ko/settings.json | 1 + packages/views/locales/zh-Hans/settings.json | 1 + .../keyboard-shortcuts-tab.test.tsx | 14 +- 16 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 packages/views/chat/components/use-chat-input-focus.test.ts create mode 100644 packages/views/chat/components/use-chat-input-focus.ts create mode 100644 packages/views/chat/floating-chat-visibility.test.ts create mode 100644 packages/views/chat/floating-chat-visibility.ts create mode 100644 packages/views/layout/global-shortcuts.chat.test.tsx diff --git a/packages/core/shortcuts/definitions.test.ts b/packages/core/shortcuts/definitions.test.ts index d01f2c5de4..4614f206a1 100644 --- a/packages/core/shortcuts/definitions.test.ts +++ b/packages/core/shortcuts/definitions.test.ts @@ -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 }[] = []; + 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 }); diff --git a/packages/core/shortcuts/definitions.ts b/packages/core/shortcuts/definitions.ts index 2598808f23..23ff047c1f 100644 --- a/packages/core/shortcuts/definitions.ts +++ b/packages/core/shortcuts/definitions.ts @@ -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 }, diff --git a/packages/views/chat/components/chat-fab.tsx b/packages/views/chat/components/chat-fab.tsx index 121c988b19..e2dfd0276f 100644 --- a/packages/views/chat/components/chat-fab.tsx +++ b/packages/views/chat/components/chat-fab.tsx @@ -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() { > - {tooltip} + + {tooltip} + {shortcut ? : null} + ); } diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 772e9c3ca3..413661b2d5 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -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 diff --git a/packages/views/chat/components/use-chat-input-focus.test.ts b/packages/views/chat/components/use-chat-input-focus.test.ts new file mode 100644 index 0000000000..9f35bd5603 --- /dev/null +++ b/packages/views/chat/components/use-chat-input-focus.test.ts @@ -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); + }); +}); diff --git a/packages/views/chat/components/use-chat-input-focus.ts b/packages/views/chat/components/use-chat-input-focus.ts new file mode 100644 index 0000000000..6ba9a2eac9 --- /dev/null +++ b/packages/views/chat/components/use-chat-input-focus.ts @@ -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 }; +} diff --git a/packages/views/chat/floating-chat-visibility.test.ts b/packages/views/chat/floating-chat-visibility.test.ts new file mode 100644 index 0000000000..401ddd8839 --- /dev/null +++ b/packages/views/chat/floating-chat-visibility.test.ts @@ -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); + }); +}); diff --git a/packages/views/chat/floating-chat-visibility.ts b/packages/views/chat/floating-chat-visibility.ts new file mode 100644 index 0000000000..094e282f48 --- /dev/null +++ b/packages/views/chat/floating-chat-visibility.ts @@ -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}/`); +} diff --git a/packages/views/chat/floating-chat.tsx b/packages/views/chat/floating-chat.tsx index 4152ad6c15..55de864ecc 100644 --- a/packages/views/chat/floating-chat.tsx +++ b/packages/views/chat/floating-chat.tsx @@ -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 ( <> diff --git a/packages/views/layout/global-shortcuts.chat.test.tsx b/packages/views/layout/global-shortcuts.chat.test.tsx new file mode 100644 index 0000000000..179963ff3a --- /dev/null +++ b/packages/views/layout/global-shortcuts.chat.test.tsx @@ -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(); + + expect(pressToggleChat()).toBe(true); + expect(h.chat.toggle).toHaveBeenCalledTimes(1); + }); + + it("still fires while the caret is inside a text input", () => { + render(); + 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(); + + // 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(); + + expect(pressToggleChat()).toBe(false); + expect(h.chat.toggle).not.toHaveBeenCalled(); + }); + + it("reads the preference at press time, not at mount", () => { + render(); + + 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(); + + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + expect(h.toggleSidebar).toHaveBeenCalledTimes(1); + expect(h.chat.toggle).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/layout/global-shortcuts.tsx b/packages/views/layout/global-shortcuts.tsx index 6b79358866..ed0d73c606 100644 --- a/packages/views/layout/global-shortcuts.tsx +++ b/packages/views/layout/global-shortcuts.tsx @@ -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> = { 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; diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 5ed05510ad..d041c9834b 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -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." }, diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index ffc39899c0..cffbb9b6ee 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -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": "ワークスペースの受信トレイを開きます。" }, diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index 84eb71ed94..78958f4d3e 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -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": "워크스페이스 받은 편지함을 엽니다." }, diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index 83fab88185..d4cf7b7be3 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -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": "打开工作区收件箱。" }, diff --git a/packages/views/settings/components/keyboard-shortcuts-tab.test.tsx b/packages/views/settings/components/keyboard-shortcuts-tab.test.tsx index 9c8f3f191e..8bb5ad0eea 100644 --- a/packages/views/settings/components/keyboard-shortcuts-tab.test.tsx +++ b/packages/views/settings/components/keyboard-shortcuts-tab.test.tsx @@ -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 }), ); });