FN-7131: add done-task summary tab

Done task details now open to a read-only Summary tab before Chat.

- Add TaskSummaryTab with completion summary markdown, landed-file stats, completed steps, workflow results, and retry context.
- Default done-task detail entrypoints to Summary while preserving explicit tab selections and plugin tab typing.
- Style and localize the Summary tab, document it, and add dashboard coverage for defaulting and tab content.
- Add a minor changeset for the published CLI package.

Files changed:
 .changeset/fn-7131-summary-tab.md                  |   7 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/App.tsx                     |   2 +-
 .../dashboard/app/components/TaskDetailModal.css   | 181 +++++++++++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  63 ++++--
 .../dashboard/app/components/TaskSummaryTab.tsx    | 160 +++++++++++++++
 .../TaskDetailModal.definition-actions.test.tsx    |  56 +++---
 .../__tests__/TaskDetailModal.summary-tab.test.tsx | 222 +++++++++++++++++++++
 .../dashboard/app/components/dashboard/types.ts    |   2 +-
 .../app/hooks/__tests__/useModalManager.test.ts    |  10 +-
 .../dashboard/app/hooks/useMainPanelTaskDetail.ts  |   9 +-
 packages/dashboard/app/hooks/useModalManager.ts    |  12 +-
 packages/dashboard/app/plugins/types.ts            |   2 +-
 packages/i18n/locales/en/app.json                  |  16 ++
 14 files changed, 687 insertions(+), 56 deletions(-)

Fusion-Task-Id: FN-7131

Fusion-Task-Lineage: e8ca4ade-9904-4705-a151-d9e55f67b398

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 12:15:42 -07:00
parent 45e27f8875
commit a19df338ad
14 changed files with 687 additions and 56 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Done tasks now open on a new Summary tab showing what changed and what the agents did.
category: feature
dev: Adds the "summary" TabId + TaskSummaryTab to TaskDetailModal; done tasks resolve the implicit Chat default to Summary while explicit tab entrypoints are honored.

View File

@@ -966,6 +966,7 @@ For related global/project configuration behavior, see [Settings reference](./se
Inspect task definition, logs, review feedback, comments, artifacts, 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 **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, and retry counts from the already-loaded task detail; non-`done` tasks still open on **Chat** by default.
- The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
- Task-detail Chat messages are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass.
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.

View File

@@ -787,7 +787,7 @@ function AppInner() {
FNXC:TaskDetailBack 2026-06-25-00:00:
Browser and Android Back must close the currently viewed full-panel task detail before leaving the prior dashboard view. The history entry owns an idempotent revert callback that clears stale snapshot state for board/list origins or restores the previous task snapshot for nested task-detail links, and explicit Back-to-board consumes that same entry without pushing a contradictory view entry during popstate.
*/
const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "chat") => {
const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail, initialTab?: DetailTaskTab) => {
const previousView = taskView;
const previousDetailTask = mainPanelDetailTask;
const previousDetailTab = mainPanelDetailInitialTab;

View File

@@ -1555,6 +1555,187 @@ The footer Actions/Move dropdown buttons sit at the BOTTOM of the embedded panel
margin-bottom: 0;
}
/*
FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
The done-task Summary tab is a scrollable overview composed from existing detail data. Reuse detail-section/markdown conventions and tokenized spacing/colors so the new landing tab preserves modal hierarchy across desktop and mobile.
*/
.detail-section--summary {
overflow-y: auto;
}
.task-summary-tab {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.task-summary-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-lg);
background: var(--card);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-lg);
}
.task-summary-section h4,
.task-summary-section h5 {
margin: 0;
}
.task-summary-section h5 {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
font-weight: 600;
}
.task-summary-markdown {
color: var(--text);
}
.task-summary-empty {
margin: 0;
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
}
.task-summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(calc(var(--space-2xl) * 3), 1fr));
gap: var(--space-sm);
margin: 0;
}
.task-summary-stats > div {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
padding: var(--space-sm);
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
}
.task-summary-stats dt {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
text-transform: uppercase;
letter-spacing: calc(var(--btn-border-width) * 0.5);
}
.task-summary-stats dd {
margin: 0;
color: var(--text);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-summary-diff-add {
color: var(--color-success);
}
.task-summary-diff-del {
color: var(--color-error);
}
.task-summary-file-list,
.task-summary-work-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding: 0;
list-style: none;
}
.task-summary-file-list li,
.task-summary-work-list li {
min-width: 0;
padding: var(--space-sm);
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
color: var(--text);
}
.task-summary-file-list li {
font-family: var(--font-mono);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
overflow-wrap: anywhere;
}
.task-summary-subsection {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.task-summary-work-list li {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.task-summary-status {
flex-shrink: 0;
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
background: var(--surface-muted);
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
font-weight: 600;
text-transform: capitalize;
}
.task-summary-status--done,
.task-summary-status--passed {
background: color-mix(in srgb, var(--color-success) 16%, transparent);
color: var(--color-success);
}
.task-summary-status--skipped,
.task-summary-status--advisory_failure,
.task-summary-status--pending {
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
color: var(--color-warning);
}
.task-summary-status--failed {
background: color-mix(in srgb, var(--color-error) 16%, transparent);
color: var(--color-error);
}
.task-summary-retries {
margin: 0;
color: var(--text-muted);
}
@media (max-width: 768px) {
.detail-section--summary {
padding-inline: var(--space-sm);
}
.task-summary-tab {
gap: var(--space-md);
}
.task-summary-section {
padding: var(--space-md);
}
.task-summary-stats {
grid-template-columns: 1fr;
}
.task-summary-work-list li {
align-items: flex-start;
}
}
/* Spec tab layout - allows SpecEditor to fill available vertical space */
.detail-section--spec {
display: flex;

View File

@@ -37,6 +37,7 @@ import { TaskChatTab } from "./TaskChatTab";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
import { TaskSummaryTab } from "./TaskSummaryTab";
import { WorkspaceWorktreesSummary, isWorkspaceTask } from "./WorkspaceWorktreesSummary";
import { TaskForm, type PendingImage } from "./TaskForm";
import { useNodes } from "../hooks/useNodes";
@@ -189,7 +190,24 @@ function formatDurationCompact(ageMs: number): string {
return `${minutes}m`;
}
type TabId = "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
type TabId = "summary" | "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
/*
FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
Done tasks land on Summary instead of Chat so completed work opens on the completion overview. Chat stays the implicit default for every other column, and explicit tab requests continue to win for done tasks.
FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so done tasks can still deep-link directly to Chat.
*/
function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId): TabId {
if (initialTab === "retries") {
return "definition";
}
if (initialTab) {
return initialTab;
}
return column === "done" ? "summary" : "chat";
}
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
const LazySessionTerminal = lazy(() =>
@@ -278,7 +296,7 @@ export interface TaskDetailModalProps {
prAuthAvailable?: boolean;
autoMergeEnabled?: boolean;
onOpenWorkflowEditor?: () => void;
/** Open the modal with this tab active instead of the default Chat view. */
/** Open the modal with this tab active instead of the default done-aware landing view. */
initialTab?: TabId;
/** Mobile-only header affordance mode. */
mobileHeaderMode?: "close" | "back";
@@ -476,7 +494,7 @@ export function TaskDetailContent({
* FNXC:TaskDetailTabs 2026-06-17-00:00:
* FN-6532 makes Chat the default task-detail view when no caller supplies an explicit initial tab.
*/
initialTab = "chat",
initialTab,
mobileHeaderMode = "close",
embedded = false,
onRequestClose,
@@ -486,7 +504,7 @@ export function TaskDetailContent({
}: TaskDetailContentProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column));
const [chatExpanded, setChatExpanded] = useState(false);
// ── CLI agent session (U11) ────────────────────────────────────────────────
@@ -613,11 +631,11 @@ export function TaskDetailContent({
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
useEffect(() => {
setActiveTab(initialTab === "retries" ? "definition" : initialTab);
setActiveTab(resolveDefaultTab(initialTab, task.column));
if (initialTab === "retries") {
setRetriesExpanded(true);
}
}, [initialTab]);
}, [initialTab, task.column]);
useEffect(() => {
if (activeTab === "pr" && task.column !== "in-review") {
@@ -625,6 +643,12 @@ export function TaskDetailContent({
}
}, [activeTab, task.column]);
useEffect(() => {
if (activeTab === "summary" && task.column !== "done") {
setActiveTab("definition");
}
}, [activeTab, task.column]);
// Reset description expanded state when task changes
useEffect(() => {
setDescriptionExpanded(false);
@@ -3050,7 +3074,18 @@ export function TaskDetailContent({
{/*
FNXC:TaskDetailTabs 2026-06-17-00:00:
FN-6532 requires Chat to be the first task-detail tab while preserving every explicit tab entrypoint.
FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
Done tasks expose Summary as the first tab because the implicit Chat default resolves there for completed work; non-done tasks keep Chat first and never render an empty Summary shell.
*/}
{task.column === "done" && (
<button
className={`detail-tab${activeTab === "summary" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("summary")}
>
{t("taskDetail.tabs.summary", "Summary")}
</button>
)}
<button
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("chat")}
@@ -3181,6 +3216,10 @@ export function TaskDetailContent({
<div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} />
</div>
) : activeTab === "summary" && task.column === "done" ? (
<div className="detail-section detail-section--summary">
<TaskSummaryTab task={workingTask} />
</div>
) : activeTab === "chat" ? (
<div className="detail-section detail-section--chat">
<TaskChatTab
@@ -3482,17 +3521,7 @@ export function TaskDetailContent({
</div>
) : (
<>
{/* Summary section - only for done tasks with summary */}
{task.column === "done" && task.summary && (
<div className="detail-section detail-summary">
<h4>{t("taskDetail.summary.heading", "Summary")}</h4>
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
{task.summary}
</ReactMarkdown>
</div>
</div>
)}
{/* FNXC:TaskDetailSummaryTab 2026-06-27-00:00: The former inline Definition-tab completion summary is intentionally removed to avoid duplicating the new done-only Summary tab; Definition keeps merge/retry/source metadata below. */}
<MergeDetails task={task} />
{(retrySummary?.total ?? 0) > 0 && (
<div className="detail-section detail-retries-section">

View File

@@ -0,0 +1,160 @@
import React from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import type { TaskDetail, TaskStep, WorkflowStepResult } from "@fusion/core";
import { createMermaidCodeComponent, sharedRehypePlugins } from "./markdownPipeline";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
const EMPTY_MARKDOWN_CHILD_SEPARATOR = "";
const STRING_OBJECT_TAG = "[object String]";
const markdownLinkifyCodeComponent: NonNullable<Components["code"]> = ({ children, ...props }) => {
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && Object.prototype.toString.call(linkedChildren[0]) === STRING_OBJECT_TAG) {
return <code {...props}>{children}</code>;
}
return <code {...props}>{linkedChildren}</code>;
};
const markdownLinkifyComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
code: createMermaidCodeComponent("task-summary-mermaid-diagram", markdownLinkifyCodeComponent),
};
interface TaskSummaryTabProps {
task: TaskDetail;
}
function getCompletedSteps(steps: TaskStep[] | undefined): TaskStep[] {
return (steps ?? []).filter((step) => step.status === "done" || step.status === "skipped");
}
function getRenderableWorkflowResults(results: WorkflowStepResult[] | undefined): WorkflowStepResult[] {
return (results ?? []).filter((result) => result.status !== "pending");
}
/**
* FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
* TaskSummaryTab aggregates read-only completion data already loaded on TaskDetail: agent-written summary, changed-file metadata, implementation steps, workflow-step outcomes, and retry counts. It does not fetch, persist, or generate AI content so done-task details remain a front-end composition only.
*/
export function TaskSummaryTab({ task }: TaskSummaryTabProps) {
const { t } = useTranslation("app");
const summary = task.summary?.trim();
const changedFiles = task.mergeDetails?.landedFiles?.length
? task.mergeDetails.landedFiles
: task.modifiedFiles ?? [];
const completedSteps = getCompletedSteps(task.steps);
const workflowResults = getRenderableWorkflowResults(task.workflowStepResults);
const retryTotal = task.retrySummary?.total ?? 0;
const hasChangedStats = task.mergeDetails?.filesChanged != null
|| task.mergeDetails?.insertions != null
|| task.mergeDetails?.deletions != null;
const hasChangedContent = changedFiles.length > 0 || hasChangedStats || Boolean(task.mergeDetails?.commitSha);
const hasAgentWork = completedSteps.length > 0 || workflowResults.length > 0 || retryTotal > 0;
return (
<div className="task-summary-tab" data-testid="task-summary-tab">
<section className="task-summary-section task-summary-section--completion">
<h4>{t("taskDetail.summaryTab.completionHeading", "Completion summary")}</h4>
{summary ? (
<div className="markdown-body task-summary-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
{summary}
</ReactMarkdown>
</div>
) : (
<p className="task-summary-empty">{t("taskDetail.summaryTab.noCompletionSummary", "No completion summary was recorded for this task.")}</p>
)}
</section>
{hasChangedContent ? (
<section className="task-summary-section task-summary-section--changes">
<h4>{t("taskDetail.summaryTab.changedHeading", "What changed")}</h4>
{(hasChangedStats || task.mergeDetails?.commitSha) && (
<dl className="task-summary-stats">
{task.mergeDetails?.commitSha && (
<div>
<dt>{t("taskDetail.summaryTab.commit", "Commit")}</dt>
<dd><code>{task.mergeDetails.commitSha.slice(0, 7)}</code></dd>
</div>
)}
{task.mergeDetails?.filesChanged != null && (
<div>
<dt>{t("taskDetail.summaryTab.filesChanged", "Files")}</dt>
<dd>{task.mergeDetails.filesChanged}</dd>
</div>
)}
{task.mergeDetails?.insertions != null && (
<div>
<dt>{t("taskDetail.summaryTab.insertions", "Added")}</dt>
<dd className="task-summary-diff-add">+{task.mergeDetails.insertions}</dd>
</div>
)}
{task.mergeDetails?.deletions != null && (
<div>
<dt>{t("taskDetail.summaryTab.deletions", "Removed")}</dt>
<dd className="task-summary-diff-del">-{task.mergeDetails.deletions}</dd>
</div>
)}
</dl>
)}
{changedFiles.length > 0 ? (
<ul className="task-summary-file-list">
{changedFiles.map((path) => (
<li key={path}><bdo dir="ltr">{path}</bdo></li>
))}
</ul>
) : (
<p className="task-summary-empty">{t("taskDetail.summaryTab.noChangedFiles", "No changed-file list is available for this task.")}</p>
)}
</section>
) : null}
{hasAgentWork ? (
<section className="task-summary-section task-summary-section--agent-work">
<h4>{t("taskDetail.summaryTab.agentWorkHeading", "Work done by agents")}</h4>
{completedSteps.length > 0 && (
<div className="task-summary-subsection">
<h5>{t("taskDetail.summaryTab.completedSteps", "Completed steps")}</h5>
<ul className="task-summary-work-list">
{completedSteps.map((step, index) => (
<li key={`${step.name}-${index}`}>
<span className={`task-summary-status task-summary-status--${step.status}`}>{step.status}</span>
<span>{step.name}</span>
</li>
))}
</ul>
</div>
)}
{workflowResults.length > 0 && (
<div className="task-summary-subsection">
<h5>{t("taskDetail.summaryTab.workflowResults", "Workflow results")}</h5>
<ul className="task-summary-work-list">
{workflowResults.map((result) => (
<li key={`${result.workflowStepId}-${result.completedAt ?? result.startedAt ?? result.workflowStepName}`}>
<span className={`task-summary-status task-summary-status--${result.status}`}>{result.status.replace("_", " ")}</span>
<span>{result.workflowStepName}</span>
</li>
))}
</ul>
</div>
)}
{retryTotal > 0 && (
<p className="task-summary-retries">
{t("taskDetail.summaryTab.retries", "Agents retried this task {{count}} time{{plural}}.", { count: retryTotal, plural: retryTotal === 1 ? "" : "s" })}
</p>
)}
</section>
) : (
<section className="task-summary-section task-summary-section--agent-work">
<h4>{t("taskDetail.summaryTab.agentWorkHeading", "Work done by agents")}</h4>
<p className="task-summary-empty">{t("taskDetail.summaryTab.noAgentWork", "No completed steps or workflow results are available for this task.")}</p>
</section>
)}
</div>
);
}

View File

@@ -255,25 +255,26 @@ describe("TaskDetailModal", () => {
/>,
);
// Done task with commit SHA: Chat, Definition, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing (11 tabs, no Commits)
// Done task with commit SHA: Summary, Chat, Definition, Logs, Changes, Review, Comments, Artifacts, Model, Workflow, Stats, Routing (12 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Chat");
expect(tabs[1].textContent).toBe("Definition");
expect(tabs[2].textContent).toBe("Logs");
expect(tabs[3].textContent).toBe("Changes");
expect(tabs[4].textContent).toBe("Review");
expect(tabs[5].textContent).toBe("Comments");
expect(tabs[6].textContent).toBe("Artifacts");
expect(tabs[7].textContent).toBe("Model");
expect(tabs[8].textContent).toBe("Workflow");
expect(tabs[9].textContent).toBe("Stats");
expect(tabs[10].textContent).toBe("Routing");
expect(tabs.length).toBe(12);
expect(tabs[0].textContent).toBe("Summary");
expect(tabs[1].textContent).toBe("Chat");
expect(tabs[2].textContent).toBe("Definition");
expect(tabs[3].textContent).toBe("Logs");
expect(tabs[4].textContent).toBe("Changes");
expect(tabs[5].textContent).toBe("Review");
expect(tabs[6].textContent).toBe("Comments");
expect(tabs[7].textContent).toBe("Artifacts");
expect(tabs[8].textContent).toBe("Model");
expect(tabs[9].textContent).toBe("Workflow");
expect(tabs[10].textContent).toBe("Stats");
expect(tabs[11].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
it("shows 11 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
it("shows 12 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
const { container } = render(
<TaskDetailModal
task={makeTask({
@@ -291,20 +292,21 @@ describe("TaskDetailModal", () => {
/>,
);
// Done task with workflow steps and commit SHA: 11 tabs including Review (no Commits)
// Done task with workflow steps and commit SHA: 12 tabs including Summary and Review (no Commits)
const tabs = container.querySelectorAll(".detail-tab");
expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Chat");
expect(tabs[1].textContent).toBe("Definition");
expect(tabs[2].textContent).toBe("Logs");
expect(tabs[3].textContent).toBe("Changes");
expect(tabs[4].textContent).toBe("Review");
expect(tabs[5].textContent).toBe("Comments");
expect(tabs[6].textContent).toBe("Artifacts");
expect(tabs[7].textContent).toBe("Model");
expect(tabs[8].textContent).toBe("Workflow");
expect(tabs[9].textContent).toBe("Stats");
expect(tabs[10].textContent).toBe("Routing");
expect(tabs.length).toBe(12);
expect(tabs[0].textContent).toBe("Summary");
expect(tabs[1].textContent).toBe("Chat");
expect(tabs[2].textContent).toBe("Definition");
expect(tabs[3].textContent).toBe("Logs");
expect(tabs[4].textContent).toBe("Changes");
expect(tabs[5].textContent).toBe("Review");
expect(tabs[6].textContent).toBe("Comments");
expect(tabs[7].textContent).toBe("Artifacts");
expect(tabs[8].textContent).toBe("Model");
expect(tabs[9].textContent).toBe("Workflow");
expect(tabs[10].textContent).toBe("Stats");
expect(tabs[11].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});

View File

@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import type { Column } from "@fusion/core";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailContent, TaskDetailModal } from "../TaskDetailModal";
setupTaskDetailModalHooks();
function expectButtonActive(button: HTMLElement): void {
expect(button.classList.contains("detail-tab-active")).toBe(true);
}
function doneTask(overrides = {}) {
return makeTask({
column: "done",
summary: "Completed **summary** with `packages/dashboard/app/components/TaskDetailModal.tsx`.",
modifiedFiles: ["packages/dashboard/app/components/TaskDetailModal.tsx"],
mergeDetails: {
commitSha: "abcdef1234567890",
filesChanged: 2,
insertions: 12,
deletions: 3,
landedFiles: [
"packages/dashboard/app/components/TaskDetailModal.tsx",
"packages/dashboard/app/components/TaskSummaryTab.tsx",
],
},
steps: [
{ name: "Preflight", status: "done" },
{ name: "Skipped optional", status: "skipped" },
{ name: "Still pending", status: "pending" },
],
workflowStepResults: [
{ workflowStepId: "WS-1", workflowStepName: "Code Review", status: "passed" },
{ workflowStepId: "WS-2", workflowStepName: "Advisory Check", status: "advisory_failure" },
],
retrySummary: {
stuckKill: 0,
recovery: 0,
taskDone: 0,
worktreeSession: 0,
workflowStep: 1,
verification: 0,
postReviewFix: 0,
mergeConflict: 0,
branchConflict: 0,
reviewerContext: 0,
reviewerFallback: 0,
total: 1,
},
...overrides,
});
}
describe("TaskDetailModal Summary tab", () => {
it("lands done tasks on Summary and renders completion, changed-files, and agent-work sections", () => {
const { container } = render(
<TaskDetailModal
task={doneTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const summaryButton = screen.getByRole("button", { name: "Summary" });
expectButtonActive(summaryButton);
expect(screen.getByText("Completion summary")).toBeTruthy();
expect(screen.getByText("summary")).toBeTruthy();
expect(screen.getByText("What changed")).toBeTruthy();
expect(screen.getByText("packages/dashboard/app/components/TaskSummaryTab.tsx")).toBeTruthy();
expect(screen.getByText("Work done by agents")).toBeTruthy();
expect(screen.getByText("Preflight")).toBeTruthy();
expect(screen.getByText("Code Review")).toBeTruthy();
expect(screen.getByText("Agents retried this task 1 time.")).toBeTruthy();
expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Summary");
});
it("honors explicit Chat for done tasks", () => {
render(
<TaskDetailModal
task={doneTask()}
initialTab="chat"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expectButtonActive(screen.getByRole("button", { name: "Chat" }));
expect(screen.queryByText("Completion summary")).toBeNull();
});
it("honors explicit non-chat tabs for done tasks", () => {
const changesRender = render(
<TaskDetailModal
task={doneTask()}
initialTab="changes"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expectButtonActive(screen.getByRole("button", { name: "Changes" }));
expect(screen.queryByText("Completion summary")).toBeNull();
changesRender.unmount();
render(
<TaskDetailModal
task={doneTask({ enabledWorkflowSteps: ["WS-1"] })}
initialTab="workflow"
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expectButtonActive(screen.getByRole("button", { name: "Workflow" }));
expect(screen.queryByText("Completion summary")).toBeNull();
});
it("does not render Summary for non-done columns and still defaults to Chat", () => {
for (const column of ["in-progress", "in-review", "todo"] as Column[]) {
const rendered = render(
<TaskDetailModal
task={makeTask({ column })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Summary" })).toBeNull();
expectButtonActive(screen.getByRole("button", { name: "Chat" }));
rendered.unmount();
}
});
it("renders graceful empty states without orphaned changed-file headings", () => {
render(
<TaskDetailModal
task={doneTask({
summary: "",
modifiedFiles: [],
mergeDetails: undefined,
steps: [],
workflowStepResults: [],
retrySummary: { total: 0 },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Completion summary")).toBeTruthy();
expect(screen.getByText("No completion summary was recorded for this task.")).toBeTruthy();
expect(screen.queryByText("What changed")).toBeNull();
expect(screen.getByText("Work done by agents")).toBeTruthy();
expect(screen.getByText("No completed steps or workflow results are available for this task.")).toBeTruthy();
});
it("keeps the Summary tab as a detail-tab inside the horizontally scrollable tab strip", () => {
const { container } = render(
<TaskDetailModal
task={doneTask()}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const tabs = container.querySelector(".detail-tabs");
const summaryButton = screen.getByRole("button", { name: "Summary" });
expect(tabs?.contains(summaryButton)).toBe(true);
expect(summaryButton.classList.contains("detail-tab")).toBe(true);
});
it("resolves the done-task Summary default in embedded TaskDetailContent", () => {
render(
<TaskDetailContent
task={doneTask()}
embedded
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expectButtonActive(screen.getByRole("button", { name: "Summary" }));
expect(screen.getByText("Completion summary")).toBeTruthy();
});
});

View File

@@ -194,7 +194,7 @@ export interface MainContentProps {
openCreateWorkflowWithNav: () => void;
sidebarActive: boolean;
isMobile: boolean;
mainPanelDetailInitialTab: DetailTaskTab;
mainPanelDetailInitialTab: DetailTaskTab | undefined;
closeTaskDetailMainPanel: () => void;
setMainPanelDetailTask: Dispatch<SetStateAction<Task | TaskDetail | null>>;
mergeTask: (id: string) => Promise<MergeResult>;

View File

@@ -219,7 +219,7 @@ describe("useModalManager", () => {
});
expect(result.current.detailTask?.id).toBe("FN-123");
expect(result.current.detailTaskInitialTab).toBe("chat");
expect(result.current.detailTaskInitialTab).toBeUndefined();
act(() => {
result.current.openDetailWithChangesTab(task);
@@ -227,6 +227,12 @@ describe("useModalManager", () => {
expect(result.current.detailTaskInitialTab).toBe("changes");
act(() => {
result.current.openDetailTask(task, "chat");
});
expect(result.current.detailTaskInitialTab).toBe("chat");
act(() => {
result.current.closeDetailTask();
});
@@ -267,7 +273,7 @@ describe("useModalManager", () => {
expect(result.current.detailTask?.id).toBe("FN-456");
// Should not have prompt field (plain Task)
expect("prompt" in (result.current.detailTask as unknown as Record<string, unknown>)).toBe(false);
expect(result.current.detailTaskInitialTab).toBe("chat");
expect(result.current.detailTaskInitialTab).toBeUndefined();
});
it.each([

View File

@@ -1,6 +1,9 @@
/*
FNXC:TaskDetail 2026-06-24-00:00:
Snapshot of the task whose detail is shown in the main panel (Board card click → full-panel detail), plus its initial tab. Kept as a snapshot so the view survives a tasks revalidation. Exposes the setters so App can compose open/close with view navigation, and so the embedded detail can patch the snapshot on task updates (setTask accepts the updater form). Extracted from AppInner.
FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
An undefined initial tab represents the implicit landing tab. Keep it distinct from explicit Chat so completed tasks can default to Summary while callers can still request Chat.
*/
import { useState, type Dispatch, type SetStateAction } from "react";
@@ -9,14 +12,14 @@ import type { DetailTaskTab } from "./useModalManager";
export interface UseMainPanelTaskDetailResult {
task: Task | TaskDetail | null;
initialTab: DetailTaskTab;
initialTab: DetailTaskTab | undefined;
setTask: Dispatch<SetStateAction<Task | TaskDetail | null>>;
setInitialTab: (tab: DetailTaskTab) => void;
setInitialTab: (tab: DetailTaskTab | undefined) => void;
}
export function useMainPanelTaskDetail(): UseMainPanelTaskDetailResult {
const [task, setTask] = useState<Task | TaskDetail | null>(null);
const [initialTab, setInitialTab] = useState<DetailTaskTab>("chat");
const [initialTab, setInitialTab] = useState<DetailTaskTab | undefined>(undefined);
return { task, initialTab, setTask, setInitialTab };
}

View File

@@ -6,6 +6,7 @@ import type { ToastType } from "./useToast";
import { removeScopedItem } from "../utils/projectStorage";
export type DetailTaskTab =
| "summary"
| "chat"
| "definition"
| "logs"
@@ -40,7 +41,7 @@ export interface ModalManager {
subtaskWorkflowId: string | null | undefined;
// Can be Task (optimistic open) or TaskDetail (full data with prompt)
detailTask: (Task | TaskDetail) | null;
detailTaskInitialTab: DetailTaskTab;
detailTaskInitialTab: DetailTaskTab | undefined;
detailTaskOrigin: DetailTaskOrigin | null;
groupModalGroupId: string | null;
settingsOpen: boolean;
@@ -173,8 +174,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
/**
* FNXC:TaskDetailTabs 2026-06-17-00:00:
* FN-6532 makes Chat the default task-detail view whenever a task opens without an explicit tab request.
*
* FNXC:TaskDetailSummaryTab 2026-06-27-00:00:
* Store omitted task-detail tabs as `undefined` so done tasks can resolve the implicit landing tab to Summary without stealing explicit Chat requests.
*/
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab>("chat");
const [detailTaskInitialTab, setDetailTaskInitialTab] = useState<DetailTaskTab | undefined>(undefined);
const [detailTaskOrigin, setDetailTaskOrigin] = useState<DetailTaskOrigin | null>(null);
const [groupModalGroupId, setGroupModalGroupId] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -296,11 +300,11 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
/**
* FNXC:TaskDetailTabs 2026-06-17-00:00:
* Open-detail callers that omit initialTab should land on Chat; explicit tab requests preserve caller intent.
* Open-detail callers that omit initialTab should land on the task-detail default; explicit tab requests preserve caller intent.
*/
const openDetailTask = useCallback((
task: Task | TaskDetail,
initialTab: DetailTaskTab = "chat",
initialTab?: DetailTaskTab,
options?: { origin?: DetailTaskOrigin },
) => {
setDetailTask(task);

View File

@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
export type DetailTaskTab = "summary" | "chat" | "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "pr" | "retries";
export type PluginToastType = "success" | "error" | "warning" | "info";

View File

@@ -7533,6 +7533,21 @@
"summary": {
"heading": "Summary"
},
"summaryTab": {
"agentWorkHeading": "Work done by agents",
"changedHeading": "What changed",
"commit": "Commit",
"completedSteps": "Completed steps",
"completionHeading": "Completion summary",
"deletions": "Removed",
"filesChanged": "Files",
"insertions": "Added",
"noAgentWork": "No completed steps or workflow results are available for this task.",
"noChangedFiles": "No changed-file list is available for this task.",
"noCompletionSummary": "No completion summary was recorded for this task.",
"retries": "Agents retried this task {{count}} time{{plural}}.",
"workflowResults": "Workflow results"
},
"tabs": {
"changes": "Changes",
"chat": "Chat",
@@ -7545,6 +7560,7 @@
"review": "Review",
"routing": "Routing",
"stats": "Stats",
"summary": "Summary",
"terminal": "Terminal",
"workflow": "Workflow"
},