diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 8f23a507fc..b17c5c47e7 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -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 = { triage: "var(--triage)", @@ -3282,7 +3283,7 @@ export function ListView({
-
{task.title || task.description}
+
{getTaskTitleDisplay(task).text}
{(hasDependencies || hasProgress) && ( @@ -3522,7 +3523,7 @@ export function ListView({ {t("listView.fastMode", "Fast mode")} )} - {task.title || task.description} + {getTaskTitleDisplay(task).text} diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 72b348ee1e..7526019715 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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; diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 58999dd42f..105fa19593 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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({ )} )} -
- {truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id} -
+ {(() => { + const titleDisplay = getTaskTitleDisplay(task); + const titleText = titleDisplay.source === "title" + ? truncate(titleDisplay.text, MAX_TITLE_LENGTH) + : titleDisplay.text; + return ( +
+ {titleText} +
+ ); + })()} {(() => { // 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 diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 7b6983d572..aa7917c6c5 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -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( 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( 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(); + } + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index fc921d9602..2de7622f0e 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -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(); + 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(); + 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(); + expect(cardTitle(fallback.container)).toHaveTextContent("Description fallback"); + fallback.unmount(); + + const idFallback = render(); + expect(cardTitle(idFallback.container)).toHaveTextContent("FN-blank"); + }); + + it("preserves explicit titles and their existing TaskCard truncation", () => { + const explicitTitle = "t".repeat(201); + const { container } = render(); + 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("..."); + }); +}); diff --git a/packages/dashboard/app/utils/taskTitleDisplay.ts b/packages/dashboard/app/utils/taskTitleDisplay.ts new file mode 100644 index 0000000000..7f26794333 --- /dev/null +++ b/packages/dashboard/app/utils/taskTitleDisplay.ts @@ -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): 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, + }; +}