FN-044: show bounded description fallbacks for titleless tasks
Display titleless tasks consistently across cards and list views without persisting generated fallback titles. - Centralize title, description, and ID display fallback selection. - Bound long description fallbacks to 200 characters with literal dots and preserve full tooltips. - Cover desktop, mobile, whitespace, length-boundary, and explicit-title behavior. Files changed: packages/dashboard/app/components/ListView.tsx | 5 +- packages/dashboard/app/components/TaskCard.css | 12 +++ packages/dashboard/app/components/TaskCard.tsx | 18 +++++- .../app/components/__tests__/ListView.test.tsx | 64 ++++++++++++++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 43 +++++++++++++++ packages/dashboard/app/utils/taskTitleDisplay.ts | 51 +++++++++++++++++ 6 files changed, 188 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-044 Fusion-Task-Lineage: 822f17f3-4839-4f9e-9aa7-cd90037b67de Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -40,6 +40,7 @@ import { useUnmappedWorkflowRefetch } from "../hooks/useUnmappedWorkflowRefetch"
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import type { DetailTaskOpenOptions, DetailTaskTab } from "../hooks/useModalManager";
|
||||
import { isTaskReverted, partitionRevertedTasks } from "../utils/taskRevert";
|
||||
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
|
||||
|
||||
const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
triage: "var(--triage)",
|
||||
@@ -3282,7 +3283,7 @@ export function ListView({
|
||||
</div>
|
||||
|
||||
<div className="list-card-row">
|
||||
<div className="list-card-title">{task.title || task.description}</div>
|
||||
<div className="list-card-title">{getTaskTitleDisplay(task).text}</div>
|
||||
</div>
|
||||
|
||||
{(hasDependencies || hasProgress) && (
|
||||
@@ -3522,7 +3523,7 @@ export function ListView({
|
||||
<span className="visually-hidden">{t("listView.fastMode", "Fast mode")}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="list-title-text">{task.title || task.description}</span>
|
||||
<span className="list-title-text">{getTaskTitleDisplay(task).text}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -169,6 +169,18 @@ Lock the id to the same chip-height row as .card-header-actions so the task id,
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskTitleDisplay 2026-08-19-15:22:
|
||||
FN-044 bounds only the display-derived description fallback after title precedence has been resolved.
|
||||
The card remains presentation-only: it neither persists a fallback nor restores FN-036's AI-length policy.
|
||||
*/
|
||||
.card-title--bounded-description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-branch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -78,6 +78,7 @@ import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel, type TaskContextMenuColumnFlags, type TaskContextMenuColumnMetadata, type TaskMenuActionDescriptor } from "./TaskContextMenu";
|
||||
import { formatCost, hasTaskCost, taskTotalCost } from "../utils/taskTokenCost";
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import { getTaskTitleDisplay } from "../utils/taskTitleDisplay";
|
||||
import {
|
||||
WORKFLOW_SETTING_VALUES_UPDATED_EVENT,
|
||||
getWorkflowSettingValuesKey,
|
||||
@@ -4060,9 +4061,20 @@ function TaskCardComponent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="card-title" title={task.title || task.description || undefined}>
|
||||
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
|
||||
</div>
|
||||
{(() => {
|
||||
const titleDisplay = getTaskTitleDisplay(task);
|
||||
const titleText = titleDisplay.source === "title"
|
||||
? truncate(titleDisplay.text, MAX_TITLE_LENGTH)
|
||||
: titleDisplay.text;
|
||||
return (
|
||||
<div
|
||||
className={`card-title${titleDisplay.isBoundedDescription ? " card-title--bounded-description" : ""}`}
|
||||
title={titleDisplay.fullText}
|
||||
>
|
||||
{titleText}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{(() => {
|
||||
// Card-placed custom field badges (U13/KTD-14). Bounded to MAX_CARD_FIELDS
|
||||
// with a "+N" overflow chip. Nothing renders when no card fields are
|
||||
|
||||
@@ -6237,3 +6237,67 @@ describe("ListView - Bulk Selection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ListView titleless display fallback (FN-044)", () => {
|
||||
const description200 = "d".repeat(200);
|
||||
const description201 = "e".repeat(201);
|
||||
const expectedBoundedDescription = description201.slice(0, 197) + "...";
|
||||
|
||||
it("uses the shared literal-dot fallback in the desktop table and preserves explicit titles", () => {
|
||||
const viewportSpy = mockDesktopViewport();
|
||||
try {
|
||||
const { container, rerender } = renderListView({
|
||||
tasks: [createMockTask({ id: "FN-044-desktop", title: undefined, description: description201 })],
|
||||
});
|
||||
expect(container.querySelector(".list-title-text")).toHaveTextContent(expectedBoundedDescription);
|
||||
expect(container.querySelector(".list-title-text")?.textContent).toHaveLength(200);
|
||||
|
||||
const explicitTitle = "t".repeat(201);
|
||||
rerender(<ListView
|
||||
tasks={[createMockTask({ id: "FN-044-explicit", title: explicitTitle, description: description201 })]}
|
||||
onMoveTask={vi.fn(async () => createMockTask())}
|
||||
onRetryTask={vi.fn(async () => createMockTask())}
|
||||
onDeleteTask={vi.fn(async () => createMockTask())}
|
||||
onMergeTask={vi.fn(async () => ({ merged: false }))}
|
||||
onResetTask={vi.fn(async () => createMockTask())}
|
||||
onDuplicateTask={vi.fn(async () => createMockTask())}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={mockAddToast}
|
||||
globalPaused={false}
|
||||
onNewTask={vi.fn()}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
/>);
|
||||
expect(container.querySelector(".list-title-text")).toHaveTextContent(explicitTitle);
|
||||
} finally {
|
||||
viewportSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same fallback in mobile cards, including 200-character and whitespace-title controls", () => {
|
||||
const viewportSpy = mockMobileViewport();
|
||||
try {
|
||||
const { container, rerender } = renderListView({
|
||||
tasks: [createMockTask({ id: "FN-044-mobile", title: " ", description: description201 })],
|
||||
});
|
||||
expect(container.querySelector(".list-card-title")).toHaveTextContent(expectedBoundedDescription);
|
||||
|
||||
rerender(<ListView
|
||||
tasks={[createMockTask({ id: "FN-044-200", title: undefined, description: description200 })]}
|
||||
onMoveTask={vi.fn(async () => createMockTask())}
|
||||
onRetryTask={vi.fn(async () => createMockTask())}
|
||||
onDeleteTask={vi.fn(async () => createMockTask())}
|
||||
onMergeTask={vi.fn(async () => ({ merged: false }))}
|
||||
onResetTask={vi.fn(async () => createMockTask())}
|
||||
onDuplicateTask={vi.fn(async () => createMockTask())}
|
||||
onOpenDetail={vi.fn()}
|
||||
addToast={mockAddToast}
|
||||
globalPaused={false}
|
||||
onNewTask={vi.fn()}
|
||||
projectId={TEST_PROJECT_ID}
|
||||
/>);
|
||||
expect(container.querySelector(".list-card-title")).toHaveTextContent(description200);
|
||||
} finally {
|
||||
viewportSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9021,3 +9021,46 @@ describe("TaskCard field editability resolves column traits (U12 — R8)", () =>
|
||||
expect(screen.getByRole("button", EDIT_LABEL)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCard titleless display fallback (FN-044)", () => {
|
||||
const description200 = "d".repeat(200);
|
||||
const description201 = "e".repeat(201);
|
||||
|
||||
function cardTitle(container: HTMLElement): HTMLDivElement {
|
||||
return container.querySelector(".card-title") as HTMLDivElement;
|
||||
}
|
||||
|
||||
it("keeps titleless descriptions through 200 characters unchanged", () => {
|
||||
const { container } = render(<TaskCard task={makeTask({ title: undefined, description: description200 })} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(cardTitle(container)).toHaveTextContent(description200);
|
||||
expect(cardTitle(container)).not.toHaveClass("card-title--bounded-description");
|
||||
});
|
||||
|
||||
it("bounds a 201-character titleless description with literal dots while retaining its full tooltip", () => {
|
||||
const { container } = render(<TaskCard task={makeTask({ title: undefined, description: description201 })} onOpenDetail={noop} addToast={noop} />);
|
||||
const title = cardTitle(container);
|
||||
expect(title).toHaveTextContent(description201.slice(0, 197) + "...");
|
||||
expect(title.textContent).toHaveLength(200);
|
||||
expect(title).toHaveClass("card-title--bounded-description");
|
||||
expect(title).toHaveAttribute("title", description201);
|
||||
});
|
||||
|
||||
it("uses description or task ID for whitespace-only titles and blank descriptions", () => {
|
||||
const fallback = render(<TaskCard task={makeTask({ title: " ", description: "Description fallback" })} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(cardTitle(fallback.container)).toHaveTextContent("Description fallback");
|
||||
fallback.unmount();
|
||||
|
||||
const idFallback = render(<TaskCard task={makeTask({ id: "FN-blank", title: " ", description: " " })} onOpenDetail={noop} addToast={noop} />);
|
||||
expect(cardTitle(idFallback.container)).toHaveTextContent("FN-blank");
|
||||
});
|
||||
|
||||
it("preserves explicit titles and their existing TaskCard truncation", () => {
|
||||
const explicitTitle = "t".repeat(201);
|
||||
const { container } = render(<TaskCard task={makeTask({ title: explicitTitle, description: description201 })} onOpenDetail={noop} addToast={noop} />);
|
||||
const title = cardTitle(container);
|
||||
expect(title).toHaveTextContent(explicitTitle.slice(0, 140) + "…");
|
||||
expect(title).toHaveAttribute("title", explicitTitle);
|
||||
expect(title).not.toHaveClass("card-title--bounded-description");
|
||||
expect(title.textContent).not.toContain("...");
|
||||
});
|
||||
});
|
||||
|
||||
51
packages/dashboard/app/utils/taskTitleDisplay.ts
Normal file
51
packages/dashboard/app/utils/taskTitleDisplay.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
export type TaskTitleDisplaySource = "title" | "description" | "id";
|
||||
|
||||
export interface TaskTitleDisplay {
|
||||
source: TaskTitleDisplaySource;
|
||||
text: string;
|
||||
fullText: string;
|
||||
isBoundedDescription: boolean;
|
||||
}
|
||||
|
||||
const MAX_DESCRIPTION_FALLBACK_LENGTH = 200;
|
||||
const DESCRIPTION_FALLBACK_SUFFIX = "...";
|
||||
|
||||
/**
|
||||
* Selects a display-only card label without changing the authoritative task data.
|
||||
*
|
||||
* FNXC:TaskTitleDisplay 2026-08-19-15:22:
|
||||
* FN-044 renders an ordinary titleless FN-036 task from its description only after a nonblank
|
||||
* persisted title has been ruled out. This UI seam must not restore an AI length policy or persist
|
||||
* a fallback title; selected descriptions above 200 characters use a literal `...` within 200 total.
|
||||
*/
|
||||
export function getTaskTitleDisplay(task: Pick<Task, "id" | "title" | "description">): TaskTitleDisplay {
|
||||
if (typeof task.title === "string" && task.title.trim().length > 0) {
|
||||
return {
|
||||
source: "title",
|
||||
text: task.title,
|
||||
fullText: task.title,
|
||||
isBoundedDescription: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof task.description === "string" && task.description.trim().length > 0) {
|
||||
const isBoundedDescription = task.description.length > MAX_DESCRIPTION_FALLBACK_LENGTH;
|
||||
return {
|
||||
source: "description",
|
||||
text: isBoundedDescription
|
||||
? task.description.slice(0, MAX_DESCRIPTION_FALLBACK_LENGTH - DESCRIPTION_FALLBACK_SUFFIX.length) + DESCRIPTION_FALLBACK_SUFFIX
|
||||
: task.description,
|
||||
fullText: task.description,
|
||||
isBoundedDescription,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "id",
|
||||
text: task.id,
|
||||
fullText: task.id,
|
||||
isBoundedDescription: false,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user