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:
Fusion Agent
2026-08-19 15:39:38 +00:00
parent b1893a63af
commit ef5ebd21e5
6 changed files with 188 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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();
}
});
});

View File

@@ -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("...");
});
});

View 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,
};
}