Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions src/components/automations/automation-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +120,7 @@ export function AutomationEditor({
const [cronBuilderOpen, setCronBuilderOpen] = useState(false)

const editorRef = useRef<RichComposerHandle>(null)
const chromeFocus = useComposerChromeFocus(editorRef)
// The composer's outer box, so the `@` panel spans it like the `/` menu does.
const composerBoxRef = useRef<HTMLDivElement>(null)
// True once the user explicitly picks an agent. A system fallback (saved agent
Expand Down Expand Up @@ -357,16 +358,13 @@ export function AutomationEditor({
config bottom bar, matching the new-conversation input. */}
<div
ref={composerBoxRef}
// Clicking the box's blank chrome (padding, the dead space below a short
// prompt, the config-bar gaps) focuses the editor at the click 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.
onMouseDown={(e) => {
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"
>
<ComposerInvocationsPopup inv={invocations} />
Expand Down
6 changes: 5 additions & 1 deletion src/components/chat/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
</div>
)
Expand Down
62 changes: 62 additions & 0 deletions src/components/chat/composer/composer-sizing.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
70 changes: 70 additions & 0 deletions src/components/chat/composer/composer-sizing.ts
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions src/components/chat/composer/rich-composer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
27 changes: 25 additions & 2 deletions src/components/chat/composer/rich-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,12 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
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 } : {}),
Expand Down Expand Up @@ -682,9 +687,27 @@ export const RichComposer = forwardRef<RichComposerHandle, RichComposerProps>(
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. */}
<EditorContent
editor={editor}
className="codeg-composer-scroll min-h-0 flex-1 overflow-y-auto px-3 py-2 text-base md:text-sm"
className="codeg-composer-scroll flex min-h-0 grow flex-col overflow-y-auto px-3 py-2 text-base md:text-sm"
/>
{referenceSearch && mentionState && (
<SuggestionPopup
Expand Down
110 changes: 110 additions & 0 deletions src/components/chat/composer/use-composer-chrome-focus.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>) => void
onClick: (event: MouseEvent<HTMLElement>) => void
onMouseDown: (event: MouseEvent<HTMLElement>) => 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<RichComposerHandle | null>
): ComposerChromeFocusProps {
const onPointerDown = useCallback(
(event: PointerEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>) => {
// 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 }
}
Loading
Loading