From 537cc2ab7f49a8dfa10a4f4a1ffc8a382cd6cf5b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 13:41:33 -0700 Subject: [PATCH] FN-6387: clamp long task detail titles Clamp long task-detail headings to two lines with an explicit expand control. - Replace character-count title truncation with measured two-line CSS clamping. - Add overflow detection so the Show more/Show less toggle only appears when the heading actually wraps past two lines. - Update task detail rendering tests for triage, non-triage, editing, summarize, chat-expanded, desktop, and mobile title surfaces. Files changed: .../dashboard/app/components/TaskDetailModal.css | 10 + .../dashboard/app/components/TaskDetailModal.tsx | 111 ++++- .../__tests__/TaskDetailModal.rendering.test.tsx | 441 +++++++++------------ 3 files changed, 261 insertions(+), 301 deletions(-) Fusion-Task-Id: FN-6387 Fusion-Task-Lineage: 8c1b860c-8fe7-4f91-89e1-b387dbd7320e --- .../app/components/TaskDetailModal.css | 10 + .../app/components/TaskDetailModal.tsx | 111 +++-- .../TaskDetailModal.rendering.test.tsx | 449 +++++++----------- 3 files changed, 265 insertions(+), 305 deletions(-) diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 2a9a35a369..08cd9550c5 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -107,6 +107,16 @@ font-size: 18px; font-weight: 600; margin-bottom: var(--space-md); + overflow-wrap: anywhere; + word-break: break-word; +} + +.detail-title--collapsed { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; } .detail-heading-row { diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index e953fa3a46..2d2865e2ae 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -1,5 +1,5 @@ import "./TaskDetailModal.css"; -import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; @@ -531,8 +531,6 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt } } -const DESCRIPTION_TRUNCATE_LENGTH = 200; - // #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids // (non-members correctly resolve to false → not editable). const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); @@ -672,12 +670,15 @@ export function TaskDetailContent({ // Reset description expanded state when task changes useEffect(() => { - setDescriptionExpanded(task.column === "triage"); + setDescriptionExpanded(false); }, [task.column, task.id]); const [logSubview, setLogSubview] = useState<"activity" | "agent-log">("activity"); const [highlightStallCode, setHighlightStallCode] = useState(null); - const [descriptionExpanded, setDescriptionExpanded] = useState(() => task.column === "triage"); + const [descriptionExpanded, setDescriptionExpanded] = useState(false); + const [titleOverflows, setTitleOverflows] = useState(false); + const titleRef = useRef(null); + const displayTitleText = task.title || task.description || task.id; const [attachments, setAttachments] = useState(task.attachments || []); const [uploading, setUploading] = useState(false); const [dependencies, setDependencies] = useState(task.dependencies || []); @@ -695,6 +696,43 @@ export function TaskDetailContent({ const [showRefineModal, setShowRefineModal] = useState(false); const [prCreateOpen, setPrCreateOpen] = useState(false); + useLayoutEffect(() => { + const titleElement = titleRef.current; + if (!titleElement) { + setTitleOverflows(false); + return; + } + + const measureTitleOverflow = () => { + let addedCollapsedClass = false; + if (descriptionExpanded && !titleElement.classList.contains("detail-title--collapsed")) { + titleElement.classList.add("detail-title--collapsed"); + addedCollapsedClass = true; + } + + const overflows = titleElement.scrollHeight > titleElement.clientHeight + 1; + + if (addedCollapsedClass) { + titleElement.classList.remove("detail-title--collapsed"); + } + + setTitleOverflows(overflows); + }; + + measureTitleOverflow(); + + const resizeObserver = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(measureTitleOverflow) + : null; + resizeObserver?.observe(titleElement); + window.addEventListener("resize", measureTitleOverflow); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener("resize", measureTitleOverflow); + }; + }, [descriptionExpanded, displayTitleText, task.id]); + // Custom field definitions (U13/KTD-14). Resolved for this task's workflow // from the board-workflows payload; absent when the workflow declares none, // in which case the fields section renders nothing (today's UI byte-identical). @@ -2783,39 +2821,36 @@ export function TaskDetailContent({ ) : ( <> - {(() => { - const displayText = task.title || task.description || task.id; - const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH; - return ( - <> -
-

- {shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText} -

- {showSummarizeTitleButton && ( - - )} -
- {displayText.length > DESCRIPTION_TRUNCATE_LENGTH && ( - - )} - - ); - })()} + <> +
+

+ {displayTitleText} +

+ {showSummarizeTitleButton && ( + + )} +
+ {(titleOverflows || descriptionExpanded) && ( + + )} + {customFieldDefs && customFieldDefs.length > 0 ? ( { }); describe("description truncation", () => { - it("expands long triage title by default with Show less button", () => { + let titleScrollHeight = 0; + let titleClientHeight = 0; + const originalScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight"); + const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + + const setTitleLayout = ({ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }) => { + titleScrollHeight = scrollHeight; + titleClientHeight = clientHeight; + }; + + const renderDetail = (taskOverrides: Parameters[0] = {}) => render( + , + ); + + beforeEach(() => { + setTitleLayout({ scrollHeight: 120, clientHeight: 40 }); + Object.defineProperty(HTMLElement.prototype, "scrollHeight", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleScrollHeight : 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get() { + return this instanceof HTMLElement && this.classList.contains("detail-title") ? titleClientHeight : 0; + }, + }); + }); + + afterEach(() => { + if (originalScrollHeight) { + Object.defineProperty(HTMLElement.prototype, "scrollHeight", originalScrollHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "scrollHeight"); + } + if (originalClientHeight) { + Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight); + } else { + Reflect.deleteProperty(HTMLElement.prototype, "clientHeight"); + } + }); + + it("collapses long triage title by default with Show more button and expands on demand", async () => { const longTitle = "Triage title ".repeat(25); - const { container } = render( - , - ); + const { container } = renderDetail({ + column: "triage", + title: longTitle, + description: "Triage planning context", + }); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(longTitle); - const toggle = container.querySelector(".detail-description-toggle"); - expect(toggle?.textContent).toBe("Show less"); + expect(h2).toHaveClass("detail-title--collapsed"); + const toggle = await screen.findByRole("button", { name: "Show more" }); + expect(toggle).toHaveClass("detail-description-toggle"); + + await userEvent.click(toggle); + + expect(container.querySelector("h2.detail-title")?.textContent).toBe(longTitle); + expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); + expect(screen.getByRole("button", { name: "Show less" })).toBeInTheDocument(); }); - it("expands long triage description by default when title is missing", () => { + it("collapses long triage description by default when title is missing", async () => { const longDescription = "Triage description ".repeat(20); - const { container } = render( - , - ); + const { container } = renderDetail({ + column: "triage", + title: undefined, + description: longDescription, + }); const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe(longDescription); - const toggle = container.querySelector(".detail-description-toggle"); - expect(toggle?.textContent).toBe("Show less"); + expect(h2).toHaveClass("detail-title--collapsed"); + expect(await screen.findByRole("button", { name: "Show more" })).toHaveClass("detail-description-toggle"); }); - it("truncates description over 200 characters with Show more button", () => { - const longDescription = "A".repeat(250); - const { container } = render( - , - ); - - const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe("A".repeat(200) + "…"); - const toggle = container.querySelector(".detail-description-toggle"); - expect(toggle?.textContent).toBe("Show more"); - }); - - it("expands full description when Show more is clicked", async () => { - const longDescription = "B".repeat(250); - const { container } = render( - , - ); - - const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; - await act(async () => { - fireEvent.click(toggle); + it("uses the title, description, and id fallback chain for the clamped heading", async () => { + const { container: withTitle } = renderDetail({ + title: "Title wins", + description: "Description loses", }); + expect(withTitle.querySelector("h2.detail-title")?.textContent).toBe("Title wins"); + expect(withTitle.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); + expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); - const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe("B".repeat(250)); - expect(toggle.textContent).toBe("Show less"); - }); - - it("lets Show less and Show more override the triage default for the current task", async () => { - const longDescription = "C".repeat(250); - const { container } = render( - , - ); - - const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; - expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(250)); - expect(toggle.textContent).toBe("Show less"); - - await act(async () => { - fireEvent.click(toggle); + setTitleLayout({ scrollHeight: 40, clientHeight: 40 }); + const { container: withDescription } = renderDetail({ + title: undefined, + description: "Description fallback", }); + expect(withDescription.querySelector("h2.detail-title")?.textContent).toBe("Description fallback"); + expect(withDescription.querySelector(".detail-description-toggle")).toBeNull(); - expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(200) + "…"); - expect(toggle.textContent).toBe("Show more"); - - await act(async () => { - fireEvent.click(toggle); + const { container: withId } = renderDetail({ + id: "FN-FALLBACK", + title: undefined, + description: undefined, }); - - expect(container.querySelector("h2.detail-title")?.textContent).toBe("C".repeat(250)); - expect(toggle.textContent).toBe("Show less"); + expect(withId.querySelector("h2.detail-title")?.textContent).toBe("FN-FALLBACK"); + expect(withId.querySelector(".detail-description-toggle")).toBeNull(); }); - it("collapses description when Show less is clicked", async () => { - const longDescription = "C".repeat(250); - const { container } = render( - , - ); + it.each(["todo", "in-progress", "in-review", "done", "archived"] as const)( + "collapses overflowing non-triage %s title by default", + async (column) => { + const longTitle = `${column} title `.repeat(25); + const { container } = renderDetail({ + column, + title: longTitle, + }); - // First expand - const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; - await act(async () => { - fireEvent.click(toggle); + const h2 = container.querySelector("h2.detail-title"); + expect(h2?.textContent).toBe(longTitle); + expect(h2).toHaveClass("detail-title--collapsed"); + expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + }, + ); + + it("does not render an empty toggle shell when the title fits within two lines", () => { + setTitleLayout({ scrollHeight: 40, clientHeight: 40 }); + const { container } = renderDetail({ + title: "Short title", + description: "This is a longer description that is not shown as the heading while title is present", }); - // Then collapse - await act(async () => { - fireEvent.click(toggle); - }); - - const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe("C".repeat(200) + "…"); - expect(toggle.textContent).toBe("Show more"); - }); - - it("does not show toggle for empty title and description fallback to task id", () => { - const { container } = render( - , - ); - - const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe("FN-EMPTY"); - expect(container.querySelector(".detail-description-toggle")).toBeNull(); - }); - - it("does not show toggle for description under 200 characters", () => { - const shortDescription = "Short description"; - const { container } = render( - , - ); - - const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe(shortDescription); - expect(container.querySelector(".detail-description-toggle")).toBeNull(); - }); - - it("does not show toggle when title is present and short", () => { - const { container } = render( - , - ); - const h2 = container.querySelector("h2.detail-title"); expect(h2?.textContent).toBe("Short title"); + expect(h2).toHaveClass("detail-title--collapsed"); expect(container.querySelector(".detail-description-toggle")).toBeNull(); }); - it("shows toggle when title exceeds 200 characters", () => { - const longTitle = "D".repeat(250); - const { container } = render( - , - ); + it("collapses again when Show less is clicked", async () => { + const longDescription = "C".repeat(250); + const { container } = renderDetail({ + title: undefined, + description: longDescription, + }); + + const toggle = await screen.findByRole("button", { name: "Show more" }); + await userEvent.click(toggle); + expect(container.querySelector("h2.detail-title")?.textContent).toBe(longDescription); + expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); + + await userEvent.click(screen.getByRole("button", { name: "Show less" })); const h2 = container.querySelector("h2.detail-title"); - expect(h2?.textContent).toBe("D".repeat(200) + "…"); - const toggle = container.querySelector(".detail-description-toggle"); - expect(toggle?.textContent).toBe("Show more"); + expect(h2?.textContent).toBe(longDescription); + expect(h2).toHaveClass("detail-title--collapsed"); + expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument(); }); - it("resets to expanded when switching from a non-triage task to a triage task", async () => { + it("resets to collapsed when switching from a non-triage task to a triage task", async () => { const todoDescription = "G".repeat(250); const triageDescription = "H".repeat(250); const { container, rerender } = render( @@ -1577,8 +1481,8 @@ describe("TaskDetailModal", () => { />, ); - expect(container.querySelector("h2.detail-title")?.textContent).toBe("G".repeat(200) + "…"); - expect(container.querySelector(".detail-description-toggle")?.textContent).toBe("Show more"); + await userEvent.click(await screen.findByRole("button", { name: "Show more" })); + expect(container.querySelector("h2.detail-title")).not.toHaveClass("detail-title--collapsed"); rerender( { ); await waitFor(() => { - expect(container.querySelector("h2.detail-title")?.textContent).toBe("H".repeat(250)); + expect(container.querySelector("h2.detail-title")?.textContent).toBe(triageDescription); }); - expect(container.querySelector(".detail-description-toggle")?.textContent).toBe("Show less"); + expect(container.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); + expect(screen.getByRole("button", { name: "Show more" })).toBeInTheDocument(); }); - it("resets expanded state when task changes", async () => { - const longDescription1 = "E".repeat(250); - const longDescription2 = "F".repeat(250); - const { container, rerender } = render( - , - ); - - // Expand the first task - const toggle = container.querySelector(".detail-description-toggle") as HTMLButtonElement; - await act(async () => { - fireEvent.click(toggle); + it("keeps the editing title form unaffected by the read-only clamp", async () => { + const longTitle = "Editable title ".repeat(25); + const { container } = renderDetail({ + column: "todo", + title: longTitle, + description: "Editable description", }); - // Verify expanded - const h2Before = container.querySelector("h2.detail-title"); - expect(h2Before?.textContent).toBe("E".repeat(250)); + expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Edit task" })); - // Change to a different task - rerender( - { + const { container } = renderDetail({ + column: "todo", + title: "Summarize me ".repeat(25), + description: "Description available for summarization", + }); + + expect(container.querySelector(".detail-heading-row h2.detail-title--collapsed")).toBeInTheDocument(); + expect(screen.getByTestId("summarize-title-btn")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + }); + + it("keeps the clamp available in chat-expanded layout", async () => { + const { container } = render( + , ); - // Should be collapsed again - const h2After = container.querySelector("h2.detail-title"); - expect(h2After?.textContent).toBe("F".repeat(200) + "…"); + await userEvent.click(screen.getByRole("button", { name: "Expand chat to full modal" })); + + expect(container.querySelector(".task-detail-content--chat-expanded")).toBeInTheDocument(); + expect(container.querySelector("h2.detail-title")).toHaveClass("detail-title--collapsed"); + expect(await screen.findByRole("button", { name: "Show more" })).toBeInTheDocument(); + }); + + it("has desktop and mobile CSS rules that preserve the two-line title clamp", () => { + const css = readDashboardStylesSource(); + expect(css).toContain(".detail-title--collapsed"); + expectBaseRule(css, ".detail-title--collapsed", "-webkit-line-clamp: 2"); + expectBaseRule(css, ".detail-title--collapsed", "line-clamp: 2"); + expect(css).toContain("@media (max-width: 768px)"); + expectBaseRule(css, ".detail-title", "font-size: 16px"); }); });