From 1649b42a6db3b5d0e4bc0d0db8d4174b1be2e250 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:44:26 +0800 Subject: [PATCH 1/2] feat(views): support the send shortcut in manual issue create (MUL-4931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent create has had Cmd/Ctrl+Enter all along; manual create had no submit shortcut at all, in either the title or the description. Reuse the configurable `send` action rather than hardcoding the chord, so a rebound or unbound shortcut follows the user's setting and the IME guards and Shift+Enter replay come along for free: - Description: pass `onSubmit` to ContentEditor, which already carries the extension. - Title: add an opt-in `onSubmitShortcut` to the shared TitleEditor. Plain Enter stays inert there — #5532 removed that trigger a day ago because it created from half-typed titles — and a single-line title has no newline to trade Enter against, so the chord path refuses a plain-Enter `send` binding. Hosts relying on plain-Enter submit (create-project, autopilot-dialog) keep `onSubmit` and are untouched. Also fixes a real double-create: `submitting` is state, so two chord presses in one tick both read the stale value and fired two creates. A ref flips synchronously and single-flights both create panels. Accessibility: the empty-title Create button moves from native `disabled` to `aria-disabled`, so it stays focusable and keyboard/SR users can finally reach the "Enter a title to create" tooltip. Keycaps are decorative, keeping the accessible name "Create Issue". Empty-title chord submits now focus the title instead of silently no-oping. Widen the `send` setting description across all four locales — it claimed to cover only chat, comments, replies, feedback and prompts. Tests: real-ProseMirror coverage for the title chord (every other editor test mocks Tiptap, so nothing verified extension ordering), plus chord/plain-Enter/ empty-title/upload-gate/single-flight/keycaps/aria-disabled cases. The single-flight test dispatches both presses inside one act(); it fails against the old state-based guard. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- .../editor/title-editor.shortcut.test.tsx | 87 ++++++++ packages/views/editor/title-editor.test.tsx | 23 +- packages/views/editor/title-editor.tsx | 58 ++++++ packages/views/locales/en/settings.json | 2 +- packages/views/locales/ja/settings.json | 2 +- packages/views/locales/ko/settings.json | 2 +- packages/views/locales/zh-Hans/settings.json | 2 +- packages/views/modals/create-issue.test.tsx | 196 ++++++++++++++++-- packages/views/modals/create-issue.tsx | 94 +++++++-- packages/views/modals/quick-create-issue.tsx | 10 +- 10 files changed, 438 insertions(+), 38 deletions(-) create mode 100644 packages/views/editor/title-editor.shortcut.test.tsx diff --git a/packages/views/editor/title-editor.shortcut.test.tsx b/packages/views/editor/title-editor.shortcut.test.tsx new file mode 100644 index 00000000000..402f2310135 --- /dev/null +++ b/packages/views/editor/title-editor.shortcut.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + configureShortcutPlatform, + createShortcutChord, + useShortcutStore, +} from "@multica/core/shortcuts"; +import { TitleEditor } from "./title-editor"; + +// Every other editor test mocks `@tiptap/react`, which means nothing verifies +// that the submit-shortcut extension actually wins over the title keymap in a +// real ProseMirror instance — the one genuinely new interaction in MUL-4931. +// This file deliberately runs the real editor to pin that ordering down. + +vi.mock("../i18n", () => ({ + useT: () => ({ t: () => "" }), +})); + +function pressEnter(options: KeyboardEventInit = {}) { + const target = document.querySelector(".title-editor") as HTMLElement; + target.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + ...options, + }), + ); +} + +describe("TitleEditor send chord (real editor)", () => { + beforeEach(() => { + configureShortcutPlatform("macos"); + useShortcutStore.setState({ overrides: {} }); + }); + + afterEach(() => { + useShortcutStore.setState({ overrides: {} }); + }); + + it("fires onSubmitShortcut on the default Cmd+Enter chord", async () => { + const onSubmitShortcut = vi.fn(); + render(); + await screen.findByText("A title"); + + pressEnter({ metaKey: true }); + + expect(onSubmitShortcut).toHaveBeenCalledTimes(1); + }); + + it("leaves plain Enter to the keymap, so it never creates", async () => { + const onSubmitShortcut = vi.fn(); + render(); + await screen.findByText("A title"); + + pressEnter(); + + expect(onSubmitShortcut).not.toHaveBeenCalled(); + }); + + it("still refuses plain Enter when the user binds send to Enter", async () => { + useShortcutStore.setState({ overrides: { send: createShortcutChord("Enter") } }); + const onSubmitShortcut = vi.fn(); + render(); + await screen.findByText("A title"); + + pressEnter(); + + // The single-line keymap owns Enter; inheriting a chat-shaped binding here + // would create from a half-typed title. + expect(onSubmitShortcut).not.toHaveBeenCalled(); + }); + + it("does not fire the chord for hosts that only pass onSubmit", async () => { + const onSubmit = vi.fn(); + render(); + await screen.findByText("A title"); + + // create-project / autopilot-dialog rely on plain Enter submitting and must + // not gain a second trigger from this change. + pressEnter({ metaKey: true }); + expect(onSubmit).not.toHaveBeenCalled(); + + pressEnter(); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/views/editor/title-editor.test.tsx b/packages/views/editor/title-editor.test.tsx index ce3232ba3a4..f2f40904149 100644 --- a/packages/views/editor/title-editor.test.tsx +++ b/packages/views/editor/title-editor.test.tsx @@ -39,7 +39,8 @@ vi.mock("@tiptap/react", () => ({ EditorContent: () =>
, })); -import { TitleEditor } from "./title-editor"; +import { createShortcutChord } from "@multica/core/shortcuts"; +import { TitleEditor, titleShortcutSubmitAllowed } from "./title-editor"; describe("TitleEditor", () => { beforeEach(() => { @@ -136,3 +137,23 @@ describe("TitleEditor", () => { expect(mockSetContent).toHaveBeenCalledWith("", { emitUpdate: false }); }); }); + +// MUL-4931 — which `send` bindings the title's shortcut-submit path honors. +describe("titleShortcutSubmitAllowed", () => { + it("allows the default Mod+Enter chord", () => { + expect( + titleShortcutSubmitAllowed(createShortcutChord("Enter", { primary: true })), + ).toBe(true); + }); + + it("refuses plain Enter, which the single-line keymap already owns", () => { + // A user may bind `send` to plain Enter for chat. The title must not + // inherit it — Enter there means "done", and creating from a half-typed + // title is the misfire #5532 removed. + expect(titleShortcutSubmitAllowed(createShortcutChord("Enter"))).toBe(false); + }); + + it("refuses an unbound send action", () => { + expect(titleShortcutSubmitAllowed(null)).toBe(false); + }); +}); diff --git a/packages/views/editor/title-editor.tsx b/packages/views/editor/title-editor.tsx index 5f70c9616e5..bdb1fca31b9 100644 --- a/packages/views/editor/title-editor.tsx +++ b/packages/views/editor/title-editor.tsx @@ -7,8 +7,14 @@ import { Document } from "@tiptap/extension-document"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Text } from "@tiptap/extension-text"; import Placeholder from "@tiptap/extension-placeholder"; +import { + getShortcut, + isPlainShortcut, + type ShortcutChord, +} from "@multica/core/shortcuts"; import { cn } from "@multica/ui/lib/utils"; import { useT } from "../i18n"; +import { createSubmitShortcutExtension } from "./extensions/submit-shortcut"; import "./title-editor.css"; // --------------------------------------------------------------------------- @@ -21,6 +27,18 @@ interface TitleEditorProps { className?: string; autoFocus?: boolean; onSubmit?: () => void; + /** + * Fires on the configured `send` chord, independent of `onSubmit`'s plain + * Enter path. Hosts that submit on plain Enter pass `onSubmit`; hosts that + * want an explicit chord (create-issue, MUL-4931) pass this instead. + * + * Plain Enter is deliberately never a trigger here even when `send` is + * configured as plain Enter: the keymap below already owns that key for + * "finish editing", and this single-line editor has no newline to trade it + * against. Shadowing it would silently create from a half-typed title — + * exactly what #5532 removed. + */ + onSubmitShortcut?: () => void; onBlur?: (value: string) => void; onChange?: (value: string) => void; /** @@ -55,6 +73,23 @@ const SingleLineDocument = Document.extend({ // Keyboard shortcuts: Enter → submit, Escape → blur // --------------------------------------------------------------------------- +/** + * Whether `onSubmitShortcut` may fire for the configured `send` chord. + * + * Plain Enter is excluded on purpose. The keymap below already owns that key + * ("finish editing"), and unlike a prose editor a single-line title has no + * newline to trade it against — so a `send` configured as plain Enter would + * turn every Enter into a create from a half-typed title. That is exactly the + * misfire #5532 removed, so the title keeps plain Enter inert and only honors + * an explicit chord. Hosts wanting plain-Enter submit still pass `onSubmit`. + */ +export function titleShortcutSubmitAllowed( + sendShortcut: ShortcutChord | null, +): boolean { + if (!sendShortcut) return false; + return !isPlainShortcut(sendShortcut, "Enter"); +} + function createTitleKeymap(opts: { onSubmitRef: React.RefObject<(() => void) | undefined>; }) { @@ -89,6 +124,7 @@ const TitleEditor = forwardRef( className, autoFocus = false, onSubmit, + onSubmitShortcut, onBlur, onChange, onReady, @@ -97,15 +133,22 @@ const TitleEditor = forwardRef( ) { const { t } = useT("editor"); const onSubmitRef = useRef(onSubmit); + const onSubmitShortcutRef = useRef(onSubmitShortcut); const onBlurRef = useRef(onBlur); const onChangeRef = useRef(onChange); const onReadyRef = useRef(onReady); onSubmitRef.current = onSubmit; + onSubmitShortcutRef.current = onSubmitShortcut; onBlurRef.current = onBlur; onChangeRef.current = onChange; onReadyRef.current = onReady; + // `useEditor` reads `extensions` once at mount and no host toggles this + // prop over its lifetime, so pin the mount-time answer rather than let the + // extension list depend on whichever render happened to create the editor. + const shortcutSubmitEnabled = useRef(onSubmitShortcut !== undefined).current; + const editor = useEditor({ immediatelyRender: false, content: defaultValue @@ -120,6 +163,21 @@ const TitleEditor = forwardRef( showOnlyCurrent: false, }), createTitleKeymap({ onSubmitRef }), + // Added last so its ProseMirror plugin sits ahead of the keymap above + // — same placement the ContentEditor extension list relies on. It + // brings the configured `send` chord, the IME guards, and key-repeat + // protection with it, none of which a hand-rolled keydown would have. + ...(shortcutSubmitEnabled + ? [ + createSubmitShortcutExtension(() => { + const fn = onSubmitShortcutRef.current; + if (!fn) return false; + if (!titleShortcutSubmitAllowed(getShortcut("send"))) return false; + fn(); + return true; + }), + ] + : []), ], editorProps: { attributes: { diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index 1da9dc61fc1..2447dce9330 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -70,7 +70,7 @@ "createIssue": { "label": "Create issue", "description": "Open your preferred issue creation flow." }, "toggleSidebar": { "label": "Toggle sidebar", "description": "Show or hide the main sidebar." }, "findInIssue": { "label": "Find in issue", "description": "Search within the open issue detail." }, - "send": { "label": "Send", "description": "Send chat messages, comments, replies, feedback, and prompts." }, + "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." }, "goChat": { "label": "Go to Chat", "description": "Open workspace chat." }, "goMyIssues": { "label": "Go to My Issues", "description": "Open issues assigned to you." }, diff --git a/packages/views/locales/ja/settings.json b/packages/views/locales/ja/settings.json index 9775c159410..41c23dee77e 100644 --- a/packages/views/locales/ja/settings.json +++ b/packages/views/locales/ja/settings.json @@ -67,7 +67,7 @@ "createIssue": { "label": "Issueを作成", "description": "優先するissue作成フローを開きます。" }, "toggleSidebar": { "label": "サイドバーを切り替え", "description": "メインサイドバーの表示を切り替えます。" }, "findInIssue": { "label": "Issue内を検索", "description": "開いているissueの詳細を検索します。" }, - "send": { "label": "送信", "description": "チャット、コメント、返信、フィードバック、プロンプトを送信します。" }, + "send": { "label": "送信", "description": "チャット、コメント、返信、フィードバック、プロンプトを送信し、作成ダイアログで issue を作成します。" }, "goInbox": { "label": "受信トレイへ移動", "description": "ワークスペースの受信トレイを開きます。" }, "goChat": { "label": "チャットへ移動", "description": "ワークスペースのチャットを開きます。" }, "goMyIssues": { "label": "自分のIssuesへ移動", "description": "自分に割り当てられたissueを開きます。" }, diff --git a/packages/views/locales/ko/settings.json b/packages/views/locales/ko/settings.json index f14aafa549a..715f2877f09 100644 --- a/packages/views/locales/ko/settings.json +++ b/packages/views/locales/ko/settings.json @@ -67,7 +67,7 @@ "createIssue": { "label": "Issue 만들기", "description": "선호하는 issue 만들기 흐름을 엽니다." }, "toggleSidebar": { "label": "사이드바 전환", "description": "기본 사이드바를 표시하거나 숨깁니다." }, "findInIssue": { "label": "Issue에서 찾기", "description": "열린 issue 상세 내용에서 검색합니다." }, - "send": { "label": "보내기", "description": "채팅, 댓글, 답글, 피드백 및 프롬프트를 보냅니다." }, + "send": { "label": "보내기", "description": "채팅, 댓글, 답글, 피드백 및 프롬프트를 보내고, 생성 대화상자에서 이슈를 만듭니다." }, "goInbox": { "label": "받은 편지함으로 이동", "description": "워크스페이스 받은 편지함을 엽니다." }, "goChat": { "label": "채팅으로 이동", "description": "워크스페이스 채팅을 엽니다." }, "goMyIssues": { "label": "내 Issues로 이동", "description": "나에게 할당된 issue를 엽니다." }, diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index 74dfab00b57..e80a7cc1b8d 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -70,7 +70,7 @@ "createIssue": { "label": "新建 issue", "description": "打开你常用的 issue 创建流程。" }, "toggleSidebar": { "label": "显示或隐藏侧边栏", "description": "切换主侧边栏的显示状态。" }, "findInIssue": { "label": "在 issue 中查找", "description": "搜索当前打开的 issue 详情。" }, - "send": { "label": "发送", "description": "发送聊天消息、评论、回复、反馈和提示词。" }, + "send": { "label": "发送", "description": "发送聊天消息、评论、回复、反馈和提示词,并在创建弹窗中创建 issue。" }, "goInbox": { "label": "前往收件箱", "description": "打开工作区收件箱。" }, "goChat": { "label": "前往聊天", "description": "打开工作区聊天。" }, "goMyIssues": { "label": "前往我的 issue", "description": "打开分配给你的 issue。" }, diff --git a/packages/views/modals/create-issue.test.tsx b/packages/views/modals/create-issue.test.tsx index a3b2fcdafc9..b3044aa7509 100644 --- a/packages/views/modals/create-issue.test.tsx +++ b/packages/views/modals/create-issue.test.tsx @@ -243,7 +243,7 @@ vi.mock("../editor", async () => { const uploadGate = await vi.importActual( "../editor/use-upload-gate", ); - const ContentEditor = forwardRef(({ defaultValue, onUpdate, onUploadFile, onUploadingChange, placeholder, attachments }: any, ref: any) => { + const ContentEditor = forwardRef(({ defaultValue, onUpdate, onSubmit, onUploadFile, onUploadingChange, placeholder, attachments }: any, ref: any) => { const valueRef = useRef(defaultValue || ""); const [value, setValue] = useState(defaultValue || ""); // Mirrors the real editor's `uploading` node attrs: the placeholder is in @@ -279,26 +279,32 @@ vi.mock("../editor", async () => { setValue(e.target.value); onUpdate?.(e.target.value); }} + // Stands in for createSubmitShortcutExtension with the default + // `send` binding (Mod+Enter). Plain Enter stays a newline. + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") onSubmit?.(); + }} /> ); }); ContentEditor.displayName = "ContentEditor"; - return { - ...uploadGate, - useEditorUpload: () => ({ - uploadWithToast: mockUploadWithToast, - upload: vi.fn(), - uploading: false, - }), - useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }), - FileDropOverlay: () => null, - ContentEditor, - TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => { + // Mirrors the real split: plain Enter is the keymap's `onSubmit` path, the + // configured `send` chord (default Mod+Enter) is `onSubmitShortcut`. The + // real component never routes plain Enter to onSubmitShortcut. + const TitleEditor = forwardRef( + ({ defaultValue, placeholder, onChange, onSubmit, onSubmitShortcut }: any, ref: any) => { const [value, setValue] = useState(defaultValue || ""); + const inputRef = useRef(null); + useImperativeHandle(ref, () => ({ + getText: () => value, + focus: () => inputRef.current?.focus(), + focusAtCoords: () => inputRef.current?.focus(), + })); return ( { @@ -306,11 +312,27 @@ vi.mock("../editor", async () => { onChange?.(e.target.value); }} onKeyDown={(e) => { - if (e.key === "Enter") onSubmit?.(); + if (e.key !== "Enter") return; + if (e.metaKey || e.ctrlKey) onSubmitShortcut?.(); + else onSubmit?.(); }} /> ); }, + ); + TitleEditor.displayName = "TitleEditor"; + + return { + ...uploadGate, + useEditorUpload: () => ({ + uploadWithToast: mockUploadWithToast, + upload: vi.fn(), + uploading: false, + }), + useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }), + FileDropOverlay: () => null, + ContentEditor, + TitleEditor, }; }); @@ -1204,7 +1226,10 @@ describe("CreateIssueModal", () => { ); }); - it("never submits manual create from Enter in the title", async () => { + // Plain Enter in the title was removed as a create trigger in #5532 — it + // fired from a half-typed title. MUL-4931 adds the explicit `send` chord + // alongside it; plain Enter must stay inert. + it("never submits manual create from plain Enter in the title", async () => { const user = userEvent.setup(); renderManual(); const title = screen.getByPlaceholderText("Issue title"); @@ -1215,6 +1240,21 @@ describe("CreateIssueModal", () => { expect(mockCreateIssue).not.toHaveBeenCalled(); }); + it("blocks the title send chord while an upload is in flight", async () => { + const user = userEvent.setup(); + renderManual(); + const title = screen.getByPlaceholderText("Issue title"); + await user.type(title, "Has a screenshot"); + + startPendingUpload(); + + // The chord bypasses the button, so the handler's own gate is what stops + // this from serializing a description whose image hasn't landed yet. + fireEvent.keyDown(title, { key: "Enter", metaKey: true }); + await Promise.resolve(); + expect(mockCreateIssue).not.toHaveBeenCalled(); + }); + it("blocks Switch to Agent while an upload is in flight", async () => { const user = userEvent.setup(); const onSwitchMode = vi.fn(); @@ -1231,4 +1271,132 @@ describe("CreateIssueModal", () => { expect(onSwitchMode).not.toHaveBeenCalled(); }); }); + + // MUL-4931 — manual create had no submit shortcut at all, while agent create + // has had one all along. + describe("send shortcut", () => { + function renderManual() { + return renderModal( + , + ); + } + + it("creates from the send chord in the title", async () => { + const user = userEvent.setup(); + renderManual(); + const title = screen.getByPlaceholderText("Issue title"); + await user.type(title, "Shortcut from title"); + + fireEvent.keyDown(title, { key: "Enter", metaKey: true }); + + await waitFor(() => expect(mockCreateIssue).toHaveBeenCalledTimes(1)); + expect(mockCreateIssue).toHaveBeenCalledWith( + expect.objectContaining({ title: "Shortcut from title" }), + ); + }); + + it("creates from the send chord in the description", async () => { + const user = userEvent.setup(); + renderManual(); + await user.type(screen.getByPlaceholderText("Issue title"), "Shortcut from body"); + const description = screen.getByPlaceholderText("Add description..."); + await user.type(description, "Body text"); + + fireEvent.keyDown(description, { key: "Enter", ctrlKey: true }); + + await waitFor(() => expect(mockCreateIssue).toHaveBeenCalledTimes(1)); + expect(mockCreateIssue).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Shortcut from body", + description: "Body text", + }), + ); + }); + + it("leaves plain Enter in the description as a newline, not a create", async () => { + const user = userEvent.setup(); + renderManual(); + await user.type(screen.getByPlaceholderText("Issue title"), "Still typing"); + + fireEvent.keyDown(screen.getByPlaceholderText("Add description..."), { key: "Enter" }); + await Promise.resolve(); + expect(mockCreateIssue).not.toHaveBeenCalled(); + }); + + it("focuses the title instead of silently doing nothing when it is empty", async () => { + const user = userEvent.setup(); + renderManual(); + const description = screen.getByPlaceholderText("Add description..."); + await user.type(description, "Body but no title"); + + fireEvent.keyDown(description, { key: "Enter", metaKey: true }); + + await Promise.resolve(); + expect(mockCreateIssue).not.toHaveBeenCalled(); + // The shortcut path can't rely on the button's tooltip, so it has to say + // where the problem is some other way. + expect(screen.getByPlaceholderText("Issue title")).toHaveFocus(); + }); + + it("creates once when the chord is pressed twice in the same tick", async () => { + const user = userEvent.setup(); + // Hold the create open so both presses land inside the in-flight window. + let release!: (v: unknown) => void; + mockCreateIssue.mockImplementationOnce( + () => new Promise((resolve) => { release = resolve; }), + ); + renderManual(); + const title = screen.getByPlaceholderText("Issue title"); + await user.type(title, "Double tap"); + + // Both presses are dispatched inside ONE act, so React cannot re-render + // between them and the second handler still closes over `submitting === + // false`. `fireEvent` would flush in between and hide the race — only a + // ref that flips synchronously stops the second create here. + await act(async () => { + const press = () => + title.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), + ); + press(); + press(); + }); + + await act(async () => { + release({ id: "issue-1", identifier: "MUL-1", title: "Double tap", status: "todo" }); + }); + expect(mockCreateIssue).toHaveBeenCalledTimes(1); + }); + + it("renders the send keycaps on Create without renaming the button", async () => { + const user = userEvent.setup(); + renderManual(); + + // Accessible name must stay the label alone — the keycaps are decorative. + expect(screen.getByRole("button", { name: "Create Issue" })).toBeInTheDocument(); + expect(document.querySelector("[data-slot='shortcut-keycaps']")).toBeInTheDocument(); + + // And the affordance survives the empty → filled transition. + await user.type(screen.getByPlaceholderText("Issue title"), "Now valid"); + expect(screen.getByRole("button", { name: "Create Issue" })).toBeInTheDocument(); + expect(document.querySelector("[data-slot='shortcut-keycaps']")).toBeInTheDocument(); + }); + + it("keeps Create focusable via aria-disabled while the title is empty", () => { + renderManual(); + const createButton = screen.getByRole("button", { name: "Create Issue" }); + + // Native `disabled` would drop it out of the tab order, hiding the + // "Enter a title to create" tooltip from keyboard and SR users. + expect(createButton).toHaveAttribute("aria-disabled", "true"); + expect(createButton).not.toBeDisabled(); + createButton.focus(); + expect(createButton).toHaveFocus(); + }); + }); }); diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index faf6cac1a49..2226e88e88e 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -50,7 +50,9 @@ import { import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@multica/ui/components/ui/tooltip"; import { Button } from "@multica/ui/components/ui/button"; import { Switch } from "@multica/ui/components/ui/switch"; -import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay, useUploadGate, useEditorUpload } from "../editor"; +import { ContentEditor, type ContentEditorRef, TitleEditor, type TitleEditorRef, useFileDropZone, FileDropOverlay, useUploadGate, useEditorUpload } from "../editor"; +import { useShortcut } from "@multica/core/shortcuts"; +import { ShortcutKeycaps } from "../common/shortcut-keycaps"; import { StatusIcon, StatusPicker, PriorityIcon, PriorityPicker, StagePicker, AssigneePicker, StartDatePicker, DueDatePicker, LabelPicker } from "../issues/components"; import { maxSiblingStage } from "../issues/components/pickers/stage-picker"; import { ProjectPicker } from "../projects/components/project-picker"; @@ -230,8 +232,10 @@ export function ManualCreatePanel({ const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen); const manualFields = useIssueCreateSettingsStore((s) => s.manualCreateFields); + const sendShortcut = useShortcut("send"); const [title, setTitle] = useState(draft.title); const [formResetKey, setFormResetKey] = useState(0); + const titleEditorRef = useRef(null); const descEditorRef = useRef(null); const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({ onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)), @@ -241,6 +245,7 @@ export function ManualCreatePanel({ (data?.priority as IssuePriority | undefined) ?? draft.priority, ); const [submitting, setSubmitting] = useState(false); + const submittingRef = useRef(false); const [assigneeType, setAssigneeType] = useState(() => { if (data && "assignee_type" in data) { return (data.assignee_type as IssueAssigneeType | null) ?? undefined; @@ -421,8 +426,19 @@ export function ManualCreatePanel({ }; const handleSubmit = async () => { - if (!title.trim() || submitting) return; + // Single-flight on a ref, not `submitting`: two shortcut presses in one + // tick both read the pre-update state and would each fire a create. The + // ref flips synchronously, so the second press loses the race. + if (submittingRef.current) return; + if (!title.trim()) { + // The shortcut paths bypass the button entirely, so an empty title would + // otherwise be a silent no-op. Put the caret where the fix is; the + // button's tooltip says the rest. + titleEditorRef.current?.focus(); + return; + } if (uploadGate.isBlocked()) return; + submittingRef.current = true; setSubmitting(true); try { const description = descEditorRef.current?.getMarkdown()?.trim() || undefined; @@ -627,6 +643,7 @@ export function ManualCreatePanel({ : t(($) => $.create_issue.toast_failed), ); } finally { + submittingRef.current = false; setSubmitting(false); } }; @@ -682,6 +699,54 @@ export function ManualCreatePanel({ }); }; + // One state for the button and the keyboard paths, so a rendered affordance + // can never disagree with what `handleSubmit` will actually do. + const submitState: "submitting" | "uploading" | "missing_title" | "ready" = + submitting + ? "submitting" + : uploadGate.uploading + ? "uploading" + : !title.trim() + ? "missing_title" + : "ready"; + const submitBusy = submitState === "submitting" || submitState === "uploading"; + + // Built once and reused by both footer branches: rendering a separate Button + // per branch is how the keycaps drifted out of one of them before. + const createButton = ( + + ); + return ( <> {t(($) => $.create_issue.sr_manual)} @@ -733,11 +798,14 @@ export function ManualCreatePanel({
$.create_issue.title_placeholder)} className="text-lg font-semibold" onChange={(v) => updateTitle(v)} + // Chord only — plain Enter still just ends title editing (#5532). + onSubmitShortcut={handleSubmit} />
@@ -748,6 +816,7 @@ export function ManualCreatePanel({ defaultValue={draft.description} placeholder={t(($) => $.create_issue.description_placeholder)} onUpdate={(md) => setDraft({ description: md })} + onSubmit={handleSubmit} onUploadFile={handleUpload} onUploadingChange={uploadGate.onUploadingChange} debounceMs={500} @@ -1137,27 +1206,18 @@ export function ManualCreatePanel({ /> {t(($) => $.create_issue.create_another)} - {!title.trim() ? ( + {submitState === "missing_title" ? ( - } /> + {/* No `` wrapper needed now: aria-disabled leaves the + button focusable and hoverable, so it can anchor its own + tooltip. */} + {t(($) => $.create_issue.title_required)} ) : ( - + createButton )}
diff --git a/packages/views/modals/quick-create-issue.tsx b/packages/views/modals/quick-create-issue.tsx index 6d0870a5a82..546c996d628 100644 --- a/packages/views/modals/quick-create-issue.tsx +++ b/packages/views/modals/quick-create-issue.tsx @@ -306,6 +306,9 @@ export function AgentCreatePanel({ const editorRef = useRef(null); const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0); const [submitting, setSubmitting] = useState(false); + // See create-issue's handleSubmit: `submitting` state can't gate two presses + // landing in the same tick, and ⌘+Enter makes that trivial to hit. + const submittingRef = useRef(false); const [justSent, setJustSent] = useState(false); const [sentCount, setSentCount] = useState(0); const [error, setError] = useState(null); @@ -342,7 +345,7 @@ export function AgentCreatePanel({ const submit = async () => { const md = editorRef.current?.getMarkdown()?.trim() ?? ""; - if (!md || !actor || submitting || versionBlocked) return; + if (!md || !actor || submittingRef.current || versionBlocked) return; // Submit-time re-read of the queue. Blocking here is what guarantees // `getMarkdown()`'s blob-url strip never erases a pasted/dropped image // whose attachment id hasn't reached `pendingAttachments` yet — the @@ -351,6 +354,7 @@ export function AgentCreatePanel({ const activeAttachmentIds = pendingAttachments .filter((a) => contentReferencesAttachment(md, a)) .map((a) => a.id); + submittingRef.current = true; setSubmitting(true); setError(null); try { @@ -424,6 +428,7 @@ export function AgentCreatePanel({ : t(($) => $.create_issue.agent.error_unknown), ); } finally { + submittingRef.current = false; setSubmitting(false); } }; @@ -713,7 +718,8 @@ export function AgentCreatePanel({ onClick={submit} disabled={!hasContent || !actor || submitting || versionBlocked || uploadGate.uploading} aria-disabled={uploadGate.uploading || undefined} - aria-busy={uploadGate.uploading || undefined} + // Sending is a busy state too, not just uploading. + aria-busy={uploadGate.uploading || submitting || undefined} title={ versionBlocked ? t(($) => $.create_issue.agent.version_blocked_tooltip, { min: versionCheck.min }) From 727e5671c9cff7ab4dac05a5a5eaa5c5db57e0f3 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:02:11 +0800 Subject: [PATCH 2/2] test(views): cover quick-create single-flight + aria-disabled visuals (MUL-4931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #5583. The quick-create ref guard shipped without a regression test, so the claim that both create panels were covered was only true of manual create. That path files a real issue, so a double-fire is a duplicate issue rather than a glitch. Add the same-tick regression: both presses dispatch inside one act(), and it fails against the old state-based guard (expected 1 call, got 2). The Button base only styles native `disabled` (disabled:opacity-50 / disabled:pointer-events-none), so the aria-disabled empty-title button stayed a fully lit, pressable-looking primary. Add local aria-disabled opacity, cursor, and press-animation styles, with no pointer-events-none — that would break the tooltip hover and the click that focuses the title. Verified against compiled Tailwind output: aria-disabled:active:translate-y-0 and the base's active:not-aria-[haspopup]:translate-y-px have equal specificity and the override emits later, so it wins without !important. Co-Authored-By: Claude Opus 4.8 Co-authored-by: multica-agent --- packages/views/modals/create-issue.test.tsx | 13 +++++++ packages/views/modals/create-issue.tsx | 5 +++ .../views/modals/quick-create-issue.test.tsx | 38 ++++++++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/views/modals/create-issue.test.tsx b/packages/views/modals/create-issue.test.tsx index b3044aa7509..ea9bc3eaa96 100644 --- a/packages/views/modals/create-issue.test.tsx +++ b/packages/views/modals/create-issue.test.tsx @@ -1398,5 +1398,18 @@ describe("CreateIssueModal", () => { createButton.focus(); expect(createButton).toHaveFocus(); }); + + it("carries its own disabled visuals, since the Button base only styles native disabled", () => { + renderManual(); + const createButton = screen.getByRole("button", { name: "Create Issue" }); + + // Without these the control reads as a live primary button while + // aria-disabled. `pointer-events-none` is deliberately absent: it would + // kill the tooltip hover and the click that focuses the title. + expect(createButton.className).toContain("aria-disabled:opacity-50"); + expect(createButton.className).toContain("aria-disabled:cursor-not-allowed"); + expect(createButton.className).toContain("aria-disabled:active:translate-y-0"); + expect(createButton.className).not.toContain("aria-disabled:pointer-events-none"); + }); }); }); diff --git a/packages/views/modals/create-issue.tsx b/packages/views/modals/create-issue.tsx index 2226e88e88e..208bf4789ec 100644 --- a/packages/views/modals/create-issue.tsx +++ b/packages/views/modals/create-issue.tsx @@ -724,6 +724,11 @@ export function ManualCreatePanel({ disabled={submitBusy} aria-disabled={submitState === "missing_title" || undefined} aria-busy={submitBusy || undefined} + // The Button base only dims/blocks on native `disabled`, so aria-disabled + // would otherwise stay a fully lit, pressable-looking primary button. + // Deliberately no `pointer-events-none`: this control still has to hover + // its tooltip and take the click that focuses the title. + className="aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:active:translate-y-0" > {submitState === "submitting" ? ( t(($) => $.create_issue.submitting) diff --git a/packages/views/modals/quick-create-issue.test.tsx b/packages/views/modals/quick-create-issue.test.tsx index a32d285a6c8..58cad86d4da 100644 --- a/packages/views/modals/quick-create-issue.test.tsx +++ b/packages/views/modals/quick-create-issue.test.tsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const mockQuickCreateIssue = vi.hoisted(() => vi.fn()); @@ -709,4 +709,40 @@ describe("AgentCreatePanel", () => { expect(screen.getByRole("button", { name: "Upload file" })).not.toBeDisabled(); }); }); + + // MUL-4931 — this path files a real issue, so a double-fire is a duplicate + // issue, not a cosmetic glitch. `submitting` is state: two chords landing in + // one tick both read the pre-update value, so only a synchronously-flipped + // ref can gate it. Mirrors the manual-create regression. + describe("send shortcut single-flight", () => { + it("creates once when the send chord fires twice in the same tick", async () => { + // Hold the request open so both presses land inside the in-flight window. + let release!: (v: unknown) => void; + mockQuickCreateIssue.mockImplementationOnce( + () => new Promise((resolve) => { release = resolve; }), + ); + + renderPanel({ onClose: vi.fn(), isExpanded: false, setIsExpanded: vi.fn() }); + + const editor = screen.getByPlaceholderText( + 'Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"', + ); + + // Both presses inside ONE act: React cannot re-render between them, so + // the second handler still closes over `submitting === false`. fireEvent + // would flush in between and hide the race entirely. + await act(async () => { + const press = () => + editor.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), + ); + press(); + press(); + }); + + await act(async () => { release(undefined); }); + + expect(mockQuickCreateIssue).toHaveBeenCalledTimes(1); + }); + }); });