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
This commit is contained in:
gsxdsm
2026-06-13 13:41:33 -07:00
parent df4939a656
commit 537cc2ab7f
3 changed files with 265 additions and 305 deletions

View File

@@ -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 {

View File

@@ -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<ColumnId> = new Set<ColumnId>(["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<string | null>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(() => task.column === "triage");
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [titleOverflows, setTitleOverflows] = useState(false);
const titleRef = useRef<HTMLHeadingElement | null>(null);
const displayTitleText = task.title || task.description || task.id;
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
const [uploading, setUploading] = useState(false);
const [dependencies, setDependencies] = useState<string[]>(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({
</div>
) : (
<>
{(() => {
const displayText = task.title || task.description || task.id;
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
return (
<>
<div className="detail-heading-row">
<h2 className="detail-title">
{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}
</h2>
{showSummarizeTitleButton && (
<button
type="button"
className="detail-summarize-title-btn"
onClick={() => void handleSummarizeTitle()}
disabled={isSummarizingTitle || isSaving}
data-testid="summarize-title-btn"
>
{isSummarizingTitle ? <Loader2 size={14} className="spinner" /> : <Sparkles size={14} />}
<span>{t("taskDetail.title.summarize", "Summarize as title")}</span>
</button>
)}
</div>
{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && (
<button
className="detail-description-toggle"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
>
{descriptionExpanded ? t("taskDetail.description.showLess", "Show less") : t("taskDetail.description.showMore", "Show more")}
</button>
)}
</>
);
})()}
<>
<div className="detail-heading-row">
<h2
ref={titleRef}
className={`detail-title${descriptionExpanded ? "" : " detail-title--collapsed"}`}
>
{displayTitleText}
</h2>
{showSummarizeTitleButton && (
<button
type="button"
className="detail-summarize-title-btn"
onClick={() => void handleSummarizeTitle()}
disabled={isSummarizingTitle || isSaving}
data-testid="summarize-title-btn"
>
{isSummarizingTitle ? <Loader2 size={14} className="spinner" /> : <Sparkles size={14} />}
<span>{t("taskDetail.title.summarize", "Summarize as title")}</span>
</button>
)}
</div>
{(titleOverflows || descriptionExpanded) && (
<button
className="detail-description-toggle"
onClick={() => setDescriptionExpanded(!descriptionExpanded)}
>
{descriptionExpanded ? t("taskDetail.description.showLess", "Show less") : t("taskDetail.description.showMore", "Show more")}
</button>
)}
</>
{customFieldDefs && customFieldDefs.length > 0 ? (
<TaskFieldsSection
fieldDefs={customFieldDefs}

View File

@@ -1300,264 +1300,168 @@ describe("TaskDetailModal", () => {
});
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<typeof makeTask>[0] = {}) => render(
<TaskDetailModal
task={makeTask(taskOverrides)}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
column: "triage",
title: longTitle,
description: "Triage planning context",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
column: "triage",
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
column: "triage",
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: undefined,
description: longDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
id: "FN-EMPTY",
column: "triage",
title: undefined,
description: undefined,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: undefined,
description: shortDescription,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: "Short title",
description: "This is a longer description that would be truncated if it were shown as the main text",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
task={makeTask({
title: longTitle,
description: "Short description",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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(
<TaskDetailModal
@@ -1598,60 +1502,71 @@ describe("TaskDetailModal", () => {
);
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(
<TaskDetailModal
task={makeTask({
id: "FN-001",
title: undefined,
description: longDescription1,
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// 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(
<TaskDetailModal
expect(container.querySelector("h2.detail-title")).toBeNull();
expect(container.querySelector(".detail-description-toggle")).toBeNull();
expect(screen.getByLabelText("Title")).toHaveValue(longTitle);
});
it("keeps the summarize-title affordance aligned next to the clamped title", async () => {
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(
<TaskDetailContent
task={makeTask({
id: "FN-002",
title: undefined,
description: longDescription2,
column: "todo",
title: "Chat expanded title ".repeat(25),
description: "Description",
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
initialTab="chat"
/>,
);
// 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");
});
});