From 25a0982900dbc154ab630e87fb314faee8a72d1e Mon Sep 17 00:00:00 2001 From: yeton <51062847+evepupil@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:59:15 +0800 Subject: [PATCH 1/2] fix(composer): size the editable area off its content, not free space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message composer is a flex column — editor on top, action row pinned under it — whose height comes only from the `min-height` its host passes (`min-h-24`, or `min-h-30` for the welcome box). The editor claimed its share of that box with `flex-1`: a flex basis of ZERO plus a `min-h-0` floor, so its whole height was free space the container had to hand down. An engine that reads a min-height-only flex column as main-size-indefinite distributes no free space at all, and off a zero basis the editor then lands at 0px. The add / agent / stop controls ride up to the top of the box, the placeholder is clipped away, and everything below the controls is chrome no tap can focus — the mobile-web report in #746. Chromium rescues the same markup by resolving the percentage basis as `content`, which is why desktop never showed it. Give the editor a content basis (`grow`) instead. It is then never shorter than the text it holds, whatever the engine does, while `min-h-0` still lets it shrink and scroll once the composer reaches its max height. The blank space under a short draft was also not the contenteditable — only 65% of the editable area was, 40% in the taller welcome box and 24% in the automation editor's prompt box — so focusing it went through the chrome's mousedown fallback, which touch has no reliable equivalent for. Make the scroll area a column and let the editable node grow inside it, so a tap anywhere in the editor lands on it natively. Measured against the built CSS in Chromium at 360px: the composer's box height, editable area and action-row offset are unchanged in every state (empty, image attached, welcome, overflowing) and in all three RichComposer hosts. Only the contenteditable's share of the editable area moves, to 100%. Co-Authored-By: Claude Opus 5 (1M context) --- .../chat/composer/rich-composer.test.tsx | 44 +++++++++++++ .../chat/composer/rich-composer.tsx | 27 +++++++- src/components/chat/message-input.test.tsx | 61 +++++++++++++++++++ src/components/chat/message-input.tsx | 8 ++- 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/components/chat/composer/rich-composer.test.tsx b/src/components/chat/composer/rich-composer.test.tsx index 7f644f7c77..df1451e57d 100644 --- a/src/components/chat/composer/rich-composer.test.tsx +++ b/src/components/chat/composer/rich-composer.test.tsx @@ -627,3 +627,47 @@ describe("RichComposer prompt-history Arrow routing", () => { expect(onHistoryKeyDown).not.toHaveBeenCalled() }) }) + +/** + * Guards the sizing contract the mobile-web composer regressed on (#746). + * + * jsdom has no layout engine, so these assert the declared box model rather + * than measured pixels — the real geometry is covered by the manual pass + * described in the PR. What they do catch is the exact edit that broke it: + * swapping a content flex basis back to a zero one, which lets an engine that + * distributes no free space in a min-height-only flex column collapse the + * editable area to 0px. + */ +describe("RichComposer editable-area sizing (#746)", () => { + it("grows the editable area off a content basis, never a zero basis", async () => { + const { container } = await mount() + const scroll = container.querySelector(".codeg-composer-scroll") + expect(scroll).not.toBeNull() + + const classes = scroll!.className.split(/\s+/) + // `flex-1` is `flex: 1 1 0%`. With nothing to grow into, that zero basis is + // the collapsed, untappable composer from #746. + expect(classes).not.toContain("flex-1") + expect(classes).toContain("grow") + // Still free to shrink and scroll when the composer hits its max height. + expect(classes).toContain("min-h-0") + expect(classes).toContain("overflow-y-auto") + }) + + it("lets the contenteditable fill the editable area so taps land on it", async () => { + const { container } = await mount() + const scroll = container.querySelector(".codeg-composer-scroll") + const editable = container.querySelector('[contenteditable="true"]') + expect(editable).not.toBeNull() + + // The scroll area is the column the editable node grows inside of. + const scrollClasses = scroll!.className.split(/\s+/) + expect(scrollClasses).toContain("flex") + expect(scrollClasses).toContain("flex-col") + // …and the editable node is the child that takes the leftover, so the + // blank space under a short draft is still the contenteditable and a tap + // there focuses it natively (touch has no chrome-mousedown fallback). + expect(editable!.className.split(/\s+/)).toContain("grow") + expect(scroll!.contains(editable)).toBe(true) + }) +}) diff --git a/src/components/chat/composer/rich-composer.tsx b/src/components/chat/composer/rich-composer.tsx index 2304bed820..bb19a84585 100644 --- a/src/components/chat/composer/rich-composer.tsx +++ b/src/components/chat/composer/rich-composer.tsx @@ -382,7 +382,12 @@ export const RichComposer = forwardRef( autofocus: autoFocus ? "end" : false, editorProps: { attributes: { - class: "codeg-composer-content", + // `grow`: the editable node fills the scroll area (a flex column, see + // EditorContent below) so the blank space under a short draft is + // still the contenteditable and a tap there focuses it natively. Its + // automatic minimum size keeps it from being squeezed under its own + // text once the composer is at max height and scrolling. + class: "codeg-composer-content grow", role: "textbox", "aria-multiline": "true", ...(ariaLabel ? { "aria-label": ariaLabel } : {}), @@ -682,9 +687,27 @@ export const RichComposer = forwardRef( style={style} data-disabled={disabled || undefined} > + {/* `grow` (a CONTENT flex basis), never `flex-1` (a ZERO basis). The + box this sits in is a flex column whose height usually comes only + from a `min-height`, and an engine that treats such a column as + main-size-indefinite hands its `flex-grow` children no free space + at all. Off a zero basis that leaves the editable area at 0px: the + action row rides up to the top of the box, the rest of it is + untappable dead space, and the placeholder is clipped away — the + mobile-web report in #746. Off a content basis the editor is always + at least as tall as the text it holds, whatever the engine does, + while `min-h-0` still lets it shrink and scroll at the box's + max height. + + The column here plus `grow` on the editable node itself (see the + `codeg-composer-content` class) also makes the contenteditable + cover the whole editable area, so a tap on the blank space under a + short draft lands on the editor natively instead of going through + the chrome's mousedown fallback — which touch has no reliable + equivalent for. */} {referenceSearch && mentionState && ( { expect(handle.getText()).toBe("queued edit") }) }) + +/** + * The composer box is a flex column: editor on top, action row pinned under + * it. On mobile web the editor was collapsing to 0px, which floated the add / + * agent / stop controls to the TOP of the box and left the rest of it as blank + * dead space that could not be focused (#746). + * + * jsdom cannot measure any of that, so these lock the declared contract the + * geometry rests on. Real pixel behaviour is covered by the manual pass in the + * PR description. + */ +describe("MessageInput composer box sizing (#746)", () => { + it("sizes the editor off a content basis so it cannot collapse to zero", async () => { + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const chrome = container.querySelector(".codeg-composer-chrome") + expect(chrome).not.toBeNull() + const chromeClasses = chrome!.className.split(/\s+/) + expect(chromeClasses).toContain("flex") + expect(chromeClasses).toContain("flex-col") + + const editorRoot = chrome!.querySelector(".codeg-composer") + expect(editorRoot).not.toBeNull() + const editorClasses = editorRoot!.className.split(/\s+/) + // A zero basis (`flex-1`) only reaches its intended height by absorbing the + // box's free space. Engines that read a min-height-only flex column as + // main-size-indefinite hand out none, and the editor lands at 0px. + expect(editorClasses).not.toContain("flex-1") + expect(editorClasses).toContain("grow") + }) + + it("keeps the action row a fixed-height last child, below the editor", async () => { + const { container } = renderInput({ isPrompting: true, onCancel: vi.fn() }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const chrome = container.querySelector(".codeg-composer-chrome")! + const editorRoot = chrome.querySelector(".codeg-composer")! + // The row holding the add menu / agent settings / stop button. + const actionRow = chrome.querySelector(":scope > .shrink-0.items-end") + expect(actionRow).not.toBeNull() + + // Stop lives in that row (it is what appeared top-right in the report). + expect( + actionRow!.querySelector( + `button[title="${enMessages.Folder.chat.messageInput.cancel}"]` + ) + ).not.toBeNull() + + // Document order: editor first, action row after it. Anything else and the + // controls render above the text, which is the reported symptom. + expect( + editorRoot.compareDocumentPosition(actionRow!) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) +}) diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 4cdd89914f..832c043d4f 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -2149,7 +2149,13 @@ export function MessageInput({ isExternalMenuOpen={slashMenuVisible} onExternalMenuKeyDown={handleExternalMenuKeyDown} onHistoryKeyDown={handleHistoryKeyDown} - className="min-h-0 flex-1" + // `grow`, not `flex-1`: this box's height comes from the + // `min-height` the host passes below, and an engine that reads + // a min-height-only flex column as main-size-indefinite gives + // its `flex-grow` children nothing. A zero basis then collapses + // the editor to 0px and strands the action row at the top of + // the box (#746); a content basis cannot. See RichComposer. + className="min-h-0 grow" />
From 309807972fcb0271bd44b6a82f8cbf4980c730f4 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Mon, 21 Sep 2026 22:26:58 +0800 Subject: [PATCH 2/2] fix(composer): state the editable floor, and give touch a way in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #779 stopped the editable area collapsing on the engine behind #746 by sizing it off a content flex basis. Two things it left: The box still only reached its floor by having free space handed out, so where none is the action row floated mid-box with dead space under it — 14.2px compact, 38.2px on the welcome input. The floor is now stated on both halves: the action row is a fixed 2.5rem and the box carries a 2px border, so the editable area's floor is the box's less those, and the box reaches its floor by SUMMING its children instead. The arithmetic and the utilities it emits live in composer-sizing.ts, pinned against each other by its test. The floor travels in as `tall` rather than a `min-h-*` smuggled through `className`, because only MessageInput knows the row between the two halves. It stands down under a thumbnail strip, where the box already clears its floor on content alone — which is what keeps this pixel-neutral. And the box's blank chrome was still mouse-only. A tap's compatibility `mousedown` lands after the gesture has resolved, and cancelling it — which the mouse path must do to stop the editor blurring — is itself enough to keep the soft keyboard down. So the shared hook splits them: mouse on `pointerdown`, touch and pen on the `click` a tap produces (and a tap that becomes a scroll produces none), with plain `mousedown` left for a browser that has no pointer events to branch on. All three composers use it. Measured against the real compiled CSS at 360px, in WebKit and in Chromium, over compact/tall x empty/overflowing/with-attachments: every before/after pair is identical on a compliant engine, and under a simulation of one that distributes no free space the dead space under the action row goes to zero. Co-Authored-By: Claude Opus 5 (1M context) --- .../automations/automation-editor.tsx | 20 ++- src/components/chat/chat-input.tsx | 6 +- .../chat/composer/composer-sizing.test.ts | 62 +++++++++ .../chat/composer/composer-sizing.ts | 70 ++++++++++ .../composer/use-composer-chrome-focus.ts | 110 ++++++++++++++++ src/components/chat/message-input.test.tsx | 123 +++++++++++++++++- src/components/chat/message-input.tsx | 63 ++++----- .../conversation-detail-panel-layout.test.ts | 11 +- .../tasks/task-message-composer.tsx | 16 +-- 9 files changed, 424 insertions(+), 57 deletions(-) create mode 100644 src/components/chat/composer/composer-sizing.test.ts create mode 100644 src/components/chat/composer/composer-sizing.ts create mode 100644 src/components/chat/composer/use-composer-chrome-focus.ts diff --git a/src/components/automations/automation-editor.tsx b/src/components/automations/automation-editor.tsx index 0bedc37e84..2e8e4ea27c 100644 --- a/src/components/automations/automation-editor.tsx +++ b/src/components/automations/automation-editor.tsx @@ -13,7 +13,7 @@ import { import { useReferenceSearch } from "@/components/chat/composer/use-reference-search" import { useComposerMentionLabels } from "@/components/chat/composer/use-composer-mention-labels" import { docToPromptBlocks } from "@/components/chat/composer/to-prompt-blocks" -import { isComposerChromeClick } from "@/components/chat/composer/composer-commands" +import { useComposerChromeFocus } from "@/components/chat/composer/use-composer-chrome-focus" import { AgentConfigSection, effectiveSelections, @@ -120,6 +120,7 @@ export function AutomationEditor({ const [cronBuilderOpen, setCronBuilderOpen] = useState(false) const editorRef = useRef(null) + const chromeFocus = useComposerChromeFocus(editorRef) // The composer's outer box, so the `@` panel spans it like the `/` menu does. const composerBoxRef = useRef(null) // True once the user explicitly picks an agent. A system fallback (saved agent @@ -357,16 +358,13 @@ export function AutomationEditor({ config bottom bar, matching the new-conversation input. */}
{ - if (!isComposerChromeClick(e.target)) return - e.preventDefault() - editorRef.current?.focusAtCoords(e.clientX, e.clientY) - }} + // Clicking or tapping the box's blank chrome (padding, the dead space + // below a short prompt, the config-bar gaps) focuses the editor at that + // point — same affordance as the chat composer. Interactive controls, + // badges and the editor surface exclude themselves via + // NON_CHROME_SELECTOR; `codeg-composer-chrome` paints the text I-beam + // over the dead space. + {...chromeFocus} className="codeg-composer-chrome relative rounded-xl border border-input bg-background transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-inset focus-within:ring-ring/50" > diff --git a/src/components/chat/chat-input.tsx b/src/components/chat/chat-input.tsx index 38d7836354..201257a9c5 100644 --- a/src/components/chat/chat-input.tsx +++ b/src/components/chat/chat-input.tsx @@ -248,7 +248,11 @@ export const ChatInput = memo(function ChatInput({ ? t("agentResponding", { agent: agentName ?? "Agent" }) : t("sendMessage") } - className={cn(tall ? "min-h-30" : "min-h-24", "max-h-60")} + // The floor goes through `tall`, not through a `min-h-*` here: the box + // and its editable area carry two halves of the same number, and only + // MessageInput knows the action row that divides them. + tall={tall} + className="max-h-60" />
) diff --git a/src/components/chat/composer/composer-sizing.test.ts b/src/components/chat/composer/composer-sizing.test.ts new file mode 100644 index 0000000000..3663872148 --- /dev/null +++ b/src/components/chat/composer/composer-sizing.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest" + +import { + COMPOSER_SIZING, + COMPOSER_SIZING_PARTS, + composerBoxMinHeight, + composerEditableMinHeight, +} from "./composer-sizing" + +/** `min-h-24` → 6rem. Tailwind's spacing scale is 0.25rem per step. */ +function spacingUtilityToRem(utility: string): number { + const step = Number(utility.replace("min-h-", "")) + expect(Number.isFinite(step)).toBe(true) + return step * 0.25 +} + +/** `min-h-[3.375rem]` → 3.375. */ +function arbitraryUtilityToRem(utility: string): number { + const match = /^min-h-\[([\d.]+)rem\]$/.exec(utility) + expect(match).not.toBeNull() + return Number(match![1]) +} + +describe("composer height floors (#746)", () => { + // The whole point of stating the editable floor is that the box reaches its + // own floor by summing its children, so no engine has to hand out free space + // for the layout to come out right. That only holds while the two halves + // agree — this is the check that keeps them agreeing. + for (const size of ["compact", "tall"] as const) { + it(`derives the ${size} editable floor from the box floor`, () => { + const { boxRem, box, editable } = COMPOSER_SIZING[size] + const { ACTION_ROW_REM, BOX_BORDERS_REM } = COMPOSER_SIZING_PARTS + + // The written-out box utility says the same thing as `boxRem`. + expect(spacingUtilityToRem(box)).toBe(boxRem) + // …and the editable area gets what the action row and the border leave. + expect(arbitraryUtilityToRem(editable)).toBeCloseTo( + boxRem - ACTION_ROW_REM - BOX_BORDERS_REM, + 5 + ) + }) + } + + it("picks the floors off the size", () => { + expect(composerBoxMinHeight(false)).toBe(COMPOSER_SIZING.compact.box) + expect(composerBoxMinHeight(true)).toBe(COMPOSER_SIZING.tall.box) + expect(composerEditableMinHeight(false, false)).toBe( + COMPOSER_SIZING.compact.editable + ) + expect(composerEditableMinHeight(true, false)).toBe( + COMPOSER_SIZING.tall.editable + ) + }) + + // A thumbnail strip already pushes the box past its floor on content alone, + // so the editable floor stands down and the editor keeps the natural height + // it had before — the attachment layout is untouched by any of this. + it("stands the editable floor down under a thumbnail strip", () => { + expect(composerEditableMinHeight(false, true)).toBe("min-h-0") + expect(composerEditableMinHeight(true, true)).toBe("min-h-0") + }) +}) diff --git a/src/components/chat/composer/composer-sizing.ts b/src/components/chat/composer/composer-sizing.ts new file mode 100644 index 0000000000..7f673f43a0 --- /dev/null +++ b/src/components/chat/composer/composer-sizing.ts @@ -0,0 +1,70 @@ +/** + * The chat composer's two height floors. + * + * The box is a flex column — editable area on top, action row pinned under it — + * and both floors are stated, because they are one number split in two. Stating + * the editable half is what lets the box reach its floor by SUMMING its + * children instead of by handing out free space: an engine that reads a + * min-height-only flex column as main-size-indefinite gives its `flex-grow` + * children none of it, and #746 is what that looked like — the editable area + * gone entirely, the action row flush with the top of a 6rem box, and the rest + * of it blank and untappable. A content flex basis (`grow`, see RichComposer) + * already keeps the editor from collapsing; the floor below keeps the action + * row on the box's bottom edge there too, instead of floating it above dead + * space. + */ + +/** + * The action row's height. A 2rem control (the send/stop button — the tallest + * thing in the row in every state, at every container width) plus its `pb-2`. + */ +const ACTION_ROW_REM = 2.5 + +/** The box's own 1px border, top and bottom, at the 16px root. */ +const BOX_BORDERS_REM = 0.125 + +/** + * Per-size floors. The utility strings are written out rather than built from + * `boxRem` because Tailwind only emits classes it can find spelled out in the + * source; `composer-sizing.test.ts` checks they still agree with the + * arithmetic, so a change to one half cannot silently leave the other behind. + */ +export const COMPOSER_SIZING = { + /** Active and historical conversations. */ + compact: { + boxRem: 6, + box: "min-h-24", + editable: "min-h-[3.375rem]", + }, + /** The welcome (new-conversation) input, which sits in a roomy empty state. */ + tall: { + boxRem: 7.5, + box: "min-h-30", + editable: "min-h-[4.875rem]", + }, +} as const + +/** Exported for the test that pins the two halves together. */ +export const COMPOSER_SIZING_PARTS = { ACTION_ROW_REM, BOX_BORDERS_REM } + +/** The box's floor. */ +export function composerBoxMinHeight(tall: boolean): string { + return (tall ? COMPOSER_SIZING.tall : COMPOSER_SIZING.compact).box +} + +/** + * The editable area's floor. + * + * `hasStripAbove` is the thumbnail strip for image attachments. It stands the + * floor down, because the floor only has to be stated while the box is actually + * resting on it: with a strip above the editor the box already clears its floor + * on content alone, so no free space is in play, and the editable area keeps + * the natural height it has always had there. + */ +export function composerEditableMinHeight( + tall: boolean, + hasStripAbove: boolean +): string { + if (hasStripAbove) return "min-h-0" + return (tall ? COMPOSER_SIZING.tall : COMPOSER_SIZING.compact).editable +} diff --git a/src/components/chat/composer/use-composer-chrome-focus.ts b/src/components/chat/composer/use-composer-chrome-focus.ts new file mode 100644 index 0000000000..ffa780b038 --- /dev/null +++ b/src/components/chat/composer/use-composer-chrome-focus.ts @@ -0,0 +1,110 @@ +import { + useCallback, + type MouseEvent, + type PointerEvent, + type RefObject, +} from "react" + +import { isComposerChromeClick } from "@/components/chat/composer/composer-commands" +import type { RichComposerHandle } from "@/components/chat/composer/rich-composer" + +/** The handlers to spread on a composer box's outer element. */ +export interface ComposerChromeFocusProps { + onPointerDown: (event: PointerEvent) => void + onClick: (event: MouseEvent) => void + onMouseDown: (event: MouseEvent) => void +} + +/** A pointer kind we know is not a mouse; anything unnamed is treated as one. */ +function isDirectPointer(pointerType: string | undefined): boolean { + return pointerType === "touch" || pointerType === "pen" +} + +/** + * Whether the browser speaks Pointer Events at all. Read per event rather than + * once at module load, so nothing depends on whether this file was first + * evaluated while prerendering. + */ +function supportsPointerEvents(): boolean { + return typeof window !== "undefined" && "PointerEvent" in window +} + +/** + * Click-the-blank-chrome-to-type for a composer box: its padding, the dead + * space below a short draft, the gaps in the action bar. Interactive controls, + * inline badges and the editor surface own their own clicks and are excluded + * (see `isComposerChromeClick`), and the caret lands AT the point that was hit + * rather than at the end of the document, so pressing the padding beside + * existing text behaves like a native textarea. + * + * It takes two events, because neither input kind is served by the other's: + * + * - **Mouse — `pointerdown`.** Acting on the press is what every native text + * field does, and cancelling it keeps the editor from blurring before we + * refocus it (cancelling `pointerdown` also suppresses the compatibility + * `mousedown` that would have done the blurring). + * - **Touch and pen — `click`.** A tap's compatibility `mousedown` arrives + * after the gesture has already resolved, so focusing from it does not + * reliably raise the soft keyboard — and cancelling it, which the mouse path + * needs, is itself enough to stop the keyboard coming up. A real `click` is + * inside the activation the tap grants, and a touch that turns into a scroll + * never produces one, so nothing fires while the user is only panning. + * + * Each kind is handled exactly once: the press path skips touch and pen, and + * the click path skips everything else — refocusing a mouse on release would + * collapse a drag selection. An event that names no pointer kind (a scripted + * `.click()`, say) counts as a mouse. + * + * A browser with no Pointer Events at all would fall between the two — nothing + * dispatches `pointerdown`, and no event names a kind for the click path to act + * on — so it keeps a plain `mousedown`, which is the single path this had + * before the split and the only signal such a browser gives. That is the one + * place mouse and touch stay indistinguishable, and there they were never + * distinguished anyway. + * + * Deliberately NOT gated on the composer being disabled: the editor stays + * editable while a connection is coming up, so chrome presses must focus then + * too — otherwise only the existing line of text is live and the blank area + * below it is dead until the agent is ready. + */ +export function useComposerChromeFocus( + editorRef: RefObject +): ComposerChromeFocusProps { + const onPointerDown = useCallback( + (event: PointerEvent) => { + if (isDirectPointer(event.pointerType)) return + if (!isComposerChromeClick(event.target)) return + event.preventDefault() + editorRef.current?.focusAtCoords(event.clientX, event.clientY) + }, + [editorRef] + ) + + const onClick = useCallback( + (event: MouseEvent) => { + const native: Event = event.nativeEvent + const pointerType = + "pointerType" in native + ? (native as globalThis.PointerEvent).pointerType + : undefined + if (!isDirectPointer(pointerType)) return + if (!isComposerChromeClick(event.target)) return + editorRef.current?.focusAtCoords(event.clientX, event.clientY) + }, + [editorRef] + ) + + const onMouseDown = useCallback( + (event: MouseEvent) => { + // Where there ARE pointer events, the press path above has already had + // its say (and cancelling it suppresses this event entirely). + if (supportsPointerEvents()) return + if (!isComposerChromeClick(event.target)) return + event.preventDefault() + editorRef.current?.focusAtCoords(event.clientX, event.clientY) + }, + [editorRef] + ) + + return { onPointerDown, onClick, onMouseDown } +} diff --git a/src/components/chat/message-input.test.tsx b/src/components/chat/message-input.test.tsx index f1e1ca6f11..4a586b9ba0 100644 --- a/src/components/chat/message-input.test.tsx +++ b/src/components/chat/message-input.test.tsx @@ -198,6 +198,10 @@ import type { } from "@/lib/types" import { MessageInput } from "./message-input" +import { + composerBoxMinHeight, + composerEditableMinHeight, +} from "./composer/composer-sizing" const CAPS: PromptCapabilitiesInfo = { image: true, @@ -205,6 +209,23 @@ const CAPS: PromptCapabilitiesInfo = { embedded_context: true, } +/** + * Dispatch a pointer-driven event that actually names its `pointerType`. jsdom + * ships no `PointerEvent`, so `fireEvent.pointerDown(el, { pointerType })` + * silently drops the property and every event reads as an unnamed one; React + * takes `pointerType` straight off the native event, so setting it by hand on a + * MouseEvent is what the composer's handlers actually see in a browser. + */ +function firePointer( + element: Element, + type: "pointerdown" | "click", + pointerType: "mouse" | "touch" | "pen" +): boolean { + const event = new MouseEvent(type, { bubbles: true, cancelable: true }) + Object.defineProperty(event, "pointerType", { value: pointerType }) + return fireEvent(element, event) +} + function renderInput( props: Partial> ) { @@ -240,13 +261,13 @@ describe("MessageInput (RichComposer integration)", () => { expect(sendButton).toBeDisabled() }) - it("claims a mousedown on the input's empty chrome (P8d focus wiring)", async () => { + it("claims a mouse press on the input's empty chrome (P8d focus wiring)", async () => { const { container } = renderInput({}) await waitFor(() => expect(container.querySelector('[role="textbox"]')).not.toBeNull() ) - // The bordered card carries the chrome-focus handler; a mousedown on the - // card itself (not on the editor or a control) is claimed via preventDefault + // The bordered card carries the chrome-focus handler; a press on the card + // itself (not on the editor or a control) is claimed via preventDefault // before refocusing the editor. Asserting preventDefault (fireEvent returns // false when the event was canceled) avoids relying on jsdom focus. const card = container.querySelector('[class~="@container"]') as HTMLElement @@ -254,7 +275,81 @@ describe("MessageInput (RichComposer integration)", () => { // The same box paints the text I-beam across its blank chrome (see the // `.codeg-composer-chrome` rule in globals.css). expect(card.className).toContain("codeg-composer-chrome") + expect(firePointer(card, "pointerdown", "mouse")).toBe(false) + }) + + // A tap's compatibility `mousedown` lands after the gesture has resolved, and + // cancelling it — which the mouse path above must do — is itself enough to + // stop the soft keyboard coming up. So touch must NOT go through the press + // path; it gets the caret from the `click` the tap produces instead. + it("leaves a touch press on the chrome alone and focuses on the tap", async () => { + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + const card = container.querySelector('[class~="@container"]') as HTMLElement + const editor = container.querySelector('[role="textbox"]') as HTMLElement + + // Not claimed: no preventDefault, so the browser's own tap handling runs. + expect(firePointer(card, "pointerdown", "touch")).toBe(true) + expect(document.activeElement).not.toBe(editor) + + // The tap itself is what puts the caret in the editor. + firePointer(card, "click", "touch") + await waitFor(() => expect(document.activeElement).toBe(editor)) + }) + + // The press already focused a mouse; focusing again on release would collapse + // a selection dragged out of the chrome. + it("ignores a mouse click on the chrome (the press owns it)", async () => { + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + const card = container.querySelector('[class~="@container"]') as HTMLElement + const editor = container.querySelector('[role="textbox"]') as HTMLElement + + firePointer(card, "click", "mouse") + expect(document.activeElement).not.toBe(editor) + // An event that names no pointer kind at all counts as a mouse too. + fireEvent.click(card) + expect(document.activeElement).not.toBe(editor) + }) + + // A browser with no Pointer Events dispatches no `pointerdown` and names no + // pointer kind on its clicks, so both paths above stand down and plain + // `mousedown` is all there is — jsdom is exactly such an environment, which + // is what lets this assert the fallback rather than describe it. + it("falls back to mousedown where the browser has no pointer events", async () => { + expect("PointerEvent" in window).toBe(false) + const { container } = renderInput({}) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + const card = container.querySelector('[class~="@container"]') as HTMLElement + const editor = container.querySelector('[role="textbox"]') as HTMLElement + + // Claimed (preventDefault) and focused, the way it behaved before the + // press/tap split. expect(fireEvent.mouseDown(card)).toBe(false) + await waitFor(() => expect(document.activeElement).toBe(editor)) + }) + + // A tap on a control inside the box must reach the control, not be swallowed + // into "put the caret here" — same exclusion the mouse path has always had. + it("leaves a touch tap on a control inside the box to the control", async () => { + const { container } = renderInput({ isPrompting: true, onCancel: vi.fn() }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + const stop = container.querySelector( + `button[title="${enMessages.Folder.chat.messageInput.cancel}"]` + ) as HTMLElement + expect(stop).not.toBeNull() + + const editor = container.querySelector('[role="textbox"]') as HTMLElement + firePointer(stop, "click", "touch") + expect(document.activeElement).not.toBe(editor) }) }) @@ -1999,6 +2094,28 @@ describe("MessageInput composer box sizing (#746)", () => { expect(editorClasses).toContain("grow") }) + // Both floors have to actually land, and on the right element: the box's on + // the box, the editable area's on the editor. Only then does the box reach + // its floor by summing its children, which is what makes the layout come out + // right on an engine that hands its flex-grow children no free space. + for (const tall of [false, true]) { + it(`states both height floors on the ${tall ? "tall" : "compact"} box`, async () => { + const { container } = renderInput({ tall }) + await waitFor(() => + expect(container.querySelector('[role="textbox"]')).not.toBeNull() + ) + + const chrome = container.querySelector(".codeg-composer-chrome")! + const editorRoot = chrome.querySelector(".codeg-composer")! + expect(chrome.className.split(/\s+/)).toContain( + composerBoxMinHeight(tall) + ) + expect(editorRoot.className.split(/\s+/)).toContain( + composerEditableMinHeight(tall, false) + ) + }) + } + it("keeps the action row a fixed-height last child, below the editor", async () => { const { container } = renderInput({ isPrompting: true, onCancel: vi.fn() }) await waitFor(() => diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 832c043d4f..3eade6d5c4 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -122,10 +122,14 @@ import { import { isEmbeddedReferenceUri } from "@/components/chat/composer/reference-uri" import { applyExpertReference, - isComposerChromeClick, isComposerEmpty, restampSkillPrefixes, } from "@/components/chat/composer/composer-commands" +import { useComposerChromeFocus } from "@/components/chat/composer/use-composer-chrome-focus" +import { + composerBoxMinHeight, + composerEditableMinHeight, +} from "@/components/chat/composer/composer-sizing" import { buildKnownInvocations, commandInvocationToken, @@ -259,6 +263,14 @@ interface MessageInputProps { getSentHistory?: () => string[] injectContent?: ComposerInjectContent | null onInjectConsumed?: () => void + /** + * Give the composer box the roomier floor, for the welcome (new-conversation) + * input; active and historical conversations keep the compact default. Owned + * here rather than passed as a `min-h-*` in `className` because the box's + * floor and the editable area's are two halves of one number, and only this + * component knows the action row that separates them. + */ + tall?: boolean } // Non-image files attach as inline file badges in the editor (like `@`-file @@ -386,6 +398,7 @@ export function MessageInput({ injectContent, onInjectConsumed, getSentHistory, + tall = false, }: MessageInputProps) { const t = useTranslations("Folder.chat.messageInput") const tQueue = useTranslations("Folder.chat.messageQueue") @@ -1642,30 +1655,21 @@ export function MessageInput({ [isEditingQueueItem, slashMenuVisible, onCancelQueueEdit] ) - // Clicking the input's empty chrome (its padding, the blank space below a - // short message, the gaps in the action bar) focuses the editor — previously - // only the editor surface itself was clickable. Interactive controls, inline - // badges and the editor surface handle their own clicks, so they're excluded; - // `preventDefault` keeps the editor from blurring before we refocus it. We - // focus *at the click point* (not the end of the document) so clicking the - // left/top padding next to existing text lands the caret there, like a native - // textarea, instead of always jumping to the end. - const handleChromeMouseDown = useCallback( - (e: React.MouseEvent) => { - // Not gated on `disabled`: the editor stays editable while connecting (see - // `handleSend`), so chrome clicks must focus too — else only the existing - // text line is clickable and the blank area below it is dead until ready. - if (!isComposerChromeClick(e.target)) return - // Keep the editor from blurring before we refocus it. - e.preventDefault() - editorRef.current?.focusAtCoords(e.clientX, e.clientY) - }, - [] - ) + // Clicking (or tapping) the input's empty chrome — its padding, the blank + // space below a short message, the gaps in the action bar — focuses the + // editor at that point. See the hook for why it takes one event per pointer + // kind, and why it is not gated on `disabled`. + const chromeFocus = useComposerChromeFocus(editorRef) const hasImageAttachments = imageAttachments.length > 0 const showDragActive = attach.isDragActive && !disabled + // The box's floor and the editable area's are two halves of one number — see + // composer-sizing.ts for the arithmetic and for why stating both is what + // keeps the layout off free-space distribution (#746). + const boxMinHeight = composerBoxMinHeight(tall) + const editableMinHeight = composerEditableMinHeight(tall, hasImageAttachments) + const inlineSelectorItems = ( <> {showSelectorsLoading && ( @@ -2075,7 +2079,7 @@ export function MessageInput({ not suppressed. Desktop/secure-web get the full custom menu. */}
diff --git a/src/components/conversations/conversation-detail-panel-layout.test.ts b/src/components/conversations/conversation-detail-panel-layout.test.ts index a5376dac45..35765d17c4 100644 --- a/src/components/conversations/conversation-detail-panel-layout.test.ts +++ b/src/components/conversations/conversation-detail-panel-layout.test.ts @@ -238,9 +238,14 @@ describe("ConversationDetailPanel new conversation layout", () => { expect(chatInputSource).toContain( 'cn("pt-0", flush ? "pb-1" : "px-4 pb-1")' ) - expect(chatInputSource).toContain( - 'cn(tall ? "min-h-30" : "min-h-24", "max-h-60")' - ) + // The composer's ceiling is still the caller's, but its FLOOR travels + // through `tall` rather than a `min-h-*` smuggled in via `className`: the + // box's floor and the editable area's are two halves of one number, and + // only MessageInput knows the action row between them (composer-sizing.ts, + // #746). A `min-h-*` set from out here would re-open that split. + expect(chatInputSource).toContain("tall={tall}") + expect(chatInputSource).toContain('className="max-h-60"') + expect(chatInputSource).not.toMatch(/className=.*min-h-/) expect(chatInputSource).not.toContain("containerClassName") expect(source).not.toContain("containerClassName") expect(conversationShellSource).not.toContain("containerClassName") diff --git a/src/components/tasks/task-message-composer.tsx b/src/components/tasks/task-message-composer.tsx index ae0de2c0dd..aa72d53707 100644 --- a/src/components/tasks/task-message-composer.tsx +++ b/src/components/tasks/task-message-composer.tsx @@ -17,10 +17,8 @@ import { import { useAgentOptions } from "@/components/automations/use-agent-options" import { ComposerAddMenu } from "@/components/chat/composer/composer-add-menu" import { ComposerImageThumbnails } from "@/components/chat/composer/composer-image-thumbnails" -import { - isComposerChromeClick, - restampSkillPrefixes, -} from "@/components/chat/composer/composer-commands" +import { restampSkillPrefixes } from "@/components/chat/composer/composer-commands" +import { useComposerChromeFocus } from "@/components/chat/composer/use-composer-chrome-focus" import { RichComposer, type RichComposerHandle, @@ -146,6 +144,7 @@ export function TaskMessageComposer({ const t = useTranslations("Folder.chat.messageInput") const editorRef = useRef(null) const containerRef = useRef(null) + const chromeFocus = useComposerChromeFocus(editorRef) const { groupLabels, uiLabels } = useComposerMentionLabels() const referenceSearch = useReferenceSearch({ @@ -292,12 +291,9 @@ export function TaskMessageComposer({ return (
{ - // Clicking the box's padding focuses the nearest caret, like a textarea. - if (!isComposerChromeClick(e.target)) return - e.preventDefault() - editorRef.current?.focusAtCoords(e.clientX, e.clientY) - }} + // Clicking or tapping the box's padding focuses the nearest caret, like a + // textarea. + {...chromeFocus} {...attach.containerDragProps} // Same shell the drawer's Textarea had (and the same rounding as every // other box in it); the editor brings the matching px-3 padding.