diff --git a/.changeset/fn-048-chat-composer-resize.md b/.changeset/fn-048-chat-composer-resize.md new file mode 100644 index 0000000000..c6be63e944 --- /dev/null +++ b/.changeset/fn-048-chat-composer-resize.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Resize primary chat composers from the top edge and restore their height after clearing drafts. +category: fix +dev: Shared composer autosizing now owns desktop/tablet pointer resizing and clears stale manual heights. diff --git a/packages/dashboard/app/components/ChatView.css b/packages/dashboard/app/components/ChatView.css index 5c4eb2d689..2fc7ce633e 100644 --- a/packages/dashboard/app/components/ChatView.css +++ b/packages/dashboard/app/components/ChatView.css @@ -1960,8 +1960,8 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline } /* -FNXC:ChatComposer 2026-08-19-02:00: -ChatView's inline controller supplies the five-line automatic height. Desktop/tablet retain the native vertical resize affordance, while mobile disables that mouse-only affordance and keeps the same inline cap. +FNXC:ChatComposer 2026-08-19-17:58: +ChatView's inline controller supplies the five-line automatic height and owns desktop/tablet top-edge pointer resizing. Disable native resizing at every breakpoint so the lower-right grip cannot compete; mobile remains automatic-only. */ .chat-input-textarea { /* FN-5322: this textarea sits directly inside `.chat-input-wrapper`, which @@ -1973,7 +1973,7 @@ ChatView's inline controller supplies the five-line automatic height. Desktop/ta box-sizing: border-box; -webkit-appearance: none; appearance: none; - resize: vertical; + resize: none; overflow-y: hidden; border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-lg); diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 04726eecf7..032a2a4227 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -546,13 +546,13 @@ FN-7241 adds timestamps inside individual task-detail transcript blocks. Keep bl FNXC:TaskDetailChat 2026-07-21-23:42: FN-8473 requires the Activity Live steering input to share the transcript panel's --radius-lg so the composer does not look sharper than the panel above it. -FNXC:ChatComposer 2026-08-19-02:00: -The shared controller caps automatic growth at five rendered lines and keeps overflow inside the textarea. Desktop/tablet native vertical resizing is intentionally available for the current draft only; mobile removes the mouse-only affordance. +FNXC:ChatComposer 2026-08-19-17:58: +The shared controller caps automatic growth at five rendered lines, owns desktop/tablet top-edge pointer resizing, and keeps overflow inside the textarea. Disable native resizing so its lower-right grip cannot compete; mobile remains automatic-only. */ .task-chat-input { min-height: calc(var(--space-2xl) + var(--space-sm)); max-height: none; - resize: vertical; + resize: none; overflow-y: hidden; flex: 1 1 auto; border-radius: var(--radius-lg); diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 72c20f374f..d7ff51f7a1 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -247,8 +247,8 @@ Task Chat keeps model and thinking controls reachable beside the composer, reusi } /* -FNXC:ChatComposer 2026-08-19-02:00: -Planner Chat uses the same five-line controller and current-draft native resize fence as Activity Chat. The inline height is the automatic cap; no fixed CSS max-height may block a deliberate desktop/tablet expansion. +FNXC:ChatComposer 2026-08-19-17:58: +Planner Chat uses the same five-line controller and desktop/tablet top-edge pointer resize as Activity Chat. Disable native resizing so the lower-right grip cannot compete; no fixed CSS max-height may block a deliberate current-draft expansion. */ .task-planner-chat-input { box-sizing: border-box; @@ -258,7 +258,7 @@ Planner Chat uses the same five-line controller and current-draft native resize max-height: none; padding: var(--btn-padding); overflow-y: hidden; - resize: vertical; + resize: none; } /* diff --git a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx index a57df5347d..218f05b004 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx @@ -310,7 +310,7 @@ describe("ChatView composer autosize", () => { } }); - it("preserves a deliberate desktop expansion through edits and clears it on send", async () => { + it("top-edge expands direct chat and clearing its draft restores the default height", async () => { const sendMessage = vi.fn(); setup({ sendMessage }); renderChatView(); @@ -318,22 +318,58 @@ describe("ChatView composer autosize", () => { const textarea = screen.getByPlaceholderText("Type a message...") as HTMLTextAreaElement; Object.defineProperty(textarea, "scrollHeight", { configurable: true, - get: () => 500, + get: () => textarea.value.length > 0 ? 500 : 24, }); await userEvent.type(textarea, "long draft"); - const automaticHeight = textarea.style.height; - textarea.style.height = "300px"; - textarea.dispatchEvent(new Event("resize")); - await userEvent.type(textarea, " more"); + const automaticHeight = Number.parseInt(textarea.style.height, 10); + vi.spyOn(textarea, "getBoundingClientRect").mockReturnValue({ + bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}), + }); + const pointer = (type: string, clientY: number) => textarea.dispatchEvent(Object.assign( + new Event(type, { bubbles: true, cancelable: true }), { clientY, pointerId: 1, pointerType: "mouse" }, + )); + pointer("pointerdown", 284); + pointer("pointermove", 0); + pointer("pointerup", 0); - expect(textarea.style.height).toBe("300px"); - expect(textarea.style.height).not.toBe(automaticHeight); + expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThan(automaticHeight); await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]); await waitFor(() => { expect(textarea).toHaveValue(""); - expect(textarea.style.height).toBe(`${expectedAutomaticHeight(textarea, 500)}px`); + expect(textarea.style.height).toBe(`${expectedAutomaticHeight(textarea, 24)}px`); + expect(textarea.style.overflowY).toBe("hidden"); + }); + }); + + it("top-edge expands rooms chat and clearing its draft restores the default height", async () => { + localStorage.setItem("fusion:chat-scope", "rooms"); + renderChatView(); + + const textarea = screen.getByTestId("chat-input") as HTMLTextAreaElement; + Object.defineProperty(textarea, "scrollHeight", { + configurable: true, + get: () => textarea.value.length > 0 ? 500 : 24, + }); + await userEvent.type(textarea, "long room draft"); + const automaticHeight = Number.parseInt(textarea.style.height, 10); + vi.spyOn(textarea, "getBoundingClientRect").mockReturnValue({ + bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}), + }); + const pointer = (type: string, clientY: number) => textarea.dispatchEvent(Object.assign( + new Event(type, { bubbles: true, cancelable: true }), { clientY, pointerId: 1, pointerType: "mouse" }, + )); + pointer("pointerdown", 284); + pointer("pointermove", 0); + pointer("pointerup", 0); + expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThan(automaticHeight); + + await userEvent.clear(textarea); + await waitFor(() => { + expect(textarea).toHaveValue(""); + expect(textarea.style.height).toBe(`${expectedAutomaticHeight(textarea, 24)}px`); + expect(textarea.style.overflowY).toBe("hidden"); }); }); diff --git a/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx index 5eed26f57e..cf1d6746da 100644 --- a/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx +++ b/packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx @@ -7,6 +7,7 @@ import { clampChatInputHeight, createChatInputAutosizeController, getChatInputAutomaticMaxHeight, + getChatInputBoxMetrics, resolveChatInputOverflowY, } from "../../utils/chatInputAutosize"; @@ -23,12 +24,13 @@ function fiveLineHeight() { } describe("ChatView chat input autosize", () => { - it("uses the shared five-line automatic cap and keeps desktop resize available", () => { + it("uses the shared five-line automatic cap and disables the native resize grip", () => { const textareaRule = chatViewCss.match(/\.chat-input-textarea\s*\{[^}]*\}/); expect(textareaRule).not.toBeNull(); expect(textareaRule?.[0]).toContain("max-height: none"); - expect(textareaRule?.[0]).toContain("resize: vertical"); + expect(textareaRule?.[0]).toContain("resize: none"); + expect(textareaRule?.[0]).not.toContain("resize: vertical"); expect(textareaRule?.[0]).toContain("overflow-y: hidden"); expect(textareaRule?.[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-sm))"); expect(chatViewCss).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.chat-input-textarea\s*\{[^}]*resize: none/); @@ -84,52 +86,90 @@ describe("ChatView chat input autosize", () => { expect(resolveChatInputOverflowY(maxHeight + 1, maxHeight)).toBe("auto"); }); - it("keeps an explicit desktop height through controlled updates and resets it explicitly", () => { + it("resizes only from the top edge and releases a stale manual height after shortening", () => { const textarea = document.createElement("textarea"); document.body.append(textarea); - let scrollHeight = 220; + let scrollHeight = 360; Object.defineProperty(textarea, "scrollHeight", { configurable: true, get: () => scrollHeight, }); + vi.spyOn(textarea, "getBoundingClientRect").mockReturnValue({ + bottom: 400, + height: 118, + left: 0, + right: 600, + top: 282, + width: 600, + x: 0, + y: 282, + toJSON: () => ({}), + }); + const pointer = (type: string, clientY: number, pointerId = 1) => { + const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), { + clientY, + pointerId, + pointerType: "mouse", + }); + textarea.dispatchEvent(event); + return event; + }; const controller = createChatInputAutosizeController(textarea); const automaticHeight = Number.parseInt(textarea.style.height, 10); expect(automaticHeight).toBeLessThan(scrollHeight); - textarea.style.height = "300px"; - textarea.dispatchEvent(new Event("resize")); - expect(textarea.style.height).toBe("300px"); + const nonTopPointer = pointer("pointerdown", 320); + expect(nonTopPointer.defaultPrevented).toBe(false); + pointer("pointermove", 180); + expect(textarea.style.height).toBe(`${automaticHeight}px`); - scrollHeight = 240; - controller.resize(); - expect(textarea.style.height).toBe("300px"); + const topPointer = pointer("pointerdown", 284); + expect(topPointer.defaultPrevented).toBe(true); + pointer("pointermove", 0); + pointer("pointerup", 0); + const manualHeight = Number.parseInt(textarea.style.height, 10); + expect(manualHeight).toBeGreaterThan(automaticHeight); expect(textarea.style.overflowY).toBe("hidden"); - scrollHeight = 0; - controller.reset(); + scrollHeight = 24; + controller.resize(); expect(textarea.style.height).toBe(`${CHAT_INPUT_MIN_HEIGHT_PX}px`); expect(textarea.style.overflowY).toBe("hidden"); + scrollHeight = 500; + controller.resize(); + const cappedHeight = clampChatInputHeight(500, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea))); + expect(textarea.style.height).toBe(`${cappedHeight}px`); + expect(textarea.style.overflowY).toBe("auto"); + controller.destroy(); + pointer("pointerdown", 284); + pointer("pointermove", 100); + expect(textarea.style.height).toBe(`${cappedHeight}px`); + expect(document.body.style.userSelect).toBe(""); textarea.remove(); }); - it("does not capture its own programmatic resize as a manual override", () => { + it("keeps mobile composers automatic-only", () => { const textarea = document.createElement("textarea"); document.body.append(textarea); Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 220, writable: true }); + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true })); const controller = createChatInputAutosizeController(textarea); - - controller.resize(); const automaticHeight = textarea.style.height; - expect(automaticHeight).not.toBe("300px"); + const event = Object.assign(new Event("pointerdown", { cancelable: true }), { + clientY: 0, + pointerId: 1, + pointerType: "mouse", + }); - Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 0, writable: true }); - controller.resize({ resetManual: true }); - expect(textarea.style.height).toBe(`${CHAT_INPUT_MIN_HEIGHT_PX}px`); + textarea.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(textarea.style.height).toBe(automaticHeight); controller.destroy(); textarea.remove(); + vi.unstubAllGlobals(); }); }); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index e852f53d0a..7cd2637cd3 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -1741,7 +1741,7 @@ describe("TaskChatTab", () => { it.each([ ["active steering", makeTask({ column: "in-progress" })], ["done-task refinement", makeTask({ column: "done" })], - ] as const)("caps the %s composer, preserves a deliberate expansion, and resets after clear", async (_label, task) => { + ] as const)("caps the %s composer, top-edge expands it, and collapses after clear", async (_label, task) => { const user = userEvent.setup(); mockedAddSteeringComment.mockResolvedValue(task); mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-024-refinement", column: "todo" })); @@ -1750,7 +1750,7 @@ describe("TaskChatTab", () => { const input = screen.getByLabelText("Message active agent session") as HTMLTextAreaElement; Object.defineProperty(input, "scrollHeight", { configurable: true, - get: () => 500, + get: () => input.value.length > 0 ? 500 : 24, }); await user.type(input, "one\ntwo\nthree\nfour\nfive\nsix"); @@ -1759,15 +1759,22 @@ describe("TaskChatTab", () => { expect(input.style.overflowY).toBe("auto"); expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); - input.style.height = "300px"; - fireEvent.resize(input); - await user.type(input, " more"); - expect(input.style.height).toBe("300px"); + vi.spyOn(input, "getBoundingClientRect").mockReturnValue({ + bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}), + }); + const pointer = (type: string, clientY: number) => input.dispatchEvent(Object.assign( + new Event(type, { bubbles: true, cancelable: true }), { clientY, pointerId: 1, pointerType: "mouse" }, + )); + pointer("pointerdown", 284); + pointer("pointermove", 0); + pointer("pointerup", 0); + expect(Number.parseInt(input.style.height, 10)).toBeGreaterThan(automaticHeight); fireEvent.change(input, { target: { value: "" } }); await waitFor(() => { expect(input).toHaveValue(""); - expect(input.style.height).toBe(`${automaticHeight}px`); + expect(input.style.height).toBe(`${clampChatInputHeight(24, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(input)))}px`); + expect(input.style.overflowY).toBe("hidden"); }); }); @@ -2884,6 +2891,9 @@ describe("TaskChatTab", () => { expect(getCssDeclaration(transcriptRule, "border-radius")).toBe("var(--radius-lg)"); expect(getCssDeclaration(inputRule, "border-radius")).toBe("var(--radius-lg)"); + expect(getCssDeclaration(inputRule, "resize")).toBe("none"); + expect(inputRule).not.toContain("resize: vertical"); + expect(getCssDeclaration(mobileInputRule, "resize")).toBe("none"); expect(mobileInputRule).not.toMatch(/border-radius\s*:/); expect(getCssDeclaration(globalInputRule, "border-radius")).toBe("var(--radius-sm)"); }); diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index b9c311179d..66d0553f97 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -296,7 +296,7 @@ describe("TaskPlannerChatTab", () => { expect(document.body.textContent).not.toContain("5. 6"); }); - it("caps the loaded planner composer, preserves deliberate expansion, and resets on clear", async () => { + it("caps the loaded planner composer, top-edge expands it, and collapses on clear", async () => { mockFetchChatMessages.mockResolvedValueOnce({ messages: [{ id: "planner-history", sessionId: "chat-planner", role: "assistant", content: "Loaded planner history", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:01:00.000Z" }], }); @@ -306,7 +306,7 @@ describe("TaskPlannerChatTab", () => { const input = await screen.findByLabelText("Message task chat") as HTMLTextAreaElement; Object.defineProperty(input, "scrollHeight", { configurable: true, - get: () => 500, + get: () => input.value.length > 0 ? 500 : 24, }); fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix" } }); @@ -315,15 +315,22 @@ describe("TaskPlannerChatTab", () => { expect(input.style.overflowY).toBe("auto"); expect(screen.getByTestId("task-planner-chat-transcript")).toBeInTheDocument(); - input.style.height = "300px"; - fireEvent.resize(input); - fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix\nmore" } }); - expect(input.style.height).toBe("300px"); + vi.spyOn(input, "getBoundingClientRect").mockReturnValue({ + bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}), + }); + const pointer = (type: string, clientY: number) => input.dispatchEvent(Object.assign( + new Event(type, { bubbles: true, cancelable: true }), { clientY, pointerId: 1, pointerType: "mouse" }, + )); + pointer("pointerdown", 284); + pointer("pointermove", 0); + pointer("pointerup", 0); + expect(Number.parseInt(input.style.height, 10)).toBeGreaterThan(automaticHeight); fireEvent.change(input, { target: { value: "" } }); await waitFor(() => { expect(input).toHaveValue(""); - expect(input.style.height).toBe(`${automaticHeight}px`); + expect(input.style.height).toBe(`${clampChatInputHeight(24, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(input)))}px`); + expect(input.style.overflowY).toBe("hidden"); }); }); @@ -1151,7 +1158,8 @@ describe("TaskPlannerChatTab", () => { expect(desktopInputRule).not.toMatch(/(?:^|\n)\s+height:/); expect(desktopInputRule).toContain(`min-height: ${desktopTabletHeight};`); expect(desktopInputRule).toContain("max-height: none;"); - expect(desktopInputRule).toContain("resize: vertical;"); + expect(desktopInputRule).toContain("resize: none;"); + expect(desktopInputRule).not.toContain("resize: vertical;"); expect(desktopInputRule).toContain("overflow-y: hidden;"); expect(desktopSendRule).toContain(`block-size: ${desktopTabletHeight};`); expect(desktopSendRule).toContain(`min-block-size: ${desktopTabletHeight};`); diff --git a/packages/dashboard/app/utils/chatInputAutosize.ts b/packages/dashboard/app/utils/chatInputAutosize.ts index 5612cf37f3..d80b4adc54 100644 --- a/packages/dashboard/app/utils/chatInputAutosize.ts +++ b/packages/dashboard/app/utils/chatInputAutosize.ts @@ -1,6 +1,6 @@ /* -FNXC:ChatComposer 2026-08-19-02:00: -Primary conversation composers protect the transcript by growing automatically through five rendered lines, then scrolling their own overflow. A native desktop/tablet resize is an explicit current-draft override; it is never persisted and is cleared when the draft is cleared or the composer target is replaced. +FNXC:ChatComposer 2026-08-19-17:58: +Primary conversation composers protect the transcript by growing automatically through five rendered lines, then scrolling their own overflow. Desktop/tablet resizing is owned by a top-edge pointer drag so the bottom edge stays fixed; its current-draft override is never persisted and collapses as soon as measured content no longer warrants the chosen height. */ export const CHAT_INPUT_MAX_LINES = 5; @@ -113,24 +113,42 @@ function readBorderBoxHeight(textarea: HTMLTextAreaElement, entry?: ResizeObserv return parseCssPixels(textarea.style.height) ?? 0; } -/** - * Attach autosizing to one mounted primary composer. The ResizeObserver is deliberately used as - * the native vertical resize signal: unlike a pointer handler, it also recognizes keyboard and - * assistive-technology resizing without inventing a custom resize affordance. - */ +/** Attach autosizing and desktop/tablet top-edge resizing to one mounted primary composer. */ export function createChatInputAutosizeController(textarea: HTMLTextAreaElement): ChatInputAutosizeController { let manualHeight: number | null = null; let appliedHeight = 0; let automaticMaxHeight = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX; + let activePointerId: number | null = null; + let dragStartY = 0; + let dragStartHeight = 0; + let previousUserSelect = ""; let destroyed = false; + const isAutomaticOnlyViewport = () => typeof window !== "undefined" + && typeof window.matchMedia === "function" + && window.matchMedia("(max-width: 768px)").matches; + + const finishResize = () => { + if (activePointerId === null) return; + if (typeof textarea.hasPointerCapture === "function" && textarea.hasPointerCapture(activePointerId)) { + textarea.releasePointerCapture(activePointerId); + } + activePointerId = null; + document.body.style.userSelect = previousUserSelect; + }; + const apply = (resetManual = false) => { if (destroyed) return; if (resetManual) manualHeight = null; automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea)); const contentHeight = Number.isFinite(textarea.scrollHeight) ? textarea.scrollHeight : 0; - const nextHeight = manualHeight ?? clampChatInputHeight(contentHeight, automaticMaxHeight); + const automaticHeight = clampChatInputHeight(contentHeight, automaticMaxHeight); + + // A manual height only belongs to the current measured draft. Once content is shorter than + // it, resume automatic sizing rather than leaving an empty or shortened composer enlarged. + if (manualHeight !== null && contentHeight < manualHeight) manualHeight = null; + const nextHeight = manualHeight ?? automaticHeight; if (manualHeight === null) { // Reset the used height before reading scrollHeight so shrinking drafts recalculate too. @@ -141,31 +159,51 @@ export function createChatInputAutosizeController(textarea: HTMLTextAreaElement) appliedHeight = nextHeight; }; - const observeNativeResize = (entry?: ResizeObserverEntry) => { - if (destroyed) return; - const observedHeight = readBorderBoxHeight(textarea, entry); - if (!observedHeight || Math.abs(observedHeight - appliedHeight) < 1) return; + const onPointerDown = (event: PointerEvent) => { + if (destroyed || event.pointerType !== "mouse" || isAutomaticOnlyViewport()) return; + const rect = textarea.getBoundingClientRect(); + if (rect.height <= 0) return; + const topResizeBorder = Math.min(12, rect.height); + if (event.clientY < rect.top || event.clientY > rect.top + topResizeBorder) return; - const nextAutomaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea)); - automaticMaxHeight = nextAutomaticMaxHeight; - if (observedHeight <= nextAutomaticMaxHeight) return; - - // Only a height beyond the automatic five-line ceiling becomes a manual override. Our own - // writes are fenced by appliedHeight, so controlled value updates cannot capture themselves. - manualHeight = observedHeight; - textarea.style.height = `${observedHeight}px`; - textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, observedHeight); - appliedHeight = observedHeight; + event.preventDefault(); + activePointerId = event.pointerId; + dragStartY = event.clientY; + dragStartHeight = Math.max(appliedHeight, readBorderBoxHeight(textarea)); + previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = "none"; + textarea.setPointerCapture?.(event.pointerId); }; - const onResizeEvent = () => observeNativeResize(); - textarea.addEventListener("resize", onResizeEvent); + const onPointerMove = (event: PointerEvent) => { + if (event.pointerId !== activePointerId) return; + event.preventDefault(); + const contentHeight = Number.isFinite(textarea.scrollHeight) ? textarea.scrollHeight : 0; + const automaticHeight = clampChatInputHeight(contentHeight, automaticMaxHeight); + const draggedHeight = Math.max(automaticHeight, dragStartHeight + dragStartY - event.clientY); + manualHeight = draggedHeight > automaticHeight ? draggedHeight : null; + textarea.style.height = `${manualHeight ?? automaticHeight}px`; + textarea.style.overflowY = resolveChatInputOverflowY(contentHeight, manualHeight ?? automaticHeight); + appliedHeight = manualHeight ?? automaticHeight; + }; + const onPointerEnd = (event: PointerEvent) => { + if (event.pointerId === activePointerId) finishResize(); + }; + + textarea.addEventListener("pointerdown", onPointerDown); + textarea.addEventListener("pointermove", onPointerMove); + textarea.addEventListener("pointerup", onPointerEnd); + textarea.addEventListener("pointercancel", onPointerEnd); + + // Keep the observer fence for layout-driven box changes, but never treat a controller-authored + // resize as a new manual override. Pointer movement is the sole manual-resize authority. const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver((entries) => { const entry = entries.find((candidate) => candidate.target === textarea); - observeNativeResize(entry); + if (!entry || destroyed || Math.abs(readBorderBoxHeight(textarea, entry) - appliedHeight) < 1) return; + automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea)); }); resizeObserver?.observe(textarea); @@ -180,8 +218,12 @@ export function createChatInputAutosizeController(textarea: HTMLTextAreaElement) }, destroy() { if (destroyed) return; + finishResize(); destroyed = true; - textarea.removeEventListener("resize", onResizeEvent); + textarea.removeEventListener("pointerdown", onPointerDown); + textarea.removeEventListener("pointermove", onPointerMove); + textarea.removeEventListener("pointerup", onPointerEnd); + textarea.removeEventListener("pointercancel", onPointerEnd); resizeObserver?.disconnect(); }, };