mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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:
1 parent
b4e7b76635
commit
b400f76647
19 files changed
+309
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
Reference in new issue
Block a user