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:
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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};`);
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user