Let the composer grow with long prompts and focus from anywhere on its row

The input row had a fixed 88px height, so a growing textarea overflowed
into the thread and tools strip. It now has a minimum height, grows to
eight lines then scrolls, keeps the buttons at the bottom, and falls back
to a JS auto-grow where field-sizing isn't supported. A click anywhere on
the row focuses the input.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 20:42:13 +01:00
co-authored by Claude Opus 5.5
parent bf7109952b
commit 727cbf53c8
3 changed files with 48 additions and 9 deletions
@@ -24,7 +24,7 @@ export default function ComposerAttachButton({ disabled, onFiles }: Props) {
color="text"
disabled={disabled}
onClick={() => pickFiles({ onFiles })}
className="shrink-0 p-1! -m-1 text-ink dark:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
className="shrink-0 p-1! -mx-1 my-1 text-ink dark:text-ink rounded-default-xs focus-visible:ring-2 focus-visible:ring-ink"
>
<Plus size={16} aria-hidden />
</Button>
@@ -1,4 +1,4 @@
import { useState, type KeyboardEvent } from "react";
import { useRef, useState, type KeyboardEvent } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea";
@@ -10,6 +10,7 @@ import type {
import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft";
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments";
@@ -30,11 +31,12 @@ type Props = {
};
/**
* Enter sends, Shift+Enter adds a line. Grows to five lines, then scrolls.
* Enter sends, Shift+Enter adds a line. Grows to eight lines, then scrolls.
* Its top rule lines up with the rail's user-row rule; picked files sit
* just above it. Files can also be pasted or dropped in. Unsent text is
* kept per conversation across reloads, with its uploaded files. The
* bot's MCP servers and tools are listed under the input.
* just above it. A click anywhere on the row focuses the input. Files can
* also be pasted or dropped in. Unsent text is kept per conversation
* across reloads, with its uploaded files. The bot's MCP servers and tools
* are listed under the input.
*/
export default function Composer({ bot, workspace_id, extensions, onSent }: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
@@ -42,6 +44,9 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
const [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus();
const input_ref = useRef<HTMLTextAreaElement>(null);
useAutoGrowTextarea({ ref: input_ref, value: text });
const can_send =
Boolean(text.trim() || files.media_ids.length) &&
@@ -101,13 +106,23 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
onRemove={files.remove}
/>
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
<Row className="w-full max-w-container mx-auto h-[88px] gap-3! md:gap-4! flex-nowrap items-center px-3 md:px-6">
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest("button, textarea")) return;
e.preventDefault();
input_ref.current?.focus();
}}
>
<ComposerAttachButton
disabled={files.full}
onFiles={files.add}
/>
<Textarea
name="message"
componentRef={input_ref}
aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`}
rows={1}
@@ -124,8 +139,10 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
}}
aria-invalid={error ? true : undefined}
title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{ className: "grow min-w-0" }}
className="field-sizing-content min-h-5 max-h-[160px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
wrapperWrapperProps={{
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
}}
wrapperProps={{
className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",