From e7aee44e79e2108c5bd7915e27c402d8183a93ac Mon Sep 17 00:00:00 2001 From: Adam Firestone Date: Fri, 14 Aug 2026 12:19:52 -0500 Subject: [PATCH] feat(web): add expandable composer editor - Add expand/collapse control for multi-line prompts - Reset expanded state when clearing or submitting prompts --- .../components/ComposerPromptEditor.test.ts | 9 +++ .../src/components/ComposerPromptEditor.tsx | 62 ++++++++++++++- apps/web/src/components/chat/ChatComposer.tsx | 79 ++++++++++++++++++- apps/web/src/composer-logic.test.ts | 9 +++ apps/web/src/composer-logic.ts | 4 + apps/web/src/index.css | 7 ++ 6 files changed, 165 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0aab8fb0c2d8..4513ab422ab5 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -11,6 +11,15 @@ import { } from "lexical"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; +import { isComposerPromptEditorBeyondMinimumHeight } from "./ComposerPromptEditor"; + +describe("isComposerPromptEditorBeyondMinimumHeight", () => { + it("reports the first physical height increase beyond the editor minimum", () => { + expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 70 }, 70)).toBe(false); + expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 71 }, 70)).toBe(false); + expect(isComposerPromptEditorBeyondMinimumHeight({ clientHeight: 92 }, 70)).toBe(true); + }); +}); class TestClipboardEvent extends Event { readonly clipboardData: DataTransfer; diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0489e8c79cdf..01c95811a3e6 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -876,14 +876,25 @@ export interface ComposerPromptEditorHandle { }; } +export function isComposerPromptEditorBeyondMinimumHeight( + element: Pick, + minimumHeight: number, +): boolean { + // Ignore sub-pixel rounding so the affordance does not flicker at the + // editor's minimum-height boundary. + return element.clientHeight - minimumHeight > 1; +} + interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; skills: ReadonlyArray; disabled: boolean; + expanded?: boolean; placeholder: string; className?: string; + onBeyondMinimumHeightChange?: (beyondMinimumHeight: boolean) => void; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, @@ -1531,8 +1542,10 @@ function ComposerPromptEditorInner({ terminalContexts, skills, disabled, + expanded = false, placeholder, className, + onBeyondMinimumHeightChange, onRemoveTerminalContext, onChange, onCommandKeyDown, @@ -1571,6 +1584,46 @@ function ComposerPromptEditorInner({ editor.setEditable(!disabled); }, [disabled, editor]); + useLayoutEffect(() => { + if (!onBeyondMinimumHeightChange || expanded) return; + + const rootElement = editor.getRootElement(); + if (!rootElement) return; + + let animationFrame: number | null = null; + const measureHeight = () => { + animationFrame = null; + const minimumHeight = Number.parseFloat(window.getComputedStyle(rootElement).minHeight); + onBeyondMinimumHeightChange( + Number.isFinite(minimumHeight) && + isComposerPromptEditorBeyondMinimumHeight(rootElement, minimumHeight), + ); + }; + const scheduleMeasurement = () => { + if (animationFrame !== null) return; + animationFrame = window.requestAnimationFrame(measureHeight); + }; + + measureHeight(); + const resizeObserver = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasurement); + resizeObserver?.observe(rootElement); + const mutationObserver = new MutationObserver(scheduleMeasurement); + mutationObserver.observe(rootElement, { + childList: true, + characterData: true, + subtree: true, + }); + + return () => { + if (animationFrame !== null) { + window.cancelAnimationFrame(animationFrame); + } + resizeObserver?.disconnect(); + mutationObserver.disconnect(); + }; + }, [editor, expanded, onBeyondMinimumHeightChange]); + useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; @@ -1747,14 +1800,15 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance // can drive it; keep everything else here. - "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + "block max-h-64 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + expanded && "h-full min-h-0 max-h-none", className, )} data-testid="composer-editor" @@ -1793,8 +1847,10 @@ export function ComposerPromptEditor({ terminalContexts, skills, disabled, + expanded = false, placeholder, className, + onBeyondMinimumHeightChange, onRemoveTerminalContext, onChange, onCommandKeyDown, @@ -1831,11 +1887,13 @@ export function ComposerPromptEditor({ terminalContexts={terminalContexts} skills={skills} disabled={disabled} + expanded={expanded} placeholder={placeholder} onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} onPaste={onPaste} editorRef={editorRef} + {...(onBeyondMinimumHeightChange ? { onBeyondMinimumHeightChange } : {})} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} /> diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 08a39ef65acb..1b9650eeb9f8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -40,6 +40,7 @@ import { expandCollapsedComposerCursor, replaceTextRange, shouldAcceptPromptSuggestionOnTab, + shouldCollapseExpandedComposer, shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -196,6 +197,8 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + Maximize2Icon, + Minimize2Icon, PencilRulerIcon, type LucideIcon, LockIcon, @@ -950,6 +953,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [isComposerExpanded, setIsComposerExpanded] = useState(false); + const [isComposerExpandAvailable, setIsComposerExpandAvailable] = useState(false); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -1402,10 +1407,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); + setIsComposerExpanded(false); + setIsComposerExpandAvailable(false); dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); + useEffect(() => { + if (isMobileViewport) { + setIsComposerExpanded(false); + } + }, [isMobileViewport]); + // ------------------------------------------------------------------ // Footer compact layout observation // ------------------------------------------------------------------ @@ -1531,6 +1544,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => { + if (shouldCollapseExpandedComposer(nextPrompt)) { + setIsComposerExpanded(false); + setIsComposerExpandAvailable(false); + } if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { setComposerCursor(nextCursor); setComposerTrigger( @@ -1828,6 +1845,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } onSend(event); + setIsComposerExpanded(false); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } @@ -1864,6 +1882,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, []); + const handleComposerBeyondMinimumHeightChange = useCallback((beyondMinimumHeight: boolean) => { + setIsComposerExpandAvailable(beyondMinimumHeight); + if (!beyondMinimumHeight) { + setIsComposerExpanded(false); + } + }, []); + // ------------------------------------------------------------------ // Callbacks: command key // ------------------------------------------------------------------ @@ -2674,6 +2699,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} + {!isMobileViewport && (isComposerExpandAvailable || isComposerExpanded) ? ( + + event.preventDefault()} + onClick={() => setIsComposerExpanded((expanded) => !expanded)} + > + {isComposerExpanded ? ( + + ) : ( + + )} + + } + /> + + {isComposerExpanded ? "Collapse" : "Expand"} + + + ) : null} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && @@ -2961,7 +3020,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (image) => !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), ) && ( -
+
{composerImages .filter( (image) => @@ -3032,7 +3098,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} -
+
{ }); }); +describe("shouldCollapseExpandedComposer", () => { + it("collapses only when the editor value becomes empty", () => { + expect(shouldCollapseExpandedComposer("")).toBe(true); + expect(shouldCollapseExpandedComposer(" ")).toBe(false); + expect(shouldCollapseExpandedComposer("still editing")).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 4b93b6be89f0..9c16f158be01 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -32,6 +32,10 @@ export function shouldAcceptPromptSuggestionOnTab(input: { ); } +export function shouldCollapseExpandedComposer(prompt: string): boolean { + return prompt.length === 0; +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 58b6580a2fea..5ff0c558b0d0 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1703,6 +1703,13 @@ code { font-size: var(--font-size-prompt, 0.875rem); } +/* The expand control owns the composer's top-right corner. In Chromium-based + web and desktop clients, start the native editor scrollbar below it instead + of shifting the control away from the corner. */ +.composer-editor-expand-control-visible::-webkit-scrollbar-track { + margin-block-start: 2rem; +} + /* Touch browsers zoom the page when a focused field is under 16px, so keep the floor there regardless of the preference. Gated on a coarse pointer: the zoom quirk does not exist on desktop, where a narrow window must not