Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/views/editor/title-editor.shortcut.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TitleEditor defaultValue="A title" onSubmitShortcut={onSubmitShortcut} />);
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(<TitleEditor defaultValue="A title" onSubmitShortcut={onSubmitShortcut} />);
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(<TitleEditor defaultValue="A title" onSubmitShortcut={onSubmitShortcut} />);
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(<TitleEditor defaultValue="A title" onSubmit={onSubmit} />);
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);
});
});
23 changes: 22 additions & 1 deletion packages/views/editor/title-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ vi.mock("@tiptap/react", () => ({
EditorContent: () => <div data-testid="editor-content" />,
}));

import { TitleEditor } from "./title-editor";
import { createShortcutChord } from "@multica/core/shortcuts";
import { TitleEditor, titleShortcutSubmitAllowed } from "./title-editor";

describe("TitleEditor", () => {
beforeEach(() => {
Expand Down Expand Up @@ -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);
});
});
58 changes: 58 additions & 0 deletions packages/views/editor/title-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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>;
}) {
Expand Down Expand Up @@ -89,6 +124,7 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
className,
autoFocus = false,
onSubmit,
onSubmitShortcut,
onBlur,
onChange,
onReady,
Expand All @@ -97,15 +133,22 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
) {
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
Expand All @@ -120,6 +163,21 @@ const TitleEditor = forwardRef<TitleEditorRef, TitleEditorProps>(
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: {
Expand Down
2 changes: 1 addition & 1 deletion packages/views/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
Expand Down
2 changes: 1 addition & 1 deletion packages/views/locales/ja/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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を開きます。" },
Expand Down
2 changes: 1 addition & 1 deletion packages/views/locales/ko/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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를 엽니다." },
Expand Down
2 changes: 1 addition & 1 deletion packages/views/locales/zh-Hans/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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。" },
Expand Down
Loading
Loading