FN-6059: add task title summarization action

Add an inline task-detail action to summarize descriptions into titles.

- add a read-mode "Summarize as title" control in Task Detail for editable tasks with descriptions
- call the title summarizer, persist the generated title, and show loading/success/error feedback
- cover button visibility, pending, success, error, and mobile behavior in Task Detail tests
- document the new Task Detail modal action in the dashboard guide

Files changed:
 docs/dashboard-guide.md                            |   1 +
 .../dashboard/app/components/TaskDetailModal.css   |  47 +++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  56 +++++++-
 .../__tests__/TaskDetailModal.test-helpers.ts      |   5 +-
 .../components/__tests__/TaskDetailModal.test.tsx  | 145 +++++++++++++++++++++
 5 files changed, 247 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6059

Fusion-Task-Lineage: ed5a2f1b-a490-45f7-a747-d28c345f143b
This commit is contained in:
gsxdsm
2026-06-09 03:57:40 -07:00
parent df74a6b752
commit ece4a37745
5 changed files with 247 additions and 7 deletions

View File

@@ -621,6 +621,7 @@ For related global/project configuration behavior, see [Settings reference](./se
Inspect task definition, logs, review feedback, comments, documents, workflow outcomes, model overrides, and task routing from a single modal.
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.

View File

@@ -100,6 +100,45 @@
margin-bottom: var(--space-md);
}
.detail-heading-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.detail-heading-row .detail-title {
margin-bottom: 0;
}
.detail-summarize-title-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
background: none;
border: none;
color: var(--text-dim);
font-size: 0.8125rem;
padding: 0;
cursor: pointer;
text-align: left;
}
.detail-summarize-title-btn:hover:not(:disabled) {
color: var(--text);
}
.detail-summarize-title-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.detail-summarize-title-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.detail-description-toggle {
display: block;
background: none;
@@ -189,6 +228,14 @@
}
@media (max-width: 768px) {
.detail-heading-row {
align-items: flex-start;
}
.detail-summarize-title-btn {
flex-shrink: 0;
}
.detail-provenance {
align-items: flex-start;
}

View File

@@ -1,7 +1,7 @@
import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -22,7 +22,7 @@ import {
resolveTaskValidatorModel,
} from "@fusion/core";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, api } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api";
import { TaskFieldsSection } from "./TaskFieldsSection";
@@ -787,6 +787,7 @@ export function TaskDetailContent({
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isSummarizingTitle, setIsSummarizingTitle] = useState(false);
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
@@ -1222,6 +1223,37 @@ export function TaskDetailContent({
const showGithubTrackingSpinner = !githubTrackedIssue && (isSavingGithubTracking || githubTrackingDetailPending);
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
const githubRepoOverrideTrimmed = githubRepoOverrideDraft.trim();
const hasDescriptionForTitleSummary = (task.description ?? "").trim().length > 0;
const showSummarizeTitleButton = !isEditing && canEdit && hasDescriptionForTitleSummary;
const handleSummarizeTitle = useCallback(async () => {
if (isSummarizingTitle || isSaving || !hasDescriptionForTitleSummary) return;
const requestTaskId = task.id;
setIsSummarizingTitle(true);
try {
const generatedTitle = await summarizeTitle(task.description || "", undefined, undefined, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
const updatedTask = await updateTask(task.id, { title: generatedTitle }, projectId);
if (activeTaskIdRef.current !== requestTaskId) {
return;
}
setFullDetail((prev) => prev
? ({ ...prev, ...updatedTask } as TaskDetail)
: (updatedTask as TaskDetail));
onTaskUpdated?.(updatedTask);
addToast(t("taskDetail.title.summarizeSuccess", "Title updated from description"), "success");
} catch (err) {
if (activeTaskIdRef.current === requestTaskId) {
addToast(t("taskDetail.title.summarizeFailed", "Failed to summarize title: {{error}}", { error: getErrorMessage(err) }), "error");
}
} finally {
if (mountedRef.current && activeTaskIdRef.current === requestTaskId) {
setIsSummarizingTitle(false);
}
}
}, [addToast, hasDescriptionForTitleSummary, isSaving, isSummarizingTitle, onTaskUpdated, projectId, t, task.description, task.id]);
const handleToggleGithubTracking = useCallback(async () => {
if (!canEditGithubTracking || isSavingGithubTracking) return;
@@ -2730,9 +2762,23 @@ export function TaskDetailContent({
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
return (
<>
<h2 className="detail-title">
{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}
</h2>
<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"

View File

@@ -15,6 +15,7 @@ vi.mock("../../api", async (importOriginal) => {
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
summarizeTitle: vi.fn().mockResolvedValue("Generated Title"),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
@@ -47,7 +48,7 @@ vi.mock("../../api", async (importOriginal) => {
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrPanel, CustomModelDropdown
vi.mock("lucide-react", () => ({
Pencil: () => null,
Sparkles: () => null,
Sparkles: (props: any) => React.createElement("svg", { "data-testid": "sparkles-icon", ...props }),
Globe: () => null,
GitPullRequest: () => null,
ExternalLink: () => null,
@@ -62,7 +63,7 @@ vi.mock("lucide-react", () => ({
X: () => null,
Maximize2: () => null,
Minimize2: () => null,
Loader2: () => null,
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import userEvent from "@testing-library/user-event";
import {
makeTask,
@@ -19,6 +20,150 @@ vi.mock("../BranchGroupCard", () => ({
setupTaskDetailModalHooks();
function renderSummarizeTitleModal(overrides: Parameters<typeof makeTask>[0] = {}, props: Partial<ComponentProps<typeof TaskDetailModal>> = {}) {
const addToast = props.addToast ?? vi.fn();
const onTaskUpdated = props.onTaskUpdated ?? vi.fn();
const task = makeTask({
id: "FN-6059",
column: "triage" as any,
title: "Existing title",
description: "This task description should be summarized into a concise task title.",
prompt: "# Prompt",
...overrides,
});
const result = render(
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={addToast}
onTaskUpdated={onTaskUpdated}
{...props}
/>,
);
return { ...result, addToast, onTaskUpdated, task };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("TaskDetailModal summarize title action", () => {
it("renders when the task is editable and has a description", () => {
renderSummarizeTitleModal({ column: "todo" as any });
expect(screen.getByTestId("summarize-title-btn")).toBeVisible();
expect(screen.getByRole("button", { name: "Summarize as title" })).toBeEnabled();
});
it("hides while the task is in edit mode", async () => {
const user = userEvent.setup();
renderSummarizeTitleModal();
expect(screen.getByTestId("summarize-title-btn")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Edit task" }));
expect(screen.queryByTestId("summarize-title-btn")).not.toBeInTheDocument();
});
it("hides for non-editable columns", () => {
renderSummarizeTitleModal({ column: "in-progress" as any });
expect(screen.queryByTestId("summarize-title-btn")).not.toBeInTheDocument();
});
it("hides when the task has no description", () => {
renderSummarizeTitleModal({ description: "" });
expect(screen.queryByTestId("summarize-title-btn")).not.toBeInTheDocument();
});
it("summarizes the description, saves the generated title, and reports success", async () => {
const user = userEvent.setup();
const { summarizeTitle, updateTask } = await import("../../api");
const addToast = vi.fn();
const onTaskUpdated = vi.fn();
const updatedTask = makeTask({ id: "FN-6059", column: "triage" as any, title: "Generated Title" });
vi.mocked(summarizeTitle).mockReset();
vi.mocked(updateTask).mockReset();
vi.mocked(summarizeTitle).mockResolvedValueOnce("Generated Title");
vi.mocked(updateTask).mockResolvedValueOnce(updatedTask);
const { task } = renderSummarizeTitleModal({}, { addToast, onTaskUpdated, projectId: "project-1" });
await user.click(screen.getByTestId("summarize-title-btn"));
await waitFor(() => {
expect(summarizeTitle).toHaveBeenCalledWith(task.description, undefined, undefined, "project-1");
expect(updateTask).toHaveBeenCalledWith("FN-6059", { title: "Generated Title" }, "project-1");
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
expect(addToast).toHaveBeenCalledWith("Title updated from description", "success");
});
expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument();
expect(screen.getByTestId("summarize-title-btn")).toBeEnabled();
});
it("shows a disabled loading state while summarization is pending", async () => {
const user = userEvent.setup();
const { summarizeTitle, updateTask } = await import("../../api");
const deferred = createDeferred<string>();
vi.mocked(summarizeTitle).mockReset();
vi.mocked(updateTask).mockReset();
vi.mocked(summarizeTitle).mockReturnValueOnce(deferred.promise);
vi.mocked(updateTask).mockResolvedValueOnce(makeTask({ id: "FN-6059", title: "Generated Title" }));
renderSummarizeTitleModal();
await user.click(screen.getByTestId("summarize-title-btn"));
expect(screen.getByTestId("summarize-title-btn")).toBeDisabled();
expect(screen.getByTestId("loader2-icon")).toBeInTheDocument();
deferred.resolve("Generated Title");
await waitFor(() => expect(screen.getByTestId("summarize-title-btn")).toBeEnabled());
expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument();
});
it("shows an error toast and re-enables the button when summarization fails", async () => {
const user = userEvent.setup();
const { summarizeTitle, updateTask } = await import("../../api");
const addToast = vi.fn();
vi.mocked(summarizeTitle).mockReset();
vi.mocked(updateTask).mockReset();
vi.mocked(summarizeTitle).mockRejectedValueOnce(new Error("description is too short"));
renderSummarizeTitleModal({}, { addToast });
await user.click(screen.getByTestId("summarize-title-btn"));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith("Failed to summarize title: description is too short", "error");
});
expect(updateTask).not.toHaveBeenCalled();
expect(screen.getByTestId("summarize-title-btn")).toBeEnabled();
});
it("remains visible and accessible on mobile viewports", () => {
Object.defineProperty(window, "innerWidth", { configurable: true, writable: true, value: 390 });
window.dispatchEvent(new Event("resize"));
renderSummarizeTitleModal({ column: "todo" as any });
const button = screen.getByTestId("summarize-title-btn");
expect(button).toBeVisible();
expect(button).toHaveAccessibleName("Summarize as title");
});
});
describe("TaskDetailModal GitHub tracking CTA", () => {
it("disables create tracking issue when task has no usable title", async () => {
const user = userEvent.setup();