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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-05 11:40:18 -06:00
1 parent b4e7b76635
commit b400f76647
19 files changed
+309 -9

No files matched your search

+5
View File
@@ -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
+1
View File
@@ -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. |
+4
View File
@@ -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.
+8
View File
@@ -237,6 +237,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableLegacyComposerSubmit: {
title: "Command/Ctrl+Enter to Submit",
description:
"Require Command/Ctrl+Enter to submit task replies and new tasks. Enter inserts a new line.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableIssuePlanDecompositions: {
title: "Task Plan Decomposition",
description: "Show accepted-plan decomposition history on task detail pages.",
+2
View File
@@ -97,6 +97,8 @@ export interface InstanceExperimentalSettings {
enableArtifactsV1: boolean;
enableConferenceRoomChat: boolean;
enableClassicTaskInterface: boolean;
/** Require Cmd/Ctrl+Enter to submit task replies and new tasks. */
enableLegacyComposerSubmit: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableExternalObjects: boolean;
@@ -6,6 +6,14 @@ import {
} from "./instance.js";
describe("instance experimental settings validators", () => {
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({});
@@ -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),
@@ -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,
+2
View File
@@ -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,
+3
View File
@@ -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"
/>
+3
View File
@@ -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) => {
+56
View File
@@ -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(<MarkdownEditor value="hello" onChange={() => {}} 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(<MarkdownEditor value="hello" onChange={() => {}} 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<typeof createRoot>; 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);
+32 -5
View File
@@ -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<MarkdownEditorRef, MarkdownEditorProps>
mentions,
actionCommands = [],
onSubmit,
submitKey = "mod-enter",
readOnly = false,
}: MarkdownEditorProps, forwardedRef) {
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
function isSubmitShortcut(event: KeyboardEvent<HTMLElement>): 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<MarkdownEditorRef, MarkdownEditorProps>
}}
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<MarkdownEditorRef, MarkdownEditorProps>
}
}}
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<MarkdownEditorRef, MarkdownEditorProps>
// 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<MarkdownEditorRef, MarkdownEditorProps>
}
}
}
// 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;
@@ -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(<TaskChatComposer onAdd={onAdd} workMode={workMode} />);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
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(<QueryClientProvider client={client}><TaskChatComposer creation={creation} workMode="standard" /></QueryClientProvider>);
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<ReturnType<typeof instanceSettingsApi.getExperimental>>) => void;
const pendingSettings = new Promise<Awaited<ReturnType<typeof instanceSettingsApi.getExperimental>>>((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(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" /></QueryClientProvider>);
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(<QueryClientProvider client={client}><TaskChatComposer onAdd={onAdd} workMode="standard" /></QueryClientProvider>);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
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(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
@@ -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
+23
View File
@@ -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";
}
+1 -1
View File
@@ -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]) => (
<div key={key} className="flex items-center justify-between px-4 py-2">
<span className="text-muted-foreground">{desc}</span>
@@ -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<HTMLButtonElement>(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<HTMLButtonElement>(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();
@@ -403,6 +403,18 @@ export function InstanceExperimentalSettings() {
/>
) : null}
<ExperimentalToggleCard
title="Command/Ctrl+Enter to Submit"
description="Restore the previous keyboard shortcut for task replies and new tasks: Command/Ctrl+Enter submits, and Enter adds a new line."
footnote="Off by default: Enter submits, and Shift+Enter adds a new line."
checked={experimentalQuery.data?.enableLegacyComposerSubmit === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableLegacyComposerSubmit: checked })}
disabled={toggleMutation.isPending}
settingKey="enableLegacyComposerSubmit"
managed={managedKeys.enableLegacyComposerSubmit}
ariaLabel="Toggle Command/Ctrl+Enter to submit"
/>
<ExperimentalToggleCard
title="Decisions"
description="Show the Decisions item in the main sidebar — the attention home that surfaces the tasks awaiting your input — while the surface is still being evaluated."