FN-048: resize chat composers from the top edge and collapse stale heights

Resize shared chat composers around a top-edge drag model and clear manual heights when drafts shrink.

- Replace native textarea resizing with desktop/tablet top-edge pointer resizing.
- Collapse stale manual heights back to automatic sizing for shorter drafts.
- Update composer styles, regression coverage, and the published patch changeset.

Files changed:
 .changeset/fn-048-chat-composer-resize.md          |  7 ++
 packages/dashboard/app/components/ChatView.css     |  6 +-
 packages/dashboard/app/components/TaskChatTab.css  |  6 +-
 .../app/components/TaskPlannerChatTab.css          |  6 +-
 .../__tests__/ChatView.autosize.test.tsx           | 54 ++++++++++--
 .../ChatView.chat-input-autosize.test.tsx          | 80 +++++++++++++-----
 .../app/components/__tests__/TaskChatTab.test.tsx  | 24 ++++--
 .../__tests__/TaskPlannerChatTab.test.tsx          | 24 ++++--
 packages/dashboard/app/utils/chatInputAutosize.ts  | 96 ++++++++++++++++------
 9 files changed, 223 insertions(+), 80 deletions(-)

Fusion-Task-Id: FN-048

Fusion-Task-Lineage: 83d573b2-fdaa-48bb-906a-c1e0b074539f

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-19 18:16:45 +00:00
parent ef22fa478a
commit c3aeff5ff1
9 changed files with 221 additions and 78 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
/*

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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)");
});

View File

@@ -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};`);

View File

@@ -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();
},
};