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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user