Files
fusion/packages/dashboard/app/utils/chatInputAutosize.ts
Fusion Agent 89427dab11 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>
2026-08-21 04:32:53 +00:00

123 lines
4.8 KiB
TypeScript

/*
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;
export const CHAT_INPUT_MIN_HEIGHT_PX = 40;
const FALLBACK_LINE_HEIGHT_PX = 20;
const FALLBACK_VERTICAL_PADDING_PX = 16;
const FALLBACK_VERTICAL_BORDER_PX = 2;
export interface ChatInputBoxMetrics {
lineHeightPx: number;
paddingTopPx: number;
paddingBottomPx: number;
borderTopPx: number;
borderBottomPx: number;
}
export interface ChatInputAutosizeController {
resize(): void;
destroy(): void;
}
function finitePositive(value: number, fallback: number): number {
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function parseCssPixels(value: string | undefined): number | null {
if (!value) return null;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function readComputedStyle(textarea: HTMLTextAreaElement): CSSStyleDeclaration | null {
if (typeof window === "undefined" || typeof window.getComputedStyle !== "function") return null;
return window.getComputedStyle(textarea);
}
export function getChatInputBoxMetrics(textarea: HTMLTextAreaElement): ChatInputBoxMetrics {
const style = readComputedStyle(textarea);
const fontSize = finitePositive(parseCssPixels(style?.fontSize ?? "") ?? Number.NaN, 16);
const lineHeightValue = style?.lineHeight ?? "";
const parsedLineHeight = parseCssPixels(lineHeightValue);
const lineHeightPx = finitePositive(
parsedLineHeight === null
? Number.NaN
: lineHeightValue.trim().endsWith("px")
? parsedLineHeight
: parsedLineHeight * fontSize,
FALLBACK_LINE_HEIGHT_PX,
);
return {
lineHeightPx,
paddingTopPx: parseCssPixels(style?.paddingTop) ?? FALLBACK_VERTICAL_PADDING_PX / 2,
paddingBottomPx: parseCssPixels(style?.paddingBottom) ?? FALLBACK_VERTICAL_PADDING_PX / 2,
borderTopPx: parseCssPixels(style?.borderTopWidth) ?? FALLBACK_VERTICAL_BORDER_PX / 2,
borderBottomPx: parseCssPixels(style?.borderBottomWidth) ?? FALLBACK_VERTICAL_BORDER_PX / 2,
};
}
export function getChatInputAutomaticMaxHeight(metrics: ChatInputBoxMetrics): number {
const lineHeight = finitePositive(metrics.lineHeightPx, FALLBACK_LINE_HEIGHT_PX);
const chrome = Math.max(0, metrics.paddingTopPx)
+ Math.max(0, metrics.paddingBottomPx)
+ Math.max(0, metrics.borderTopPx)
+ Math.max(0, metrics.borderBottomPx);
return Math.max(CHAT_INPUT_MIN_HEIGHT_PX, Math.ceil(lineHeight * CHAT_INPUT_MAX_LINES + chrome));
}
// The default is a deterministic zero-layout fallback for callers that only have a scrollHeight.
// Mounted composers use getChatInputAutomaticMaxHeight so their actual line box and box chrome win.
export const CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX = getChatInputAutomaticMaxHeight({
lineHeightPx: FALLBACK_LINE_HEIGHT_PX,
paddingTopPx: FALLBACK_VERTICAL_PADDING_PX / 2,
paddingBottomPx: FALLBACK_VERTICAL_PADDING_PX / 2,
borderTopPx: FALLBACK_VERTICAL_BORDER_PX / 2,
borderBottomPx: FALLBACK_VERTICAL_BORDER_PX / 2,
});
export function resolveChatInputOverflowY(
scrollHeight: number,
maxHeight: number = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX,
): "auto" | "hidden" {
return Number.isFinite(scrollHeight) && scrollHeight > maxHeight ? "auto" : "hidden";
}
export function clampChatInputHeight(
scrollHeight: number,
maxHeight: number = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX,
): number {
const safeMaxHeight = Math.max(CHAT_INPUT_MIN_HEIGHT_PX, finitePositive(maxHeight, CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX));
const safeScrollHeight = Number.isFinite(scrollHeight) ? Math.max(0, scrollHeight) : 0;
return Math.max(CHAT_INPUT_MIN_HEIGHT_PX, Math.min(safeScrollHeight, safeMaxHeight));
}
/** Attach automatic-only five-line sizing to one mounted dashboard chat textarea. */
export function createChatInputAutosizeController(textarea: HTMLTextAreaElement): ChatInputAutosizeController {
let destroyed = false;
const resize = () => {
if (destroyed) return;
// 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 nextHeight = clampChatInputHeight(contentHeight, maxHeight);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = resolveChatInputOverflowY(contentHeight, maxHeight);
};
resize();
return {
resize,
destroy() {
destroyed = true;
},
};
}