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
@@ -0,0 +1,22 @@
import { useLayoutEffect, type RefObject } from "react";
type Params = {
ref: RefObject<HTMLTextAreaElement | null>;
value: string;
};
/**
* Grows a textarea with its text where `field-sizing: content` isn't
* supported (Firefox). Its CSS `max-height` still caps it, then it scrolls.
* Does nothing where the CSS handles it.
*/
export default function useAutoGrowTextarea({ ref, value }: Params) {
useLayoutEffect(() => {
const el = ref.current;
if (!el || CSS.supports("field-sizing", "content")) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [ref, value]);
}
@@ -24,7 +24,7 @@ export default function ComposerAttachButton({ disabled, onFiles }: Props) {
color="text" color="text"
disabled={disabled} disabled={disabled}
onClick={() => pickFiles({ onFiles })} 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 /> <Plus size={16} aria-hidden />
</Button> </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 Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack"; import Stack from "@moduletrace/twui/layout/Stack";
import Textarea from "@moduletrace/twui/form/Textarea"; import Textarea from "@moduletrace/twui/form/Textarea";
@@ -10,6 +10,7 @@ import type {
import sendBotMessage from "../(functions)/send-bot-message"; import sendBotMessage from "../(functions)/send-bot-message";
import useComposerAttachments from "../(hooks)/use-composer-attachments"; import useComposerAttachments from "../(hooks)/use-composer-attachments";
import useComposerDraft from "../(hooks)/use-composer-draft"; import useComposerDraft from "../(hooks)/use-composer-draft";
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
import useOnlineStatus from "@/src/hooks/use-online-status"; import useOnlineStatus from "@/src/hooks/use-online-status";
import ComposerAttachButton from "./composer-attach-button"; import ComposerAttachButton from "./composer-attach-button";
import ComposerAttachments from "./composer-attachments"; 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 * 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 * just above it. A click anywhere on the row focuses the input. Files can
* kept per conversation across reloads, with its uploaded files. The * also be pasted or dropped in. Unsent text is kept per conversation
* bot's MCP servers and tools are listed under the input. * 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) { export default function Composer({ bot, workspace_id, extensions, onSent }: Props) {
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id }); 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 [error, setError] = useState<string>();
const files = useComposerAttachments({ bot_id: bot.id, workspace_id }); const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
const online = useOnlineStatus(); const online = useOnlineStatus();
const input_ref = useRef<HTMLTextAreaElement>(null);
useAutoGrowTextarea({ ref: input_ref, value: text });
const can_send = const can_send =
Boolean(text.trim() || files.media_ids.length) && Boolean(text.trim() || files.media_ids.length) &&
@@ -101,13 +106,23 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
onRemove={files.remove} onRemove={files.remove}
/> />
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline"> <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 <ComposerAttachButton
disabled={files.full} disabled={files.full}
onFiles={files.add} onFiles={files.add}
/> />
<Textarea <Textarea
name="message" name="message"
componentRef={input_ref}
aria-label={`Message ${bot.name}`} aria-label={`Message ${bot.name}`}
placeholder={`Message ${bot.name}`} placeholder={`Message ${bot.name}`}
rows={1} rows={1}
@@ -124,8 +139,10 @@ export default function Composer({ bot, workspace_id, extensions, onSent }: Prop
}} }}
aria-invalid={error ? true : undefined} aria-invalid={error ? true : undefined}
title={error} title={error}
className="field-sizing-content max-h-[100px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary" 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" }} wrapperWrapperProps={{
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
}}
wrapperProps={{ wrapperProps={{
className: className:
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none", "p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",