diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-auto-grow-textarea.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-auto-grow-textarea.ts new file mode 100644 index 0000000..4762525 --- /dev/null +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-auto-grow-textarea.ts @@ -0,0 +1,22 @@ +import { useLayoutEffect, type RefObject } from "react"; + +type Params = { + ref: RefObject; + 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]); +} diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer-attach-button.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer-attach-button.tsx index 05098d4..7e55233 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer-attach-button.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer-attach-button.tsx @@ -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" > diff --git a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx index c89b315..ec0981d 100644 --- a/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx +++ b/src/pages/admin/bots/[bot_id]/(partials)/composer.tsx @@ -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(); const files = useComposerAttachments({ bot_id: bot.id, workspace_id }); const online = useOnlineStatus(); + const input_ref = useRef(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} /> - + { + const target = e.target as HTMLElement; + + if (target.closest("button, textarea")) return; + + e.preventDefault(); + input_ref.current?.focus(); + }} + >