diff --git a/.changeset/fn-8805-stable-task-title.md b/.changeset/fn-8805-stable-task-title.md new file mode 100644 index 0000000000..4bb7246220 --- /dev/null +++ b/.changeset/fn-8805-stable-task-title.md @@ -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. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 17d4f1bfef..761767fea1 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1199,17 +1199,30 @@ export function TaskDetailContent({ } }, [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(() => { - setDescriptionExpanded(false); setPlannerChatExpanded(false); - }, [task.column, task.id]); + }, [task.id]); const [highlightStallCode, setHighlightStallCode] = useState(null); const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [titleOverflows, setTitleOverflows] = useState(false); const titleRef = useRef(null); 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(task.attachments || []); const [uploading, setUploading] = useState(false); const [dependencies, setDependencies] = useState(task.dependencies || []); @@ -1274,20 +1287,15 @@ export function TaskDetailContent({ 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 = () => { - let addedCollapsedClass = false; - if (descriptionExpanded && !titleElement.classList.contains("detail-title--collapsed")) { - titleElement.classList.add("detail-title--collapsed"); - addedCollapsedClass = true; - } - + if (cancelled) return; const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1; - - if (addedCollapsedClass) { - titleElement.classList.remove("detail-title--collapsed"); - } - - setTitleOverflows(overflows); + setTitleOverflows((previous) => previous === overflows ? previous : overflows); }; measureTitleOverflow(); @@ -1299,6 +1307,7 @@ export function TaskDetailContent({ window.addEventListener("resize", measureTitleOverflow); return () => { + cancelled = true; resizeObserver?.disconnect(); window.removeEventListener("resize", measureTitleOverflow); }; diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index c213520f7e..d5b953be3a 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -2284,8 +2284,10 @@ describe("TaskDetailModal", () => { describe("description truncation", () => { let titleScrollHeight = 0; let titleClientHeight = 0; + let titleResizeObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }> = []; const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + const originalResizeObserver = Object.getOwnPropertyDescriptor(globalThis, "ResizeObserver"); const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => { titleScrollHeight = scrollHeight; @@ -2313,6 +2315,22 @@ describe("TaskDetailModal", () => { beforeEach(() => { 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", { configurable: true, get() { @@ -2338,6 +2356,11 @@ describe("TaskDetailModal", () => { } else { 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 () => { @@ -2373,6 +2396,93 @@ describe("TaskDetailModal", () => { 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(); + 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(); + 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 () => { renderDetail({ title: "Keyboard title ".repeat(25) });