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:
7
.changeset/fn-048-chat-composer-resize.md
Normal file
7
.changeset/fn-048-chat-composer-resize.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
|
||||
@@ -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};`);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user