diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 757629ed9e..47191ad609 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 03fc24c8e4..a861877090 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -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; } diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index d2818f4d6b..017ce0072a 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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([]); const [isSaving, setIsSaving] = useState(false); + const [isSummarizingTitle, setIsSummarizingTitle] = useState(false); const [inlinePriority, setInlinePriority] = useState(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 ( <> -

- {shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText} -

+
+

+ {shouldTruncate ? displayText.slice(0, DESCRIPTION_TRUNCATE_LENGTH) + "…" : displayText} +

+ {showSummarizeTitleButton && ( + + )} +
{displayText.length > DESCRIPTION_TRUNCATE_LENGTH && (