diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md new file mode 100644 index 0000000..fd2296b --- /dev/null +++ b/.changeset/mobile-enter-newline.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..944c3a9 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,6 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; +import { createMobilePromptEnterMedia, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -59,6 +60,7 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -238,7 +240,7 @@ export class PromptEditor extends LitElement { { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: () => this.handleEditorEnter() }, + { key: "Enter", run: (view) => this.handleEditorEnter(view) }, { key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, @@ -335,12 +337,15 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(): boolean { + private handleEditorEnter(view: EditorView): boolean { if (this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } + if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); + } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); return true; } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts new file mode 100644 index 0000000..bb5eabe --- /dev/null +++ b/src/client/src/promptEnterBehavior.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; + +describe("promptEnterBehavior", () => { + it("uses the expected mobile media query", () => { + expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); + }); + + it("sends on Enter outside the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); + expect(shouldSendPromptOnEnter(undefined)).toBe(true); + }); + + it("keeps Enter as a newline in the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + }); +}); diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts new file mode 100644 index 0000000..faef8ac --- /dev/null +++ b/src/client/src/promptEnterBehavior.ts @@ -0,0 +1,11 @@ +export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; + +export type PromptEnterMedia = Pick; + +export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { + return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { + return media?.matches !== true; +}