Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
c2c54ae3c2 fix(chat): unify chat and comment send shortcut to Mod+Enter
Chat input had `submitOnEnter` enabled while the comment editor used
`Mod+Enter`. Two consequences:

- Inconsistent muscle memory between the two inputs.
- In chat, bare Enter sending stole the only key that continues a
  TipTap bullet/ordered list. Shift+Enter falls through to HardBreak
  (a <br> inside the same list item), so bullet lists were stuck at
  one item.

Drop `submitOnEnter` from the chat input so it follows the editor
default. Mod+Enter (⌘↵ / Ctrl+Enter) sends in both places; bare Enter
now continues lists and inserts paragraphs as users expect.

Surface the shortcut on the SubmitButton via a new optional `tooltip`
prop, and route the comment input through SubmitButton instead of an
ad-hoc Button — same affordance, deduped.

Add unit coverage for the submit-shortcut extension that pins
Mod-Enter, the submitOnEnter=false case, IME, and code-block guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-11 14:33:45 +08:00
8 changed files with 154 additions and 24 deletions

View File

@@ -1,7 +1,13 @@
"use client";
import type { ReactNode } from "react";
import { ArrowUp, Loader2, Square } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
interface SubmitButtonProps {
onClick: () => void;
@@ -9,26 +15,53 @@ interface SubmitButtonProps {
loading?: boolean;
running?: boolean;
onStop?: () => void;
/**
* Tooltip shown over the send button when idle. Pass a string or a node
* (e.g. `Send · ⌘↵`). Omit to render no tooltip.
* Callers compose the shortcut hint themselves to keep this component
* free of `@multica/core` (platform-detection) and i18n imports.
*/
tooltip?: ReactNode;
/** Tooltip shown over the stop button while a run is in progress. */
stopTooltip?: ReactNode;
}
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
function SubmitButton({
onClick,
disabled,
loading,
running,
onStop,
tooltip,
stopTooltip,
}: SubmitButtonProps) {
if (running) {
return (
const stopButton = (
<Button size="icon-sm" onClick={onStop}>
<Square className="fill-current" />
</Button>
);
if (!stopTooltip) return stopButton;
return (
<Tooltip>
<TooltipTrigger render={stopButton} />
<TooltipContent side="top">{stopTooltip}</TooltipContent>
</Tooltip>
);
}
return (
const submitButton = (
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<ArrowUp />
)}
{loading ? <Loader2 className="animate-spin" /> : <ArrowUp />}
</Button>
);
if (!tooltip) return submitButton;
return (
<Tooltip>
<TooltipTrigger render={submitButton} />
<TooltipContent side="top">{tooltip}</TooltipContent>
</Tooltip>
);
}
export { SubmitButton, type SubmitButtonProps };

View File

@@ -7,6 +7,7 @@ import { ContentEditor, type ContentEditorRef } from "../../editor";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
import { createLogger } from "@multica/core/logger";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import { useT } from "../../i18n";
const logger = createLogger("chat.ui");
@@ -140,8 +141,10 @@ export function ChatInput({
// Chat is short-form — the floating formatting toolbar is
// more distraction than feature here.
showBubbleMenu={false}
// Enter sends; Shift-Enter inserts a hard break.
submitOnEnter
// Mod+Enter submits. Bare Enter falls through to Tiptap's
// default, which continues lists/quotes and breaks paragraphs.
// Without this, Enter-as-send would steal the only key that
// continues a bullet list, leaving users stuck after one item.
/>
</div>
{leftAdornment && (
@@ -156,6 +159,8 @@ export function ChatInput({
disabled={isEmpty || !!disabled || !!noAgent}
running={isRunning}
onStop={onStop}
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
stopTooltip={t(($) => $.input.stop_tooltip)}
/>
</div>
</div>

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import { getExtensionField } from "@tiptap/core";
import type { Editor } from "@tiptap/core";
import { createSubmitExtension } from "./submit-shortcut";
function getShortcuts(
ext: ReturnType<typeof createSubmitExtension>,
editor: Partial<Editor>,
): Record<string, () => boolean> {
const fn = getExtensionField<
() => Record<string, () => boolean>
>(ext, "addKeyboardShortcuts", {
name: "submitShortcut",
options: {},
storage: {},
editor: editor as Editor,
type: null,
});
return fn?.() ?? {};
}
describe("createSubmitExtension", () => {
const baseEditor = {
view: { composing: false } as unknown as Editor["view"],
isActive: () => false,
} as Partial<Editor>;
it("Mod-Enter always submits", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: false }),
baseEditor,
);
expect(shortcuts["Mod-Enter"]).toBeDefined();
shortcuts["Mod-Enter"]!();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("bare Enter is not bound when submitOnEnter is false", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: false }),
baseEditor,
);
expect(shortcuts.Enter).toBeUndefined();
expect(onSubmit).not.toHaveBeenCalled();
});
it("bare Enter submits when submitOnEnter is true", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
baseEditor,
);
expect(shortcuts.Enter).toBeDefined();
expect(shortcuts.Enter!()).toBe(true);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it("Enter is suppressed during IME composition", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
{
view: { composing: true } as unknown as Editor["view"],
isActive: () => false,
},
);
expect(shortcuts.Enter!()).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
it("Enter is suppressed inside a code block", () => {
const onSubmit = vi.fn(() => true);
const shortcuts = getShortcuts(
createSubmitExtension(onSubmit, { submitOnEnter: true }),
{
view: { composing: false } as unknown as Editor["view"],
isActive: (name: string) => name === "codeBlock",
},
);
expect(shortcuts.Enter!()).toBe(false);
expect(onSubmit).not.toHaveBeenCalled();
});
});

View File

@@ -1,14 +1,15 @@
"use client";
import { useRef, useState, useCallback } from "react";
import { ArrowUp, Loader2, Maximize2, Minimize2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Maximize2, Minimize2 } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { cn } from "@multica/ui/lib/utils";
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { SubmitButton } from "@multica/ui/components/common/submit-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
import { useT } from "../../i18n";
interface CommentInputProps {
@@ -96,17 +97,12 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
size="sm"
onSelect={(file) => editorRef.current?.uploadFile(file)}
/>
<Button
size="icon-sm"
disabled={isEmpty || submitting}
<SubmitButton
onClick={handleSubmit}
>
{submitting ? (
<Loader2 className="animate-spin" />
) : (
<ArrowUp />
)}
</Button>
disabled={isEmpty}
loading={submitting}
tooltip={`${t(($) => $.comment.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
/>
</div>
{isDragOver && <FileDropOverlay />}
</div>

View File

@@ -9,7 +9,9 @@
"placeholder_no_agent": "Create an agent to start chatting",
"placeholder_archived": "This session is archived",
"placeholder_named": "Tell {{name}} what to do…",
"placeholder_default": "Tell me what to do…"
"placeholder_default": "Tell me what to do…",
"send_tooltip": "Send",
"stop_tooltip": "Stop"
},
"message_list": {
"show_details": "Show details",

View File

@@ -173,6 +173,7 @@
"reply_count_one": "{{count}} reply",
"reply_count_other": "{{count}} replies",
"leave_comment_placeholder": "Leave a comment...",
"send_tooltip": "Send",
"expand_tooltip": "Expand",
"collapse_tooltip": "Collapse",
"resolve": {

View File

@@ -8,7 +8,9 @@
"placeholder_no_agent": "创建一个智能体后才能开始对话",
"placeholder_archived": "此会话已归档",
"placeholder_named": "告诉 {{name}} 该做什么...",
"placeholder_default": "告诉我该做什么..."
"placeholder_default": "告诉我该做什么...",
"send_tooltip": "发送",
"stop_tooltip": "停止"
},
"message_list": {
"show_details": "查看详情",

View File

@@ -171,6 +171,7 @@
"delete_failed": "删除评论失败",
"reply_count_other": "{{count}} 条回复",
"leave_comment_placeholder": "留下评论...",
"send_tooltip": "发送",
"expand_tooltip": "展开",
"collapse_tooltip": "收起",
"resolve": {