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:
@@ -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"
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user