Files
fusion/packages/dashboard/app/utils/chatInputAutosize.ts
Fusion Agent df5c580a50 FN-024: cap and resize chat composers
Keep chat drafts compact by default while allowing intentional desktop resizing.

- Autosize Chat, Rooms, Activity, and Planner Chat composers through five lines.
- Cap overflow with internal scrolling and provide desktop/tablet-only manual resize behavior.
- Reset transient composer sizing on draft and target lifecycle changes, with regression coverage and operator documentation.
- Preserve shared terminal session documentation, including detach, end, and reopen behavior.

Files changed:
 .changeset/fn-024-chat-composer-height.md          |   7 +
 docs/dashboard-guide.md                            |   3 +
 packages/dashboard/app/components/ChatView.css     |  28 ++-
 packages/dashboard/app/components/ChatView.tsx     |  65 +++----
 packages/dashboard/app/components/TaskChatTab.css  |   9 +-
 packages/dashboard/app/components/TaskChatTab.tsx  |  32 ++--
 packages/dashboard/app/components/TaskPlannerChatTab.css          |  11 +-
 packages/dashboard/app/components/TaskPlannerChatTab.tsx          |  27 ++-
 packages/dashboard/app/components/__tests__/ChatView.autosize.test.tsx           |  64 +++++--
 packages/dashboard/app/components/__tests__/ChatView.chat-input-autosize.test.tsx | 148 ++++++++++------
 packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx  |  34 ++++
 packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx           |  41 ++++-
 packages/dashboard/app/utils/chatInputAutosize.ts  | 192 +++++++++++++++++++--
 13 files changed, 516 insertions(+), 145 deletions(-)

Fusion-Task-Id: FN-024

Fusion-Task-Lineage: 663b9f18-87ac-467b-b67c-6d2e27f34ba4

Co-authored-by: Fusion <noreply@runfusion.ai>
2026-08-20 03:55:39 +00:00

189 lines
7.6 KiB
TypeScript

/*
FNXC:ChatComposer 2026-08-19-02:00:
Primary conversation composers protect the transcript by growing automatically through five rendered lines, then scrolling their own overflow. A native desktop/tablet resize is an explicit current-draft override; it is never persisted and is cleared when the draft is cleared or the composer target is replaced.
*/
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(options?: { resetManual?: boolean }): void;
reset(): 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));
}
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 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 {
let manualHeight: number | null = null;
let appliedHeight = 0;
let automaticMaxHeight = CHAT_INPUT_DEFAULT_MAX_HEIGHT_PX;
let destroyed = false;
const apply = (resetManual = false) => {
if (destroyed) return;
if (resetManual) manualHeight = null;
automaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
const contentHeight = Number.isFinite(textarea.scrollHeight) ? textarea.scrollHeight : 0;
const nextHeight = manualHeight ?? clampChatInputHeight(contentHeight, automaticMaxHeight);
if (manualHeight === null) {
// Reset the used height before reading scrollHeight so shrinking drafts recalculate too.
textarea.style.height = "0px";
}
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = resolveChatInputOverflowY(contentHeight, nextHeight);
appliedHeight = nextHeight;
};
const observeNativeResize = (entry?: ResizeObserverEntry) => {
if (destroyed) return;
const observedHeight = readBorderBoxHeight(textarea, entry);
if (!observedHeight || Math.abs(observedHeight - appliedHeight) < 1) return;
const nextAutomaticMaxHeight = getChatInputAutomaticMaxHeight(getChatInputBoxMetrics(textarea));
automaticMaxHeight = nextAutomaticMaxHeight;
if (observedHeight <= nextAutomaticMaxHeight) return;
// Only a height beyond the automatic five-line ceiling becomes a manual override. Our own
// writes are fenced by appliedHeight, so controlled value updates cannot capture themselves.
manualHeight = observedHeight;
textarea.style.height = `${observedHeight}px`;
textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, observedHeight);
appliedHeight = observedHeight;
};
const onResizeEvent = () => observeNativeResize();
textarea.addEventListener("resize", onResizeEvent);
const resizeObserver = typeof ResizeObserver === "undefined"
? null
: new ResizeObserver((entries) => {
const entry = entries.find((candidate) => candidate.target === textarea);
observeNativeResize(entry);
});
resizeObserver?.observe(textarea);
apply();
return {
resize(options) {
apply(options?.resetManual === true);
},
reset() {
apply(true);
},
destroy() {
if (destroyed) return;
destroyed = true;
textarea.removeEventListener("resize", onResizeEvent);
resizeObserver?.disconnect();
},
};
}