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: FNXC:ChatComposer 2026-08-19-17:58:
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. 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 { .chat-input-textarea {
/* FN-5322: this textarea sits directly inside `.chat-input-wrapper`, which /* 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; box-sizing: border-box;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
resize: vertical; resize: none;
overflow-y: hidden; overflow-y: hidden;
border: var(--btn-border-width) solid var(--border); border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-lg); 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: 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. 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: FNXC:ChatComposer 2026-08-19-17:58:
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. 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 { .task-chat-input {
min-height: calc(var(--space-2xl) + var(--space-sm)); min-height: calc(var(--space-2xl) + var(--space-sm));
max-height: none; max-height: none;
resize: vertical; resize: none;
overflow-y: hidden; overflow-y: hidden;
flex: 1 1 auto; flex: 1 1 auto;
border-radius: var(--radius-lg); 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: FNXC:ChatComposer 2026-08-19-17:58:
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. 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 { .task-planner-chat-input {
box-sizing: border-box; box-sizing: border-box;
@@ -258,7 +258,7 @@ Planner Chat uses the same five-line controller and current-draft native resize
max-height: none; max-height: none;
padding: var(--btn-padding); padding: var(--btn-padding);
overflow-y: hidden; 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(); const sendMessage = vi.fn();
setup({ sendMessage }); setup({ sendMessage });
renderChatView(); renderChatView();
@@ -318,22 +318,58 @@ describe("ChatView composer autosize", () => {
const textarea = screen.getByPlaceholderText("Type a message...") as HTMLTextAreaElement; const textarea = screen.getByPlaceholderText("Type a message...") as HTMLTextAreaElement;
Object.defineProperty(textarea, "scrollHeight", { Object.defineProperty(textarea, "scrollHeight", {
configurable: true, configurable: true,
get: () => 500, get: () => textarea.value.length > 0 ? 500 : 24,
}); });
await userEvent.type(textarea, "long draft"); await userEvent.type(textarea, "long draft");
const automaticHeight = textarea.style.height; const automaticHeight = Number.parseInt(textarea.style.height, 10);
textarea.style.height = "300px"; vi.spyOn(textarea, "getBoundingClientRect").mockReturnValue({
textarea.dispatchEvent(new Event("resize")); bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}),
await userEvent.type(textarea, " more"); });
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(Number.parseInt(textarea.style.height, 10)).toBeGreaterThan(automaticHeight);
expect(textarea.style.height).not.toBe(automaticHeight);
await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]); await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]);
await waitFor(() => { await waitFor(() => {
expect(textarea).toHaveValue(""); 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, clampChatInputHeight,
createChatInputAutosizeController, createChatInputAutosizeController,
getChatInputAutomaticMaxHeight, getChatInputAutomaticMaxHeight,
getChatInputBoxMetrics,
resolveChatInputOverflowY, resolveChatInputOverflowY,
} from "../../utils/chatInputAutosize"; } from "../../utils/chatInputAutosize";
@@ -23,12 +24,13 @@ function fiveLineHeight() {
} }
describe("ChatView chat input autosize", () => { 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*\{[^}]*\}/); const textareaRule = chatViewCss.match(/\.chat-input-textarea\s*\{[^}]*\}/);
expect(textareaRule).not.toBeNull(); expect(textareaRule).not.toBeNull();
expect(textareaRule?.[0]).toContain("max-height: none"); 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("overflow-y: hidden");
expect(textareaRule?.[0]).toContain("min-height: calc(var(--space-2xl) + var(--space-sm))"); 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/); 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"); 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"); const textarea = document.createElement("textarea");
document.body.append(textarea); document.body.append(textarea);
let scrollHeight = 220; let scrollHeight = 360;
Object.defineProperty(textarea, "scrollHeight", { Object.defineProperty(textarea, "scrollHeight", {
configurable: true, configurable: true,
get: () => scrollHeight, 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 controller = createChatInputAutosizeController(textarea);
const automaticHeight = Number.parseInt(textarea.style.height, 10); const automaticHeight = Number.parseInt(textarea.style.height, 10);
expect(automaticHeight).toBeLessThan(scrollHeight); expect(automaticHeight).toBeLessThan(scrollHeight);
textarea.style.height = "300px"; const nonTopPointer = pointer("pointerdown", 320);
textarea.dispatchEvent(new Event("resize")); expect(nonTopPointer.defaultPrevented).toBe(false);
expect(textarea.style.height).toBe("300px"); pointer("pointermove", 180);
expect(textarea.style.height).toBe(`${automaticHeight}px`);
scrollHeight = 240; const topPointer = pointer("pointerdown", 284);
controller.resize(); expect(topPointer.defaultPrevented).toBe(true);
expect(textarea.style.height).toBe("300px"); pointer("pointermove", 0);
pointer("pointerup", 0);
const manualHeight = Number.parseInt(textarea.style.height, 10);
expect(manualHeight).toBeGreaterThan(automaticHeight);
expect(textarea.style.overflowY).toBe("hidden"); expect(textarea.style.overflowY).toBe("hidden");
scrollHeight = 0; scrollHeight = 24;
controller.reset(); controller.resize();
expect(textarea.style.height).toBe(`${CHAT_INPUT_MIN_HEIGHT_PX}px`); expect(textarea.style.height).toBe(`${CHAT_INPUT_MIN_HEIGHT_PX}px`);
expect(textarea.style.overflowY).toBe("hidden"); 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(); controller.destroy();
pointer("pointerdown", 284);
pointer("pointermove", 100);
expect(textarea.style.height).toBe(`${cappedHeight}px`);
expect(document.body.style.userSelect).toBe("");
textarea.remove(); 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"); const textarea = document.createElement("textarea");
document.body.append(textarea); document.body.append(textarea);
Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 220, writable: true }); Object.defineProperty(textarea, "scrollHeight", { configurable: true, value: 220, writable: true });
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
const controller = createChatInputAutosizeController(textarea); const controller = createChatInputAutosizeController(textarea);
controller.resize();
const automaticHeight = textarea.style.height; 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 }); textarea.dispatchEvent(event);
controller.resize({ resetManual: true }); expect(event.defaultPrevented).toBe(false);
expect(textarea.style.height).toBe(`${CHAT_INPUT_MIN_HEIGHT_PX}px`); expect(textarea.style.height).toBe(automaticHeight);
controller.destroy(); controller.destroy();
textarea.remove(); textarea.remove();
vi.unstubAllGlobals();
}); });
}); });

View File

@@ -1741,7 +1741,7 @@ describe("TaskChatTab", () => {
it.each([ it.each([
["active steering", makeTask({ column: "in-progress" })], ["active steering", makeTask({ column: "in-progress" })],
["done-task refinement", makeTask({ column: "done" })], ["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(); const user = userEvent.setup();
mockedAddSteeringComment.mockResolvedValue(task); mockedAddSteeringComment.mockResolvedValue(task);
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-024-refinement", column: "todo" })); 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; const input = screen.getByLabelText("Message active agent session") as HTMLTextAreaElement;
Object.defineProperty(input, "scrollHeight", { Object.defineProperty(input, "scrollHeight", {
configurable: true, configurable: true,
get: () => 500, get: () => input.value.length > 0 ? 500 : 24,
}); });
await user.type(input, "one\ntwo\nthree\nfour\nfive\nsix"); await user.type(input, "one\ntwo\nthree\nfour\nfive\nsix");
@@ -1759,15 +1759,22 @@ describe("TaskChatTab", () => {
expect(input.style.overflowY).toBe("auto"); expect(input.style.overflowY).toBe("auto");
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
input.style.height = "300px"; vi.spyOn(input, "getBoundingClientRect").mockReturnValue({
fireEvent.resize(input); bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}),
await user.type(input, " more"); });
expect(input.style.height).toBe("300px"); 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: "" } }); fireEvent.change(input, { target: { value: "" } });
await waitFor(() => { await waitFor(() => {
expect(input).toHaveValue(""); 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(transcriptRule, "border-radius")).toBe("var(--radius-lg)");
expect(getCssDeclaration(inputRule, "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(mobileInputRule).not.toMatch(/border-radius\s*:/);
expect(getCssDeclaration(globalInputRule, "border-radius")).toBe("var(--radius-sm)"); 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"); 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({ 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" }], 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; const input = await screen.findByLabelText("Message task chat") as HTMLTextAreaElement;
Object.defineProperty(input, "scrollHeight", { Object.defineProperty(input, "scrollHeight", {
configurable: true, configurable: true,
get: () => 500, get: () => input.value.length > 0 ? 500 : 24,
}); });
fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix" } }); fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix" } });
@@ -315,15 +315,22 @@ describe("TaskPlannerChatTab", () => {
expect(input.style.overflowY).toBe("auto"); expect(input.style.overflowY).toBe("auto");
expect(screen.getByTestId("task-planner-chat-transcript")).toBeInTheDocument(); expect(screen.getByTestId("task-planner-chat-transcript")).toBeInTheDocument();
input.style.height = "300px"; vi.spyOn(input, "getBoundingClientRect").mockReturnValue({
fireEvent.resize(input); bottom: 400, height: 118, left: 0, right: 600, top: 282, width: 600, x: 0, y: 282, toJSON: () => ({}),
fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix\nmore" } }); });
expect(input.style.height).toBe("300px"); 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: "" } }); fireEvent.change(input, { target: { value: "" } });
await waitFor(() => { await waitFor(() => {
expect(input).toHaveValue(""); 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).not.toMatch(/(?:^|\n)\s+height:/);
expect(desktopInputRule).toContain(`min-height: ${desktopTabletHeight};`); expect(desktopInputRule).toContain(`min-height: ${desktopTabletHeight};`);
expect(desktopInputRule).toContain("max-height: none;"); 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(desktopInputRule).toContain("overflow-y: hidden;");
expect(desktopSendRule).toContain(`block-size: ${desktopTabletHeight};`); expect(desktopSendRule).toContain(`block-size: ${desktopTabletHeight};`);
expect(desktopSendRule).toContain(`min-block-size: ${desktopTabletHeight};`); expect(desktopSendRule).toContain(`min-block-size: ${desktopTabletHeight};`);

View File

@@ -1,6 +1,6 @@
/* /*
FNXC:ChatComposer 2026-08-19-02:00: 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. 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. 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; export const CHAT_INPUT_MAX_LINES = 5;
@@ -113,24 +113,42 @@ function readBorderBoxHeight(textarea: HTMLTextAreaElement, entry?: ResizeObserv
return parseCssPixels(textarea.style.height) ?? 0; return parseCssPixels(textarea.style.height) ?? 0;
} }
/** /** Attach autosizing and desktop/tablet top-edge resizing to one mounted primary composer. */
* 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.
*/
export function createChatInputAutosizeController(textarea: HTMLTextAreaElement): ChatInputAutosizeController { export function createChatInputAutosizeController(textarea: HTMLTextAreaElement): ChatInputAutosizeController {
let manualHeight: number | null = null; let manualHeight: number | null = null;
let appliedHeight = 0; let appliedHeight = 0;
let automaticMaxHeight = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX; let automaticMaxHeight = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX;
let activePointerId: number | null = null;
let dragStartY = 0;
let dragStartHeight = 0;
let previousUserSelect = "";
let destroyed = false; 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) => { const apply = (resetManual = false) => {
if (destroyed) return; if (destroyed) return;
if (resetManual) manualHeight = null; if (resetManual) manualHeight = null;
automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea)); automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
const contentHeight = Number.isFinite(textarea.scrollHeight) ? textarea.scrollHeight : 0; 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) { if (manualHeight === null) {
// Reset the used height before reading scrollHeight so shrinking drafts recalculate too. // Reset the used height before reading scrollHeight so shrinking drafts recalculate too.
@@ -141,31 +159,51 @@ export function createChatInputAutosizeController(textarea: HTMLTextAreaElement)
appliedHeight = nextHeight; appliedHeight = nextHeight;
}; };
const observeNativeResize = (entry?: ResizeObserverEntry) => { const onPointerDown = (event: PointerEvent) => {
if (destroyed) return; if (destroyed || event.pointerType !== "mouse" || isAutomaticOnlyViewport()) return;
const observedHeight = readBorderBoxHeight(textarea, entry); const rect = textarea.getBoundingClientRect();
if (!observedHeight || Math.abs(observedHeight - appliedHeight) < 1) return; 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)); event.preventDefault();
automaticMaxHeight = nextAutomaticMaxHeight; activePointerId = event.pointerId;
if (observedHeight <= nextAutomaticMaxHeight) return; dragStartY = event.clientY;
dragStartHeight = Math.max(appliedHeight, readBorderBoxHeight(textarea));
// Only a height beyond the automatic five-line ceiling becomes a manual override. Our own previousUserSelect = document.body.style.userSelect;
// writes are fenced by appliedHeight, so controlled value updates cannot capture themselves. document.body.style.userSelect = "none";
manualHeight = observedHeight; textarea.setPointerCapture?.(event.pointerId);
textarea.style.height = `${observedHeight}px`;
textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, observedHeight);
appliedHeight = observedHeight;
}; };
const onResizeEvent = () => observeNativeResize(); const onPointerMove = (event: PointerEvent) => {
textarea.addEventListener("resize", onResizeEvent); 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" const resizeObserver = typeof ResizeObserver === "undefined"
? null ? null
: new ResizeObserver((entries) => { : new ResizeObserver((entries) => {
const entry = entries.find((candidate) => candidate.target === textarea); 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); resizeObserver?.observe(textarea);
@@ -180,8 +218,12 @@ export function createChatInputAutosizeController(textarea: HTMLTextAreaElement)
}, },
destroy() { destroy() {
if (destroyed) return; if (destroyed) return;
finishResize();
destroyed = true; destroyed = true;
textarea.removeEventListener("resize", onResizeEvent); textarea.removeEventListener("pointerdown", onPointerDown);
textarea.removeEventListener("pointermove", onPointerMove);
textarea.removeEventListener("pointerup", onPointerEnd);
textarea.removeEventListener("pointercancel", onPointerEnd);
resizeObserver?.disconnect(); resizeObserver?.disconnect();
}, },
}; };