FN-8805: preserve expanded task titles on resize

Keep task-detail title expansion stable across resize observers and task changes.

- Measure overflow only in the collapsed title layout.
- Preserve explicit expand/collapse selection through stale resize callbacks.
- Cover modal and embedded narrow-title behavior with regression tests.
- Add a patch changeset for the title stability fix.

Files changed:
 .changeset/fn-8805-stable-task-title.md            |   7 ++
 packages/dashboard/app/components/TaskDetailModal.tsx |  39 +++++---
 packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx | 110 +++++++++++++++++++++
 3 files changed, 141 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-8805

Fusion-Task-Lineage: 66177622-78e8-4378-88f6-c0979a96afd2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-05 09:58:45 -07:00
parent 8120c07b6a
commit 4324762d9a
3 changed files with 141 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep long task-detail titles stable when expanded or collapsed.
category: fix
dev: Measures title eligibility only from the collapsed layout so resize callbacks preserve the operator's choice.

View File

@@ -1199,17 +1199,30 @@ export function TaskDetailContent({
} }
}, [activeTab, task.column, isDoneColumn, detailFlagsAreForThisTask]); }, [activeTab, task.column, isDoneColumn, detailFlagsAreForThisTask]);
// Reset description and planner-chat focus state when task changes // Reset planner-chat focus when the operator opens a different task.
useEffect(() => { useEffect(() => {
setDescriptionExpanded(false);
setPlannerChatExpanded(false); setPlannerChatExpanded(false);
}, [task.column, task.id]); }, [task.id]);
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null); const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [titleOverflows, setTitleOverflows] = useState(false); const [titleOverflows, setTitleOverflows] = useState(false);
const titleRef = useRef<HTMLHeadingElement | null>(null); const titleRef = useRef<HTMLHeadingElement | null>(null);
const displayTitleText = task.title || task.description || task.id; const displayTitleText = task.title || task.description || task.id;
/*
FNXC:TaskDetailTitle 2026-08-05-16:42:
Title overflow eligibility belongs to the stable two-line collapsed layout, while expanded or
collapsed display is an explicit operator choice. Do not mutate the live expanded heading to
measure it: ResizeObserver callbacks caused by that class change can otherwise replace the
title-owned control and flicker the detail view. Reset both values before paint only when this
task identity or its displayed title/fallback changes; ordinary rerenders and resize callbacks
must never reverse the choice.
*/
useLayoutEffect(() => {
setDescriptionExpanded(false);
setTitleOverflows(false);
}, [displayTitleText, task.id]);
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []); const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []); const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
@@ -1274,20 +1287,15 @@ export function TaskDetailContent({
return; return;
} }
// Expanded headings have natural height, so only the rendered collapsed layout is a valid
// overflow measurement. The user choice remains mounted while this observer is disconnected.
if (descriptionExpanded) return;
let cancelled = false;
const measureTitleOverflow = () => { const measureTitleOverflow = () => {
let addedCollapsedClass = false; if (cancelled) return;
if (descriptionExpanded && !titleElement.classList.contains("detail-title--collapsed")) {
titleElement.classList.add("detail-title--collapsed");
addedCollapsedClass = true;
}
const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1; const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1;
setTitleOverflows((previous) => previous === overflows ? previous : overflows);
if (addedCollapsedClass) {
titleElement.classList.remove("detail-title--collapsed");
}
setTitleOverflows(overflows);
}; };
measureTitleOverflow(); measureTitleOverflow();
@@ -1299,6 +1307,7 @@ export function TaskDetailContent({
window.addEventListener("resize", measureTitleOverflow); window.addEventListener("resize", measureTitleOverflow);
return () => { return () => {
cancelled = true;
resizeObserver?.disconnect(); resizeObserver?.disconnect();
window.removeEventListener("resize", measureTitleOverflow); window.removeEventListener("resize", measureTitleOverflow);
}; };

View File

@@ -2284,8 +2284,10 @@ describe("TaskDetailModal", () => {
describe("description truncation", () => { describe("description truncation", () => {
let titleScrollHeight = 0; let titleScrollHeight = 0;
let titleClientHeight = 0; let titleClientHeight = 0;
let titleResizeObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }> = [];
const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver");
const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => { const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => {
titleScrollHeight = scrollHeight; titleScrollHeight = scrollHeight;
@@ -2313,6 +2315,22 @@ describe("TaskDetailModal", () => {
beforeEach(() => { beforeEach(() => {
setTitleLayout({ scrollHeight: 120, clientHeight: 40 }); setTitleLayout({ scrollHeight: 120, clientHeight: 40 });
titleResizeObservers = [];
Object.defineProperty(globalThis, "ResizeObserver", {
configurable: true,
value: class TitleResizeObserver {
private readonly observation: { callback: ResizeObserverCallback; disconnected: boolean };
constructor(callback: ResizeObserverCallback) {
this.observation = { callback, disconnected: false };
titleResizeObservers.push(this.observation);
}
observe() {}
unobserve() {}
disconnect() { this.observation.disconnected = true; }
},
});
Object.defineProperty(HTMLElement.prototype, "scrollHeight", { Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
configurable: true, configurable: true,
get() { get() {
@@ -2338,6 +2356,11 @@ describe("TaskDetailModal", () => {
} else { } else {
Reflect.deleteProperty(HTMLElement.prototype, "clientHeight"); Reflect.deleteProperty(HTMLElement.prototype, "clientHeight");
} }
if (originalResizeObserver) {
Object.defineProperty(globalThis, "ResizeObserver", originalResizeObserver);
} else {
Reflect.deleteProperty(globalThis, "ResizeObserver");
}
}); });
it("toggles a long triage title directly without a standalone affordance", async () => { it("toggles a long triage title directly without a standalone affordance", async () => {
@@ -2373,6 +2396,93 @@ describe("TaskDetailModal", () => {
expectNoStandaloneTitleToggle(); expectNoStandaloneTitleToggle();
}); });
it("keeps the modal title control stable through repeated resize callbacks after each activation", async () => {
const longTitle = "Resize-safe title ".repeat(25);
renderDetail({ title: longTitle });
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const collapsedObserver = titleResizeObservers.at(-1);
expect(collapsedObserver).toBeDefined();
await userEvent.click(titleControl);
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "true");
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expect(collapsedObserver?.disconnected).toBe(true);
// Delivery can race disconnect; a stale collapsed-layout observer must not reclaim the choice.
await act(async () => {
for (let index = 0; index < 3; index++) {
collapsedObserver?.callback([], {} as ResizeObserver);
}
});
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "true");
expect(screen.getAllByRole("button", { name: "Collapse task title" })).toHaveLength(1);
await userEvent.click(titleControl);
const recollapsedObserver = titleResizeObservers.at(-1);
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
expect(titleControl).toHaveAttribute("aria-expanded", "false");
expect(recollapsedObserver).not.toBe(collapsedObserver);
await act(async () => {
for (let index = 0; index < 3; index++) {
recollapsedObserver?.callback([], {} as ResizeObserver);
}
});
expect(document.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Expand task title" })).toBe(titleControl);
expect(screen.getAllByRole("button", { name: "Expand task title" })).toHaveLength(1);
expect(document.querySelector("h2.detail-title")?.textContent).toBe(longTitle);
expectNoStandaloneTitleToggle();
});
it("keeps the embedded narrow title choice stable and ignores a switched task's stale observer", async () => {
const longTitle = "Embedded mobile title ".repeat(25);
const props = {
embedded: true,
active: true,
initialTab: "definition" as const,
onMoveTask: noopMove,
onDeleteTask: noopDelete,
onMergeTask: noopMerge,
onOpenDetail: noopOpenDetail,
addToast: noop,
};
const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 375 });
const { rerender } = render(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED", title: longTitle })} />);
fireEvent(window, new Event("resize"));
const titleControl = await screen.findByRole("button", { name: "Expand task title" });
const oldObserver = titleResizeObservers.at(-1);
await userEvent.click(titleControl);
await act(async () => {
oldObserver?.callback([], {} as ResizeObserver);
oldObserver?.callback([], {} as ResizeObserver);
});
expect(document.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed");
expect(screen.getByRole("button", { name: "Collapse task title" })).toBe(titleControl);
setTitleLayout({ scrollHeight: 40, clientHeight: 40 });
rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-EMBEDDED-NEXT", title: "Narrow fitting title" })} />);
await act(async () => {});
expect(document.querySelector("h2.detail-title")?.textContent).toBe("Narrow fitting title");
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
expect(oldObserver?.disconnected).toBe(true);
await act(async () => {
oldObserver?.callback([], {} as ResizeObserver);
oldObserver?.callback([], {} as ResizeObserver);
});
expect(screen.queryByRole("button", { name: /task title/ })).toBeNull();
expectNoStandaloneTitleToggle();
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
});
it("supports keyboard activation through the title control", async () => { it("supports keyboard activation through the title control", async () => {
renderDetail({ title: "Keyboard title ".repeat(25) }); renderDetail({ title: "Keyboard title ".repeat(25) });