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