From b400f76647515b34a052bc7a04ddcb96785451cc Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:40:18 -0600 Subject: [PATCH] feat(ui): submit task composers with Enter by default (#19272) Use Enter to submit task replies and new tasks, with Shift+Enter for a new line. Add an experimental preference to require Command/Ctrl+Enter. Preserve autocomplete selection and IME confirmation, and retain the modifier shortcut until saved settings load. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 5 + doc/SPEC-implementation.md | 1 + doc/SPEC.md | 4 + packages/shared/src/feature-catalog.ts | 8 ++ packages/shared/src/types/instance.ts | 2 + .../shared/src/validators/instance.test.ts | 8 ++ packages/shared/src/validators/instance.ts | 1 + .../instance-settings-service.test.ts | 9 ++ server/src/services/instance-settings.ts | 2 + ui/src/components/CommentThread.tsx | 3 + ui/src/components/IssueChatThread.tsx | 3 + ui/src/components/MarkdownEditor.test.tsx | 56 +++++++++ ui/src/components/MarkdownEditor.tsx | 37 +++++- .../task-chat/TaskChatComposer.test.tsx | 117 +++++++++++++++++- .../components/task-chat/TaskChatComposer.tsx | 5 +- ui/src/hooks/useComposerSubmitKey.ts | 23 ++++ ui/src/pages/DesignGuide.tsx | 2 +- .../InstanceExperimentalSettings.test.tsx | 20 +++ ui/src/pages/InstanceExperimentalSettings.tsx | 12 ++ 19 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 ui/src/hooks/useComposerSubmitKey.ts diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 5cf4795e89c..38a1de02587 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -141,6 +141,11 @@ the latest agent that assigned it to them. If no assignment is available, it use the latest other commenter, or leaves the recipient empty. A manual recipient choice stays selected while task data refreshes. +Task reply and new-task composers submit with Enter by default; Shift+Enter +adds a new line. Settings → Experimental → **Command/Ctrl+Enter to Submit** +restores the previous behavior, where Enter adds a new line and Command/Ctrl+Enter +submits. + ### Task collection creation filters The task Filters menu also offers **Routine tasks**. It selects tasks with diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index f82412ba4fb..fe86ceebf86 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -39,6 +39,7 @@ These decisions close open questions from `SPEC.md` for V1. | Org graph | Strict tree (`reports_to` nullable root); no multi-manager reporting | | Visibility | Issues remain company-open by default. Opt-in `private` issues use task ACLs; deployment exposure flags remain independent of work-object privacy. | | Communication | Tasks + comments only (no separate chat system) | +| Composer shortcuts | Task replies and new tasks submit with Enter; Shift+Enter adds a new line. The off-by-default `enableLegacyComposerSubmit` preference in Experimental settings restores Command/Ctrl+Enter submission and Enter for a new line. | | Experimental voice transport | Speko browser voice can create or resume a task through the existing chat-connection delivery/publication pipeline. The selected Paperclip agent retains execution authority. Phone support remains gated on live continuation qualification. | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | | Task watchdogs | A task watchdog is an explicitly configured, issue-subtree-scoped verification and recovery capacity. It may restore live task paths inside the watched subtree; for issue-thread interaction resolution it is an ordinary agent subject to the same audience and containment checks, not board authority, active-run output monitoring, or general liveness recovery. | diff --git a/doc/SPEC.md b/doc/SPEC.md index 28892d5e76a..49f2afafecc 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -691,6 +691,10 @@ The `create_project` runtime tool uses the normal project API with durable idemp Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive. +Task replies and new tasks submit with Enter by default; Shift+Enter adds a new +line. The **Command/Ctrl+Enter to Submit** experiment restores the previous +keyboard behavior. + ### Paused task messages A paused task takes over the composer with an amber notice and a Resume action. diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index fedbeca4b76..a6e3701701f 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -237,6 +237,14 @@ export const INSTANCE_FEATURE_CATALOG: Record { + it("defaults legacy composer submission off and accepts only explicit boolean patches", () => { + expect(instanceExperimentalSettingsSchema.parse({}).enableLegacyComposerSubmit).toBe(false); + expect(patchInstanceExperimentalSettingsSchema.parse({})).not.toHaveProperty("enableLegacyComposerSubmit"); + for (const enabled of [true, false]) { + expect(patchInstanceExperimentalSettingsSchema.parse({ enableLegacyComposerSubmit: enabled })).toEqual({ enableLegacyComposerSubmit: enabled }); + } + expect(patchInstanceExperimentalSettingsSchema.safeParse({ enableLegacyComposerSubmit: "true" }).success).toBe(false); + }); it("keeps Notebook disabled by default and accepts only explicit boolean rollout values", () => { expect(instanceExperimentalSettingsSchema.parse({ enableApps: true, enableMemoryConnectors: true }).enablePaperclipNotebook).toBe(false); expect(patchInstanceExperimentalSettingsSchema.parse({})).toEqual({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index ef6673db35f..c8e07a4626a 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -69,6 +69,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableArtifactsV1: z.boolean().default(true), enableConferenceRoomChat: z.boolean().default(false), enableClassicTaskInterface: z.boolean().default(false), + enableLegacyComposerSubmit: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 2399dd67724..2597523f15f 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,14 @@ import { import type { ManagedInstanceConfig } from "../services/managed-config.js"; describe("instance settings service", () => { + it("retains the legacy composer preference through saved and unrelated settings updates", () => { + expect(normalizeExperimentalSettings({}).enableLegacyComposerSubmit).toBe(false); + const enabled = applyExperimentalSettingsPatch({}, { enableLegacyComposerSubmit: true }); + const saved = JSON.parse(JSON.stringify(enabled)); + expect(normalizeExperimentalSettings(saved).enableLegacyComposerSubmit).toBe(true); + expect(applyExperimentalSettingsPatch(saved, { enableCases: true }).enableLegacyComposerSubmit).toBe(true); + expect(applyExperimentalSettingsPatch(saved, { enableLegacyComposerSubmit: false }).enableLegacyComposerSubmit).toBe(false); + }); it("defaults remote workspace enrollment on while preserving explicit operator choices", () => { expect(instanceExperimentalSettingsSchema.parse({}).remoteWorkspaceStorageEnabled).toBe(true); expect(INSTANCE_FEATURE_CATALOG.remoteWorkspaceStorageEnabled).toMatchObject({ cloudDefault: true, selfHostedDefault: true }); @@ -118,6 +126,7 @@ describe("instance settings service", () => { enableGoogleWorkspaceConnector: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, + enableLegacyComposerSubmit: false, enableExternalObjects: false, enableSmokeLab: false, enableCases: false, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index fcde8b8fe61..91615369003 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -272,6 +272,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableArtifactsV1: parsed.data.enableArtifactsV1 ?? true, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false, + enableLegacyComposerSubmit: parsed.data.enableLegacyComposerSubmit ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, @@ -330,6 +331,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableArtifactsV1: true, enableConferenceRoomChat: false, enableClassicTaskInterface: false, + enableLegacyComposerSubmit: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 287f350e173..40aae1d7540 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -27,6 +27,7 @@ import { cn, formatDateTime } from "../lib/utils"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { copyTextToClipboard } from "../lib/clipboard"; import { PluginSlotOutlet } from "@/plugins/slots"; +import { useComposerSubmitKey } from "@/hooks/useComposerSubmitKey"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; @@ -748,6 +749,7 @@ export function CommentThread({ composerDisabledReason = null, externalReferences, }: CommentThreadProps) { + const submitKey = useComposerSubmitKey(); const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); @@ -1030,6 +1032,7 @@ export function CommentThread({ placeholder="Leave a comment..." mentions={mentions} onSubmit={handleSubmit} + submitKey={submitKey} imageUploadHandler={imageUploadHandler} contentClassName="min-h-(--sz-60px) text-sm" /> diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 6d2a79cd591..d7cf97d7bc0 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -227,6 +227,7 @@ import { } from "../lib/transcriptPresentation"; import { buildAgentMentionHref } from "@paperclipai/shared"; import { useComposerStop } from "@/hooks/useComposerStop"; +import { useComposerSubmitKey } from "@/hooks/useComposerSubmitKey"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { liveBlueBadge } from "../lib/status-colors"; import { @@ -4691,6 +4692,7 @@ const IssueChatComposer = forwardRef< forwardedRef, ) { const stopControl = useComposerStop(onStop, stopPending); + const submitKey = useComposerSubmitKey(); const restoredRecovery = useMemo(() => { const key = sharedDraftKey && loadDraftRecoveryKey(sharedDraftKey); return key ? { sourceKey: sharedDraftKey!, key, persisted: true } : null; @@ -5537,6 +5539,7 @@ const IssueChatComposer = forwardRef< placeholder="Reply" mentions={mentions} onSubmit={handleSubmit} + submitKey={submitKey} imageUploadHandler={ canAcceptFiles ? async (file) => { diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index ed7193996b9..b6c37e2234d 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -351,6 +351,44 @@ describe("MarkdownEditor", () => { mdxEditorMockState.suppressHtmlProcessingValues = []; }); + it.each([false, true])("handles submission, newlines, and IME confirmation (raw fallback=%s)", async (fallback) => { + mdxEditorMockState.emitMountParseError = fallback; + const onSubmit = vi.fn(); + const root = createRoot(container); + await act(async () => { + root.render( {}} onSubmit={onSubmit} submitKey="enter" />); + }); + await flush(); + if (fallback) { + await vi.waitFor(() => expect(container.querySelector("textarea")).not.toBeNull()); + } + const input = container.querySelector(fallback ? "textarea" : '[contenteditable="true"]')!; + for (const modifiers of [{ shiftKey: true }, { isComposing: true }, { keyCode: 229 }]) { + const event = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...modifiers }); + await act(() => { input.dispatchEvent(event); }); + expect(event.defaultPrevented).toBe(false); + } + expect(onSubmit).not.toHaveBeenCalled(); + const enter = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + await act(() => { input.dispatchEvent(enter); }); + expect(enter.defaultPrevented).toBe(true); + expect(onSubmit).toHaveBeenCalledTimes(1); + await act(() => root.unmount()); + }); + + it("keeps ordinary document editors on Command/Ctrl+Enter", async () => { + const onSubmit = vi.fn(); + const root = createRoot(container); + await act(async () => { root.render( {}} onSubmit={onSubmit} />); }); + await flush(); + const input = container.querySelector('[contenteditable="true"]')!; + await act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); }); + expect(onSubmit).not.toHaveBeenCalled(); + await act(() => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, metaKey: true })); }); + expect(onSubmit).toHaveBeenCalledTimes(1); + await act(() => root.unmount()); + }); + it("applies async external value updates once the editor ref becomes ready", async () => { const root = createRoot(container); @@ -1593,6 +1631,7 @@ describe("MarkdownEditor", () => { }, ], matchText = "Paperclip App", + onSubmit?: () => void, ): Promise<{ option: HTMLButtonElement; root: ReturnType; menu: HTMLElement }> { const root = createRoot(container); @@ -1602,6 +1641,8 @@ describe("MarkdownEditor", () => { value="@Pap" onChange={handleChange} mentions={mentions} + onSubmit={onSubmit} + submitKey="enter" />, ); }); @@ -1633,6 +1674,21 @@ describe("MarkdownEditor", () => { return { option: option!, root, menu: menu! }; } + it("accepts a mention on Enter before submitting the message", async () => { + const onSubmit = vi.fn(); + const handleChange = vi.fn(); + const { root } = await openMentionMenuFor(handleChange, undefined, undefined, onSubmit); + const editable = container.querySelector('[contenteditable="true"]')!; + const shiftEnter = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }); + await act(() => { editable.dispatchEvent(shiftEnter); }); + expect(shiftEnter.defaultPrevented).toBe(false); + expect(handleChange).not.toHaveBeenCalled(); + await act(() => { editable.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); }); + expect(onSubmit).not.toHaveBeenCalled(); + expect(handleChange).toHaveBeenCalled(); + await act(() => { root.unmount(); }); + }); + it("accepts mention selection from a touch tap", async () => { const handleChange = vi.fn(); const { option, root } = await openMentionMenuFor(handleChange); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index c05cc77413a..487d3add984 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -4,6 +4,7 @@ import { Component, type ClipboardEvent, type ErrorInfo, + type KeyboardEvent, forwardRef, useCallback, useEffect, @@ -98,8 +99,10 @@ interface MarkdownEditorProps { mentions?: MentionOption[]; /** Capability-aware action commands supplied by the owning composer. */ actionCommands?: SlashCommandOption[]; - /** Called on Cmd/Ctrl+Enter */ + /** Called on the configured submit shortcut. */ onSubmit?: () => void; + /** Task composers use Enter; document editors retain Cmd/Ctrl+Enter by default. */ + submitKey?: "enter" | "mod-enter"; /** Render the rich editor without allowing edits. */ readOnly?: boolean; } @@ -743,9 +746,19 @@ export const MarkdownEditor = forwardRef mentions, actionCommands = [], onSubmit, + submitKey = "mod-enter", readOnly = false, }: MarkdownEditorProps, forwardedRef) { const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]); + function isSubmitShortcut(event: KeyboardEvent): boolean { + return !readOnly + && event.key === "Enter" + && !event.nativeEvent.isComposing + && event.keyCode !== 229 + && !event.shiftKey + && !event.altKey + && (submitKey === "enter" || event.metaKey || event.ctrlKey); + } const { slashCommands: sharedSlashCommands } = useEditorAutocomplete(); const slashCommands = useMemo( () => [...actionCommands, ...sharedSlashCommands], @@ -1368,8 +1381,9 @@ export const MarkdownEditor = forwardRef }} onBlur={() => onBlur?.()} onKeyDown={(event) => { - if (onSubmit && event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + if (onSubmit && isSubmitShortcut(event)) { event.preventDefault(); + event.stopPropagation(); onSubmit(); } }} @@ -1404,7 +1418,8 @@ export const MarkdownEditor = forwardRef } }} onKeyDownCapture={(e) => { - if (readOnly) return; + if (readOnly || e.nativeEvent.isComposing || e.keyCode === 229) return; + if (e.key === "Enter" && (e.shiftKey || e.altKey)) return; if ( (e.key === "Backspace" || e.key === "Delete") && e.target instanceof HTMLElement @@ -1413,8 +1428,8 @@ export const MarkdownEditor = forwardRef // Lexical handles deletion on keydown and may suppress DOM input. initialChildOnChangeRef.current = false; } - // Cmd/Ctrl+Enter to submit - if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + // An explicit Cmd/Ctrl+Enter sends even while autocomplete is open. + if (onSubmit && isSubmitShortcut(e) && (e.metaKey || e.ctrlKey)) { e.preventDefault(); e.stopPropagation(); onSubmit(); @@ -1468,6 +1483,18 @@ export const MarkdownEditor = forwardRef } } } + + // Leave an unselected slash menu's Enter with the editor. + if ( + onSubmit && isSubmitShortcut(e) + && !(mentionActive && filteredMentions.length > 0) + && e.target instanceof HTMLElement + && e.target.closest('[contenteditable="true"]') + ) { + e.preventDefault(); + e.stopPropagation(); + onSubmit(); + } }} onDragEnter={(evt) => { if (readOnly) return; diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 97f6056ac17..d033444878b 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildAgentMentionHref, buildSkillMentionHref, + instanceExperimentalSettingsSchema, } from "@paperclipai/shared"; import { parseRunnerGoalCommand, TaskChatComposer } from "./TaskChatComposer"; import { ComposerAddMenu } from "./ComposerAddMenu"; @@ -14,6 +15,7 @@ import { QuestionForm } from "./QuestionForm"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Agent, HeartbeatRun } from "@paperclipai/shared"; import { agentsApi } from "@/api/agents"; +import { instanceSettingsApi } from "@/api/instanceSettings"; import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation"; import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft"; import { @@ -1095,12 +1097,21 @@ describe("TaskChatComposer", () => { expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined, undefined, expect.any(String)); }); - it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => { + it.each(["standard", "ask", "planning"] as const)("submits %s messages on Enter by default", async (workMode) => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + typeText("hello"); + expect(sendButton().title).toBe("Send (Enter)"); + pressKey("Enter"); + await flushAsync(); + expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined, undefined, expect.any(String)); + }); + + it("does not submit on Shift+Enter (newline stays with the editor)", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); typeText("line one"); - pressKey("Enter"); pressKey("Enter", { shiftKey: true }); await flushAsync(); @@ -1108,6 +1119,87 @@ describe("TaskChatComposer", () => { expect(editable().textContent).toBe("line one"); }); + it.each([false, true])("uses the experimental shortcut preference for new tasks (legacy=%s)", async (legacy) => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + client.setQueryData(queryKeys.instance.experimentalSettings, { enableLegacyComposerSubmit: legacy }); + const creation = { + value: "Create this task", onChange: vi.fn(), onSubmit: vi.fn().mockResolvedValue(undefined), + submitLabel: "Create task", onSelectFiles: vi.fn(), runSettings: null, onRunSettingsChange: vi.fn(), + }; + render(); + expect(sendButton().title).toBe(`Create task (${legacy ? "⌘/Ctrl+Enter" : "Enter"})`); + pressKey("Enter", { shiftKey: true }); + expect(creation.onSubmit).not.toHaveBeenCalled(); + pressKey("Enter"); + await flushAsync(); + if (legacy) { + expect(creation.onSubmit).not.toHaveBeenCalled(); + pressKey("Enter", { metaKey: true }); + await flushAsync(); + } + expect(creation.onSubmit).toHaveBeenCalledExactlyOnceWith(creation.value, "standard", null); + }); + + it.each([false, true])("waits for the saved shortcut preference before enabling Enter submission (legacy=%s)", async (legacy) => { + let resolveSettings!: (settings: Awaited>) => void; + const pendingSettings = new Promise>>((resolve) => { + resolveSettings = resolve; + }); + const getSettings = vi.spyOn(instanceSettingsApi, "getExperimental").mockReturnValue(pendingSettings); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + const onAdd = vi.fn().mockResolvedValue(undefined); + try { + render(); + typeText("Keep this draft"); + pressKey("Enter"); + await flushAsync(); + expect(onAdd).not.toHaveBeenCalled(); + expect(editable().textContent).toBe("Keep this draft"); + + await act(async () => { + resolveSettings(instanceExperimentalSettingsSchema.parse({ enableLegacyComposerSubmit: legacy })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await flushAsync(); + pressKey("Enter"); + await flushAsync(); + if (legacy) { + expect(onAdd).not.toHaveBeenCalled(); + pressKey("Enter", { metaKey: true }); + await flushAsync(); + } + expect(onAdd).toHaveBeenCalledTimes(1); + } finally { + getSettings.mockRestore(); + } + }); + + it("restores the old shortcut for task replies and updates the preference without remounting", async () => { + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); + client.setQueryData(queryKeys.instance.experimentalSettings, { enableLegacyComposerSubmit: true }); + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + typeText("first message"); + expect(sendButton().title).toBe("Send (⌘/Ctrl+Enter)"); + pressKey("Enter"); + pressKey("Enter", { shiftKey: true }); + await flushAsync(); + expect(onAdd).not.toHaveBeenCalled(); + pressKey("Enter", { ctrlKey: true }); + await flushAsync(); + expect(onAdd).toHaveBeenCalledTimes(1); + + await act(async () => { + client.setQueryData(queryKeys.instance.experimentalSettings, { enableLegacyComposerSubmit: false }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + typeText("second message"); + expect(sendButton().title).toBe("Send (Enter)"); + pressKey("Enter"); + await flushAsync(); + expect(onAdd).toHaveBeenCalledTimes(2); + }); + it("cycles the pending mode with Shift+Tab and applies it on submit", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); const onWorkModeChange = vi.fn().mockResolvedValue(undefined); @@ -1618,6 +1710,27 @@ describe("TaskChatComposer", () => { expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined, undefined, expect.any(String)); }); + it("does not submit an unselected slash menu with Enter", async () => { + const onAdd = vi.fn().mockResolvedValue(undefined); + render(); + typeText("/dep"); + await placeCaretAtEnd(); + autocompleteOption("/deploy"); + pressKey("Enter"); + await flushAsync(); + expect(onAdd).not.toHaveBeenCalled(); + expect(editable().textContent).toBe("/dep"); + + pressKey("ArrowDown"); + pressKey("Enter"); + await flushAsync(); + expect(onAdd).not.toHaveBeenCalled(); + expect(editable().textContent).toBe(`[/deploy](${SLASH_HREF}) `); + pressKey("Enter", { metaKey: true }); + await flushAsync(); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + it("inserts a /-command from the autocomplete menu", async () => { const onAdd = vi.fn().mockResolvedValue(undefined); render(); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 3a9fc1e2d75..df9ee2ea889 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -13,6 +13,7 @@ import { import { cn } from "@/lib/utils"; import { taskChatComposerSurfaceClasses } from "./composer-surface"; import { useComposerStop } from "@/hooks/useComposerStop"; +import { useComposerSubmitKey } from "@/hooks/useComposerSubmitKey"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { DRAFT_DEBOUNCE_MS, @@ -426,6 +427,7 @@ export function TaskChatComposer({ onRunnerGoalReassign, }: TaskChatComposerProps) { const streamlined = useStreamlinedTaskChatPresentation(); + const submitKey = useComposerSubmitKey(); const stopControl = useComposerStop(onStop, stopPending, confirmedStoppedRunIds); const [localBody, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : "")); const body = creation ? creation.value : localBody; @@ -1409,6 +1411,7 @@ export function TaskChatComposer({ disabled, }] : [goalCommandOption]} onSubmit={() => void submit()} + submitKey={submitKey} fileDropTarget="parent" bordered={false} className={cn(disabled && "opacity-60")} @@ -1648,7 +1651,7 @@ export function TaskChatComposer({ ? "Waiting for upload to finish" : uploadFailed ? "Remove the failed attachment to send" - : creation ? `${creation.submitLabel} (⌘+Enter)` : "Send (⌘+Enter)" + : `${creation?.submitLabel ?? "Send"} (${submitKey === "mod-enter" ? "⌘/Ctrl+Enter" : "Enter"})` } aria-label={ showStop diff --git a/ui/src/hooks/useComposerSubmitKey.ts b/ui/src/hooks/useComposerSubmitKey.ts new file mode 100644 index 00000000000..4a34dfe1e33 --- /dev/null +++ b/ui/src/hooks/useComposerSubmitKey.ts @@ -0,0 +1,23 @@ +import { useContext } from "react"; +import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "@/lib/queryKeys"; + +let detachedClient: QueryClient | null = null; + +/** Shared task-composer preference, including isolated previews without a query provider. */ +export function useComposerSubmitKey(): "enter" | "mod-enter" { + const contextClient = useContext(QueryClientContext); + if (!contextClient) detachedClient ??= new QueryClient(); + const { data } = useQuery( + { + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + enabled: contextClient != null, + }, + contextClient ?? detachedClient!, + ); + if (!contextClient) return "enter"; + // Keep plain Enter safe until the saved preference is known on a cold load. + return !data || data.enableLegacyComposerSubmit === true ? "mod-enter" : "enter"; +} diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 6e479fd30bf..50b053cf222 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -2068,7 +2068,7 @@ export function DesignGuide() { ["[", "Toggle Sidebar"], ["]", "Toggle Properties Panel"], - ["Cmd+Enter / Ctrl+Enter", "Submit markdown comment"], + ["Enter / Shift+Enter", "Submit task message / add a new line (Command/Ctrl+Enter is available in Experimental settings)"], ].map(([key, desc]) => (
{desc} diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index f255a769472..fc00027320b 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -104,6 +104,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableArtifactsV1: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, + enableLegacyComposerSubmit: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, @@ -477,6 +478,25 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) }); }); + it("saves and restores the legacy composer shortcut", async () => { + await renderPage(); + const selector = 'button[aria-label="Toggle Command/Ctrl+Enter to submit"]'; + const toggle = container.querySelector(selector)!; + expect(toggle.getAttribute("aria-checked")).toBe("false"); + await act(async () => { toggle.click(); }); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableLegacyComposerSubmit: true }); + + flushSync(() => { root?.unmount(); }); + root = null; + await renderPage(); + const restored = container.querySelector(selector)!; + expect(restored.getAttribute("aria-checked")).toBe("true"); + await act(async () => { restored.click(); }); + await flushReact(); + expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableLegacyComposerSubmit: false }); + }); + it("renders and patches the Decisions experimental toggle", async () => { await renderPage(); diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 7737df4d688..c33d413a3c5 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -403,6 +403,18 @@ export function InstanceExperimentalSettings() { /> ) : null} + toggleMutation.mutate({ enableLegacyComposerSubmit: checked })} + disabled={toggleMutation.isPending} + settingKey="enableLegacyComposerSubmit" + managed={managedKeys.enableLegacyComposerSubmit} + ariaLabel="Toggle Command/Ctrl+Enter to submit" + /> +