FN-076: disable chat textarea mouse resizing

Disable mouse-driven composer resizing while preserving automatic five-line sizing across chat surfaces.

- Remove pointer-based manual resize handling and related CSS affordances.
- Apply consistent autosizing and shrink-to-minimum behavior to all chat composers.
- Update regression coverage, documentation, and the published package changeset.

Files changed:
 .changeset/fn-076-removal.md                       |   7 ++
 docs/dashboard-guide.md                            |   6 +-
 .../app/components/ChatQuestionResponse.css        |   4 +-
 .../app/components/ChatQuestionResponse.tsx        |  23 ++--
 packages/dashboard/app/components/ChatView.css     |   8 +-
 packages/dashboard/app/components/ChatView.tsx     |  18 +--
 .../dashboard/app/components/ComposeChatPanel.css  |   4 +-
 .../dashboard/app/components/ComposeChatPanel.tsx  |  20 +++-
 .../app/components/StandardChatSurface.tsx         |  20 +++-
 packages/dashboard/app/components/TaskChatTab.css  |   4 +-
 packages/dashboard/app/components/TaskChatTab.tsx  |  12 +-
 .../app/components/TaskPlannerChatTab.css          |   4 +-
 .../app/components/TaskPlannerChatTab.tsx          |   8 +-
 .../__tests__/ChatQuestionResponse.test.tsx        |  38 ++++++
 .../__tests__/ChatView.autosize.test.tsx           |  26 ++--
 .../ChatView.chat-input-autosize.test.tsx          |  61 ++--------
 .../__tests__/ChatView.message-edit.test.tsx       |  10 ++
 .../components/__tests__/ComposeChatPanel.test.tsx |  25 ++++
 .../app/components/__tests__/TaskChatTab.test.tsx  |  13 +-
 .../__tests__/TaskPlannerChatTab.test.tsx          |  13 +-
 packages/dashboard/app/utils/chatInputAutosize.ts  | 132 ++-------------------
 21 files changed, 195 insertions(+), 261 deletions(-)

Fusion-Task-Id: FN-076
Fusion-Task-Lineage: d4580427-640b-41c1-b5d1-837da43c7438
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
Fusion Agent
2026-08-20 19:46:40 +00:00
parent a2856ba629
commit 89427dab11
21 changed files with 195 additions and 261 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep dashboard chat textareas automatic through five lines without mouse resizing.
category: fix
dev: Chat composers now use the shared automatic-only five-line autosize controller and shrink after content is removed or cleared.

View File

@@ -743,8 +743,8 @@ Use **Settings → Appearance → Conversation layout** to choose the project-sc
<!-- FNXC:ChatStreamingDocs 2026-08-19-13:52: Ordinary Markdown links in the shared Chat renderer open safely in a new tab and use the assistant-bubble text token so source destinations remain readable on desktop and narrow hosts. -->
Ordinary Markdown links in Direct Chat, Chat Rooms, Quick Chat, floating/dock Chat, and task-detail Planner Chat open in a new browser tab and include the safe `noopener noreferrer` relationship. They retain the complete sanitized destination and use the shared readable, always-underlined Chat treatment on desktop and mobile. Native `fusion://` structure references continue to open their preview cards, file-path controls keep their in-app navigation, and terminal-only CLI output plus separate non-Chat Markdown surfaces are outside this link contract.
<!-- FNXC:ChatComposerDocs 2026-08-19-03:02: Primary conversation drafts must stop displacing the transcript while retaining an intentional desktop/tablet escape hatch. -->
Primary Chat, Rooms, Activity, and task Chat composers grow automatically through five rendered lines, then scroll excess text inside the input. On desktop and tablet, drag the native vertical resize affordance to enlarge the current draft; that manual height is in-memory only, is not saved, and resets when the draft is cleared, sent, or the conversation/task target changes. Mobile keeps the composer compact, capped at five lines, and internally scrolling without advertising a mouse-only resize affordance.
<!-- FNXC:ChatComposerDocs 2026-08-20-19:25: FN-076 makes every dashboard chat textarea automatic-only so mouse resizing cannot leave a shortened or cleared draft enlarged. -->
Every dashboard chat textarea—Primary Chat, Rooms, Activity, task Planner Chat, message correction, question responses, and Compose Chat—grows automatically through five rendered lines and scrolls additional text inside the input. It shrinks as content is removed and returns to its minimum height when cleared. Mouse resizing is unavailable on desktop, tablet, and mobile.
## Mailbox archive
@@ -2351,7 +2351,7 @@ const baseOnly = await loadAllAppCssBaseOnly(); // strips @media/@supports
### File browser editor & autosize textarea
- `FileEditor.tsx` is CodeMirror 6-only (no `<textarea>` fallback). Language resolution: `packages/dashboard/app/utils/codemirror-language.ts`.
- For chat-style composer fields use `packages/dashboard/app/hooks/useAutosizeTextarea.ts`. Pattern: `height = "auto"` then clamp `scrollHeight` to min/max in `useLayoutEffect`. Pair with `resize: none`; keep `overflow-y: hidden` while under the max-height cap and switch to `overflow-y: auto` only after content exceeds the cap.
- For dashboard chat textareas use `packages/dashboard/app/utils/chatInputAutosize.ts`. Its controller resets the used height before measuring, caps automatic growth at five rendered lines, sets `overflow-y: auto` only beyond that cap, and returns cleared content to the minimum. Pair every chat class with `resize: none`; do not add a manual mouse-resize path.
### File-path links

View File

@@ -172,7 +172,9 @@ the mobile column layout stay correct without hardcoded colors.
width: 100%;
min-height: calc(var(--space-xl) * 3);
margin-block-start: var(--space-xs);
resize: vertical;
resize: none;
overflow-y: hidden;
max-height: none;
line-height: 1.45;
}

View File

@@ -4,6 +4,10 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MutableRe
import { useTranslation } from "react-i18next";
import type { ChatQuestion, ChatQuestionAnswers, ChatQuestionAnswerValue, ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { formatQuestionAnswer } from "../utils/parseQuestionToolCall";
import {
createChatInputAutosizeController,
type ChatInputAutosizeController,
} from "../utils/chatInputAutosize";
export interface ChatQuestionResponseProps {
parsed: ParsedQuestionToolCall;
@@ -29,7 +33,7 @@ export function ChatQuestionResponse({
}: ChatQuestionResponseProps) {
const { t } = useTranslation("app");
const [answers, setAnswers] = useState<ChatQuestionAnswers>({});
const textareaRefs = useRef(new Map<string, HTMLTextAreaElement>());
const autosizeControllers = useRef(new Map<string, ChatInputAutosizeController>());
const isValid = useMemo(
() => parsed.questions.every((question) => isQuestionAnswerValid(question, answers[question.id])),
@@ -37,9 +41,8 @@ export function ChatQuestionResponse({
);
useLayoutEffect(() => {
for (const textarea of textareaRefs.current.values()) {
textarea.style.height = "0";
textarea.style.height = `${textarea.scrollHeight}px`;
for (const controller of autosizeControllers.current.values()) {
controller.resize();
}
}, [answers]);
@@ -93,7 +96,7 @@ export function ChatQuestionResponse({
disabled={disabled}
setQuestionAnswer={setQuestionAnswer}
toggleMultiSelect={toggleMultiSelect}
textareaRefs={textareaRefs}
autosizeControllers={autosizeControllers}
/>
)}
</article>
@@ -130,7 +133,7 @@ interface QuestionControlsProps {
disabled: boolean;
setQuestionAnswer: (questionId: string, value: ChatQuestionAnswerValue) => void;
toggleMultiSelect: (questionId: string, optionId: string, checked: boolean) => void;
textareaRefs: MutableRefObject<Map<string, HTMLTextAreaElement>>;
autosizeControllers: MutableRefObject<Map<string, ChatInputAutosizeController>>;
}
function QuestionControls({
@@ -140,7 +143,7 @@ function QuestionControls({
disabled,
setQuestionAnswer,
toggleMultiSelect,
textareaRefs,
autosizeControllers,
}: QuestionControlsProps) {
const { t } = useTranslation("app");
@@ -154,10 +157,10 @@ function QuestionControls({
disabled={disabled}
rows={3}
ref={(element) => {
autosizeControllers.current.get(question.id)?.destroy();
autosizeControllers.current.delete(question.id);
if (element) {
textareaRefs.current.set(question.id, element);
} else {
textareaRefs.current.delete(question.id);
autosizeControllers.current.set(question.id, createChatInputAutosizeController(element));
}
}}
onChange={(event) => setQuestionAnswer(question.id, event.target.value)}

View File

@@ -1185,7 +1185,9 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline
.chat-message-edit-textarea {
width: 100%;
resize: vertical;
resize: none;
overflow-y: hidden;
max-height: none;
min-height: calc(var(--space-lg) * 4);
font: inherit;
color: var(--text);
@@ -1632,8 +1634,8 @@ User messages in direct (model-loop) chat carry a compact edit affordance inline
}
/*
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.
FNXC:ChatComposer 2026-08-20-19:25:
FN-076 requires ChatView composers to use automatic-only sizing through five rendered lines. The controller owns capped overflow and shrink-to-minimum behavior, while this rule removes native mouse resizing at every breakpoint.
*/
.chat-input-textarea {
/* FN-5322: this textarea sits directly inside `.chat-input-wrapper`, which

View File

@@ -1728,13 +1728,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
setShowNewDialog(true);
}, [chatDefaultTarget, chatSettings?.chatNewSessionMode, handleCreateSession]);
const resizeComposer = useCallback((textarea?: HTMLTextAreaElement | null, options?: { resetManual?: boolean }) => {
const resizeComposer = useCallback((textarea?: HTMLTextAreaElement | null) => {
if (!textarea || textarea === inputRef.current) {
inputAutosizeRef.current?.resize(options);
inputAutosizeRef.current?.resize();
return;
}
if (textarea === roomInputRef.current) {
roomAutosizeRef.current?.resize(options);
roomAutosizeRef.current?.resize();
}
}, []);
@@ -1773,23 +1773,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout
useLayoutEffect(() => {
// FNXC:VoiceInput 2026-07-24-05:00: Select the active textarea explicitly so controlled
// programmatic updates, including dictation, resize the room composer instead of a hidden direct input.
resizeComposer(
chatScope === "rooms" ? roomInputRef.current : inputRef.current,
{ resetManual: messageInput.length === 0 },
);
resizeComposer(chatScope === "rooms" ? roomInputRef.current : inputRef.current);
if (focusComposerAfterPrefillRef.current) {
focusComposerAfterPrefillRef.current = false;
inputRef.current?.focus();
}
}, [chatScope, messageInput, activeSession?.id, rooms.activeRoom?.id, resizeComposer]);
useLayoutEffect(() => {
// FNXC:ChatComposer 2026-08-19-02:00: Session and room changes replace the mounted draft target,
// so a height deliberately chosen for the previous conversation must not leak into this one.
inputAutosizeRef.current?.reset();
roomAutosizeRef.current?.reset();
}, [chatScope, activeSession?.id, rooms.activeRoom?.id]);
/*
FNXC:ChatComposerPrefill 2026-07-30-12:00:
The GitHub Import Chat action seeds, but never sends, a selected issue or PR link. A nonce makes

View File

@@ -9,7 +9,9 @@
.compose-chat-panel__input {
min-height: calc(var(--space-xl) * 3);
resize: vertical;
max-height: none;
overflow-y: hidden;
resize: none;
}
.compose-chat-panel__output {

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { MicButton } from "./MicButton";
import type { NativeStructureEmbed } from "@fusion/core";
import { FN_AGENT_ID, useChat } from "../hooks/useChat";
import "./ComposeChatPanel.css";
import {
createChatInputAutosizeController,
type ChatInputAutosizeController,
} from "../utils/chatInputAutosize";
interface ComposeChatPanelProps {
projectId?: string;
@@ -35,9 +39,21 @@ export function ComposeChatPanel({ projectId, embeds, draftBody, onUseDraft, onC
const closed = useRef(false);
const archivedSessionId = useRef<string | null>(null);
const requestRef = useRef<HTMLTextAreaElement>(null);
const autosizeRef = useRef<ChatInputAutosizeController | null>(null);
const dictation = useComposerDictation({ textareaRef: requestRef, value: request, onChange: setRequest, projectId });
const restoredSessionId = useRef<string | null>(null);
const handleRequestRef = useCallback((textarea: HTMLTextAreaElement | null) => {
autosizeRef.current?.destroy();
autosizeRef.current = null;
requestRef.current = textarea;
if (textarea) autosizeRef.current = createChatInputAutosizeController(textarea);
}, []);
useLayoutEffect(() => {
autosizeRef.current?.resize();
}, [request]);
const archiveScratchSession = useCallback((id = scratchSessionId.current) => {
if (!id || archivedSessionId.current === id) return;
archivedSessionId.current = id;
@@ -111,7 +127,7 @@ export function ComposeChatPanel({ projectId, embeds, draftBody, onUseDraft, onC
return (
<section id="compose-chat-panel" className="compose-chat-panel" aria-label={t("composeChat.ariaLabel", "Compose chat narrative helper")} data-testid="compose-chat-panel">
<label className="message-composer-label" htmlFor="compose-chat-request">{t("composeChat.draftNarrative", "Draft narrative")}</label>
<textarea ref={requestRef} id="compose-chat-request" className="input compose-chat-panel__input" value={request} onChange={(event) => setRequest(event.target.value)} />
<textarea ref={handleRequestRef} id="compose-chat-request" className="input compose-chat-panel__input" value={request} onChange={(event) => setRequest(event.target.value)} />
<div className="compose-chat-panel__output" aria-live="polite">{latestDraft || t("composeChat.emptyDraft", "Ask the assistant to draft the narrative around your attached structures.")}</div>
<div className="compose-chat-panel__actions"><MicButton {...dictation.micProps} />
<button className="btn btn-sm btn-primary" type="button" onClick={() => void send()} disabled={chat.isStreaming || isCreating || hasPendingPrompt || !request.trim()}>{t("composeChat.draft", "Draft")}</button>

View File

@@ -1,5 +1,5 @@
import type { Agent } from "@fusion/core";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -16,6 +16,10 @@ import { nativeStructureChatRefMatcher, parseNativeStructureChatRef, splitNative
import { MicButton } from "./MicButton";
import { useComposerDictation } from "../hooks/useComposerDictation";
import { ToolCallDetails, formatToolArgsPreview, formatToolPreview, hasToolCallDetails } from "./ToolCallDetails";
import {
createChatInputAutosizeController,
type ChatInputAutosizeController,
} from "../utils/chatInputAutosize";
export interface StandardRoomContext {
roomName: string;
@@ -557,10 +561,22 @@ function StandardChatMessageEditComposer({
}) {
const { t } = useTranslation("app");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const autosizeRef = useRef<ChatInputAutosizeController | null>(null);
// FNXC:VoiceInput 2026-07-25-12:15: Message correction dictation must resolve availability
// within the owning project; falling back to another project's settings can expose the mic incorrectly.
const dictation = useComposerDictation({ textareaRef, value, onChange, projectId });
const handleTextareaRef = useCallback((textarea: HTMLTextAreaElement | null) => {
autosizeRef.current?.destroy();
autosizeRef.current = null;
textareaRef.current = textarea;
if (textarea) autosizeRef.current = createChatInputAutosizeController(textarea);
}, []);
useLayoutEffect(() => {
autosizeRef.current?.resize();
}, [value]);
useEffect(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
@@ -569,7 +585,7 @@ function StandardChatMessageEditComposer({
return (
<div className="chat-message-edit-editor" data-testid={`chat-message-edit-editor-${messageId}`}>
<textarea
ref={textareaRef}
ref={handleTextareaRef}
className="input chat-message-edit-textarea"
value={value}
disabled={disabled}

View File

@@ -546,8 +546,8 @@ 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-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.
FNXC:ChatComposer 2026-08-20-19:25:
FN-076 requires Activity Chat to grow automatically through five rendered lines, internally scroll excess content, and shrink after deletion. Native mouse resizing is disabled so the shared controller remains the only height authority.
*/
.task-chat-input {
min-height: calc(var(--space-2xl) + var(--space-sm));

View File

@@ -745,8 +745,8 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
const showLoadingIndicator = loadingIndicatorTaskId === task.id;
const resizeComposer = useCallback((options?: { resetManual?: boolean }) => {
autosizeRef.current?.resize(options);
const resizeComposer = useCallback(() => {
autosizeRef.current?.resize();
}, []);
const handleComposerRef = useCallback((textarea: HTMLTextAreaElement | null) => {
@@ -758,15 +758,9 @@ export function TaskChatTab({ task, columnFlags, projectId, active, addToast, on
}, []);
useLayoutEffect(() => {
resizeComposer({ resetManual: draft.length === 0 });
resizeComposer();
}, [draft, resizeComposer]);
useLayoutEffect(() => {
// FNXC:ChatComposer 2026-08-19-02:00: Reused task-detail instances must not carry a
// manually enlarged Activity composer into another task's draft.
autosizeRef.current?.reset();
}, [task.id]);
const cancelAnchorTranscriptFrame = useCallback(() => {
if (anchorFrameRef.current === null) return;
window.cancelAnimationFrame(anchorFrameRef.current);

View File

@@ -247,8 +247,8 @@ Task Chat keeps model and thinking controls reachable beside the composer, reusi
}
/*
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.
FNXC:ChatComposer 2026-08-20-19:25:
FN-076 requires Planner Chat to grow automatically through five rendered lines, internally scroll excess content, and shrink after deletion. Native mouse resizing is disabled and CSS leaves the controller's measured cap authoritative.
*/
.task-planner-chat-input {
box-sizing: border-box;

View File

@@ -481,15 +481,9 @@ export function TaskPlannerChatTab({ task, columnFlags, projectId, active, expan
}, []);
useLayoutEffect(() => {
autosizeRef.current?.resize({ resetManual: draft.length === 0 });
autosizeRef.current?.resize();
}, [draft]);
useLayoutEffect(() => {
// FNXC:ChatComposer 2026-08-19-02:00: Task and planner-session replacement starts a
// fresh draft target, so an intentional resize from the prior conversation is cleared.
autosizeRef.current?.reset();
}, [task.id, sessionId]);
const replacePendingMessages = useCallback((nextMessages: readonly string[], resolvedSessionId = sessionIdRef.current) => {
const normalizedMessages = normalizePendingMessages(nextMessages);
pendingMessagesRef.current = normalizedMessages;

View File

@@ -1,8 +1,13 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ChatQuestionResponse } from "../ChatQuestionResponse";
import type { ParsedQuestionToolCall } from "../../utils/parseQuestionToolCall";
import { clampChatInputHeight, getChatInputAutomaticMaxHeight, getChatInputBoxMetrics } from "../../utils/chatInputAutosize";
const chatQuestionResponseCss = readFileSync(resolve(__dirname, "../ChatQuestionResponse.css"), "utf8");
const parsed: ParsedQuestionToolCall = {
questions: [
@@ -86,6 +91,39 @@ describe("ChatQuestionResponse", () => {
expect(yesButton).toHaveAttribute("aria-pressed", "false");
});
it("autosizes independent text answers through five lines and returns each cleared answer to its minimum", async () => {
const user = userEvent.setup();
const twoTextQuestions: ParsedQuestionToolCall = {
questions: [
{ id: "short", type: "text", question: "Short answer" },
{ id: "long", type: "text", question: "Long answer" },
],
};
render(<ChatQuestionResponse parsed={twoTextQuestions} onSubmit={vi.fn()} />);
const short = screen.getByTestId("chat-question-response-text-short") as HTMLTextAreaElement;
const long = screen.getByTestId("chat-question-response-text-long") as HTMLTextAreaElement;
Object.defineProperty(short, "scrollHeight", { configurable: true, get: () => short.value ? 60 : 24 });
Object.defineProperty(long, "scrollHeight", { configurable: true, get: () => long.value ? 500 : 24 });
await user.type(short, "brief");
await user.type(long, "one\ntwo\nthree\nfour\nfive\nsix");
expect(short.style.height).toBe(`${clampChatInputHeight(60, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(short)))}px`);
expect(long.style.height).toBe(`${clampChatInputHeight(500, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(long)))}px`);
expect(long.style.overflowY).toBe("auto");
await user.clear(long);
expect(long.style.height).toBe(`${clampChatInputHeight(24, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(long)))}px`);
expect(short.style.height).toBe(`${clampChatInputHeight(60, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(short)))}px`);
});
it("uses controller-owned overflow instead of a native resize grip", () => {
const textareaRule = chatQuestionResponseCss.match(/\.chat-question-response__textarea\s*\{[^}]*\}/)?.[0] ?? "";
expect(textareaRule).toContain("resize: none");
expect(textareaRule).toContain("overflow-y: hidden");
expect(textareaRule).not.toContain("resize: vertical");
});
it("supports compact mode", () => {
render(<ChatQuestionResponse parsed={{ questions: [parsed.questions[0]!] }} compact onSubmit={vi.fn()} />);
expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");

View File

@@ -310,7 +310,7 @@ describe("ChatView composer autosize", () => {
}
});
it("top-edge expands direct chat and clearing its draft restores the default height", async () => {
it("ignores the former top-edge pointer drag and clears direct chat to its minimum", async () => {
const sendMessage = vi.fn();
setup({ sendMessage });
renderChatView();
@@ -323,17 +323,14 @@ describe("ChatView composer autosize", () => {
await userEvent.type(textarea, "long 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);
pointer("pointerdown", 0);
pointer("pointermove", -200);
pointer("pointerup", -200);
expect(Number.parseInt(textarea.style.height, 10)).toBeGreaterThan(automaticHeight);
expect(Number.parseInt(textarea.style.height, 10)).toBe(automaticHeight);
await userEvent.click(screen.getAllByTestId("chat-send-btn")[0]);
await waitFor(() => {
@@ -343,7 +340,7 @@ describe("ChatView composer autosize", () => {
});
});
it("top-edge expands rooms chat and clearing its draft restores the default height", async () => {
it("ignores the former top-edge pointer drag and clears rooms chat to its minimum", async () => {
localStorage.setItem("fusion:chat-scope", "rooms");
renderChatView();
@@ -354,16 +351,13 @@ describe("ChatView composer autosize", () => {
});
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);
pointer("pointerdown", 0);
pointer("pointermove", -200);
pointer("pointerup", -200);
expect(Number.parseInt(textarea.style.height, 10)).toBe(automaticHeight);
await userEvent.clear(textarea);
await waitFor(() => {

View File

@@ -86,7 +86,7 @@ describe("ChatView chat input autosize", () => {
expect(resolveChatInputOverflowY(maxHeight + 1, maxHeight)).toBe("auto");
});
it("resizes only from the top edge and releases a stale manual height after shortening", () => {
it("ignores the former desktop top-edge pointer sequence and shrinks after deletion", () => {
const textarea = document.createElement("textarea");
document.body.append(textarea);
let scrollHeight = 360;
@@ -94,17 +94,6 @@ describe("ChatView chat input autosize", () => {
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,
@@ -116,21 +105,13 @@ describe("ChatView chat input autosize", () => {
};
const controller = createChatInputAutosizeController(textarea);
const automaticHeight = Number.parseInt(textarea.style.height, 10);
expect(automaticHeight).toBeLessThan(scrollHeight);
const nonTopPointer = pointer("pointerdown", 320);
expect(nonTopPointer.defaultPrevented).toBe(false);
pointer("pointermove", 180);
expect(textarea.style.height).toBe(`${automaticHeight}px`);
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");
const automaticHeight = textarea.style.height;
const topPointer = pointer("pointerdown", 0);
pointer("pointermove", -200);
pointer("pointerup", -200);
expect(topPointer.defaultPrevented).toBe(false);
expect(textarea.style.height).toBe(automaticHeight);
expect(document.body.style.userSelect).toBe("");
scrollHeight = 24;
controller.resize();
@@ -144,32 +125,6 @@ describe("ChatView chat input autosize", () => {
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("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);
const automaticHeight = textarea.style.height;
const event = Object.assign(new Event("pointerdown", { cancelable: true }), {
clientY: 0,
pointerId: 1,
pointerType: "mouse",
});
textarea.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(textarea.style.height).toBe(automaticHeight);
controller.destroy();
textarea.remove();
vi.unstubAllGlobals();
});
});

View File

@@ -6,6 +6,8 @@ assistant messages, CLI-agent-backed sessions, and Rooms, and is disabled while
streaming. Also covers the inline editor save/cancel interaction and the
editMessageAndResend wiring.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { ChatView } from "../ChatView";
@@ -16,6 +18,7 @@ import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
Element.prototype.scrollIntoView = vi.fn();
const chatViewCss = readFileSync(resolve(__dirname, "../ChatView.css"), "utf8");
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
@@ -141,6 +144,13 @@ function baseChatState(overrides: Partial<UseChatReturn> = {}): UseChatReturn {
}
describe("ChatView message edit affordance", () => {
it("uses controller-owned overflow instead of a native resize grip", () => {
const textareaRule = chatViewCss.match(/\.chat-message-edit-textarea\s*\{[^}]*\}/)?.[0] ?? "";
expect(textareaRule).toContain("resize: none");
expect(textareaRule).toContain("overflow-y: hidden");
expect(textareaRule).not.toContain("resize: vertical");
});
beforeEach(() => {
localStorage.clear();
mockUseChatRooms.mockReturnValue(baseRoomsState());

View File

@@ -1,6 +1,11 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ComposeChatPanel } from "../ComposeChatPanel";
import { clampChatInputHeight, getChatInputAutomaticMaxHeight, getChatInputBoxMetrics } from "../../utils/chatInputAutosize";
const composeChatPanelCss = readFileSync(resolve(__dirname, "../ComposeChatPanel.css"), "utf8");
const createSession = vi.fn();
const archiveSession = vi.fn();
@@ -93,6 +98,26 @@ describe("ComposeChatPanel", () => {
expect(selectSession).toHaveBeenCalledWith("user-session", prior);
});
it("caps and clears the assistant request with controller-owned overflow", () => {
render(<ComposeChatPanel embeds={[]} draftBody="" onUseDraft={vi.fn()} onClose={vi.fn()} />);
const input = screen.getByLabelText("Draft narrative") as HTMLTextAreaElement;
Object.defineProperty(input, "scrollHeight", { configurable: true, get: () => input.value.length > 10 ? 500 : 24 });
fireEvent.change(input, { target: { value: "one\ntwo\nthree\nfour\nfive\nsix" } });
expect(input.style.height).toBe(`${clampChatInputHeight(500, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(input)))}px`);
expect(input.style.overflowY).toBe("auto");
fireEvent.change(input, { target: { value: "" } });
expect(input.style.height).toBe(`${clampChatInputHeight(24, getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(input)))}px`);
});
it("uses controller-owned overflow instead of a native resize grip", () => {
const textareaRule = composeChatPanelCss.match(/\.compose-chat-panel__input\s*\{[^}]*\}/)?.[0] ?? "";
expect(textareaRule).toContain("resize: none");
expect(textareaRule).toContain("overflow-y: hidden");
expect(textareaRule).not.toContain("resize: vertical");
});
it("returns generated text through Use draft", () => {
const onUseDraft = vi.fn();
render(<ComposeChatPanel embeds={[]} draftBody="Existing text" onUseDraft={onUseDraft} onClose={vi.fn()} />);

View File

@@ -1741,7 +1741,7 @@ describe("TaskChatTab", () => {
it.each([
["active steering", makeTask({ column: "in-progress" })],
["done-task refinement", makeTask({ column: "done" })],
] as const)("caps the %s composer, top-edge expands it, and collapses after clear", async (_label, task) => {
] as const)("caps the %s composer, ignores pointer resizing, and collapses after clear", async (_label, task) => {
const user = userEvent.setup();
mockedAddSteeringComment.mockResolvedValue(task);
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-024-refinement", column: "todo" }));
@@ -1759,16 +1759,13 @@ describe("TaskChatTab", () => {
expect(input.style.overflowY).toBe("auto");
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
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);
pointer("pointerdown", 0);
pointer("pointermove", -200);
pointer("pointerup", -200);
expect(Number.parseInt(input.style.height, 10)).toBe(automaticHeight);
fireEvent.change(input, { target: { value: "" } });
await waitFor(() => {

View File

@@ -296,7 +296,7 @@ describe("TaskPlannerChatTab", () => {
expect(document.body.textContent).not.toContain("5. 6");
});
it("caps the loaded planner composer, top-edge expands it, and collapses on clear", async () => {
it("caps the loaded planner composer, ignores pointer resizing, 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" }],
});
@@ -315,16 +315,13 @@ describe("TaskPlannerChatTab", () => {
expect(input.style.overflowY).toBe("auto");
expect(screen.getByTestId("task-planner-chat-transcript")).toBeInTheDocument();
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);
pointer("pointerdown", 0);
pointer("pointermove", -200);
pointer("pointerup", -200);
expect(Number.parseInt(input.style.height, 10)).toBe(automaticHeight);
fireEvent.change(input, { target: { value: "" } });
await waitFor(() => {

View File

@@ -1,6 +1,6 @@
/*
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.
FNXC:ChatComposer 2026-08-20-19:25:
FN-076 requires every dashboard chat textarea to grow automatically through five rendered lines, scroll excess content internally, and return to its minimum height after content is removed or cleared. Manual mouse resizing is intentionally unavailable so one controller-owned measurement remains the only height authority.
*/
export const CHAT_INPUT_MAX_LINES = 5;
@@ -18,8 +18,7 @@ export interface ChatInputBoxMetrics {
}
export interface ChatInputAutosizeController {
resize(options?: { resetManual?: boolean }): void;
reset(): void;
resize(): void;
destroy(): void;
}
@@ -96,135 +95,28 @@ export function clampChatInputHeight(
return Math.max(CHAT_INPUT_MIN_HEIGHT_PX, Math.min(safeScrollHeight, safeMaxHeight));
}
function readBorderBoxHeight(textarea: HTMLTextAreaElement, entry?: ResizeObserverEntry): number {
const borderBoxSize = entry?.borderBoxSize;
const observedSize = Array.isArray(borderBoxSize) ? borderBoxSize[0] : borderBoxSize;
if (observedSize?.blockSize && observedSize.blockSize > 0) return observedSize.blockSize;
const contentHeight = entry?.contentRect.height ?? 0;
if (contentHeight > 0) {
const metrics = getChatInputBoxMetrics(textarea);
return contentHeight + metrics.paddingTopPx + metrics.paddingBottomPx + metrics.borderTopPx + metrics.borderBottomPx;
}
const rectHeight = textarea.getBoundingClientRect().height;
if (rectHeight > 0) return rectHeight;
if (textarea.offsetHeight > 0) return textarea.offsetHeight;
return parseCssPixels(textarea.style.height) ?? 0;
}
/** Attach autosizing and desktop/tablet top-edge resizing to one mounted primary composer. */
/** Attach automatic-only five-line sizing to one mounted dashboard chat textarea. */
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) => {
const resize = () => {
if (destroyed) return;
if (resetManual) manualHeight = null;
automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
// Clear the previous used size before measurement so shortened and empty controlled drafts shrink.
textarea.style.height = "0px";
const maxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
const contentHeight = Number.isFinite(textarea.scrollHeight) ? textarea.scrollHeight : 0;
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.
textarea.style.height = "0px";
}
const nextHeight = clampChatInputHeight(contentHeight, maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = resolveChatInputOverflowY(contentHeight, nextHeight);
appliedHeight = nextHeight;
textarea.style.overflowY = resolveChatInputOverflowY(contentHeight, maxHeight);
};
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;
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 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);
if (!entry || destroyed || Math.abs(readBorderBoxHeight(textarea, entry) - appliedHeight) < 1) return;
automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
});
resizeObserver?.observe(textarea);
apply();
resize();
return {
resize(options) {
apply(options?.resetManual === true);
},
reset() {
apply(true);
},
resize,
destroy() {
if (destroyed) return;
finishResize();
destroyed = true;
textarea.removeEventListener("pointerdown", onPointerDown);
textarea.removeEventListener("pointermove", onPointerMove);
textarea.removeEventListener("pointerup", onPointerEnd);
textarea.removeEventListener("pointercancel", onPointerEnd);
resizeObserver?.disconnect();
},
};
}