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:
@@ -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.
|
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.
|
- 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.
|
- 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.
|
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
||||||
|
|||||||
@@ -100,6 +100,45 @@
|
|||||||
margin-bottom: var(--space-md);
|
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 {
|
.detail-description-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -189,6 +228,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.detail-heading-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-summarize-title-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-provenance {
|
.detail-provenance {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "./TaskDetailModal.css";
|
import "./TaskDetailModal.css";
|
||||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { useModalResizePersist } from "../hooks/useModalResizePersist";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
resolveTaskValidatorModel,
|
resolveTaskValidatorModel,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
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 type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||||
import { ApiRequestError } from "../api";
|
import { ApiRequestError } from "../api";
|
||||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||||
@@ -787,6 +787,7 @@ export function TaskDetailContent({
|
|||||||
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
|
const [editSourceIssueUrl, setEditSourceIssueUrl] = useState(task.sourceIssue?.url ?? "");
|
||||||
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
const [editPendingImages, setEditPendingImages] = useState<PendingImage[]>([]);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isSummarizingTitle, setIsSummarizingTitle] = useState(false);
|
||||||
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
|
const [inlinePriority, setInlinePriority] = useState<TaskPriority>(normalizeTaskPriorityValue(task.priority));
|
||||||
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
|
const [isSavingInlinePriority, setIsSavingInlinePriority] = useState(false);
|
||||||
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
|
const [inlineExecutionMode, setInlineExecutionMode] = useState<"standard" | "fast">(normalizeExecutionModeValue(task.executionMode));
|
||||||
@@ -1222,6 +1223,37 @@ export function TaskDetailContent({
|
|||||||
const showGithubTrackingSpinner = !githubTrackedIssue && (isSavingGithubTracking || githubTrackingDetailPending);
|
const showGithubTrackingSpinner = !githubTrackedIssue && (isSavingGithubTracking || githubTrackingDetailPending);
|
||||||
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
|
const effectiveGithubRepoDefault = resolveEffectiveGithubRepoDefault(settings ?? null, globalSettings);
|
||||||
const githubRepoOverrideTrimmed = githubRepoOverrideDraft.trim();
|
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 () => {
|
const handleToggleGithubTracking = useCallback(async () => {
|
||||||
if (!canEditGithubTracking || isSavingGithubTracking) return;
|
if (!canEditGithubTracking || isSavingGithubTracking) return;
|
||||||
@@ -2730,9 +2762,23 @@ export function TaskDetailContent({
|
|||||||
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
|
const shouldTruncate = !descriptionExpanded && displayText.length > DESCRIPTION_TRUNCATE_LENGTH;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="detail-title">
|
<div className="detail-heading-row">
|
||||||
{shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText}
|
<h2 className="detail-title">
|
||||||
</h2>
|
{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 && (
|
{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && (
|
||||||
<button
|
<button
|
||||||
className="detail-description-toggle"
|
className="detail-description-toggle"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ vi.mock("../../api", async (importOriginal) => {
|
|||||||
uploadAttachment: vi.fn(),
|
uploadAttachment: vi.fn(),
|
||||||
deleteAttachment: vi.fn(),
|
deleteAttachment: vi.fn(),
|
||||||
updateTask: vi.fn().mockResolvedValue({}),
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
summarizeTitle: vi.fn().mockResolvedValue("Generated Title"),
|
||||||
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
|
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
|
||||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||||
requestSpecRevision: 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
|
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrPanel, CustomModelDropdown
|
||||||
vi.mock("lucide-react", () => ({
|
vi.mock("lucide-react", () => ({
|
||||||
Pencil: () => null,
|
Pencil: () => null,
|
||||||
Sparkles: () => null,
|
Sparkles: (props: any) => React.createElement("svg", { "data-testid": "sparkles-icon", ...props }),
|
||||||
Globe: () => null,
|
Globe: () => null,
|
||||||
GitPullRequest: () => null,
|
GitPullRequest: () => null,
|
||||||
ExternalLink: () => null,
|
ExternalLink: () => null,
|
||||||
@@ -62,7 +63,7 @@ vi.mock("lucide-react", () => ({
|
|||||||
X: () => null,
|
X: () => null,
|
||||||
Maximize2: () => null,
|
Maximize2: () => null,
|
||||||
Minimize2: () => null,
|
Minimize2: () => null,
|
||||||
Loader2: () => null,
|
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
|
||||||
Bot: () => null,
|
Bot: () => null,
|
||||||
CircleDot: () => null,
|
CircleDot: () => null,
|
||||||
XCircle: () => null,
|
XCircle: () => null,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import {
|
import {
|
||||||
makeTask,
|
makeTask,
|
||||||
@@ -19,6 +20,150 @@ vi.mock("../BranchGroupCard", () => ({
|
|||||||
|
|
||||||
setupTaskDetailModalHooks();
|
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", () => {
|
describe("TaskDetailModal GitHub tracking CTA", () => {
|
||||||
it("disables create tracking issue when task has no usable title", async () => {
|
it("disables create tracking issue when task has no usable title", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
Reference in New Issue
Block a user