feat(FN-2924): merge fusion/fn-2924

- Add task provenance display to TaskDetailModal, surfacing AI-generated merge commit summaries so users understand when and why tasks landed
- New `ai-summarize` module in core with structured commit summarization logic and tests
- Extend CLI `fn task show` with provenance output
- Refactor merger to produce cleaner merge commit messages with task context
- Update SettingsModal to expose AI summarization toggle (hidden behind feature flag)
- Adjust QuickChatFAB styling for theme compatibility
- Update scheduler and node-routing policy tests to match new behavior
- Add changeset for `@runfusion/fusion` minor release

Commits merged:
- feat(FN-2924): display task provenance in dashboard and cli
- feat(FN-2971): merge fusion/fn-2971
- feat(FN-2947): merge fusion/fn-2947
- feat(FN-2970): merge fusion/fn-2970
- feat(FN-2956): merge fusion/fn-2956

Files changed:
.changeset/add-ai-merge-commit-summary.md          |   5 +
 docs/cli-reference.md                              |   3 +-
 docs/settings-reference.md                         |   3 +
 packages/cli/src/commands/__tests__/task.test.ts   |  44 ++++-
 packages/cli/src/commands/task.ts                  |  55 +++++++
 packages/core/src/__tests__/ai-summarize.test.ts   |  64 ++++++++
 packages/core/src/ai-summarize.ts                  | 114 +++++++++++++
 packages/core/src/index.ts                         |   3 +
 packages/core/src/settings-schema.ts               |   1 +
 packages/core/src/types.ts                         |   4 +
 packages/dashboard/app/components/QuickChatFAB.css |  17 +-
 packages/dashboard/app/components/QuickChatFAB.tsx |   2 +-
 .../dashboard/app/components/SettingsModal.css     |  11 +-
 .../dashboard/app/components/SettingsModal.tsx     |  22 ++-
 .../dashboard/app/components/TaskDetailModal.css   |  40 +++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  82 +++++++++-
 .../__tests__/SettingsModalNodeRouting.test.tsx    |  12 +-
 .../components/__tests__/TaskDetailModal.test.tsx  |  81 ++++++++-
 packages/engine/src/__tests__/merger.test.ts       |  75 +++++++++
 .../src/__tests__/node-routing-policy.test.ts      |  25 ++-
 .../src/__tests__/scheduler-node-routing.test.ts   |  18 +-
 packages/engine/src/merger.ts                      | 181 +++++++++------------
 packages/engine/src/scheduler.ts                   |   8 +-
 23 files changed, 723 insertions(+), 147 deletions(-)

Fusion-Task-Id: FN-2924
This commit is contained in:
Fusion
2026-04-29 18:00:15 -07:00
committed by gsxdsm
parent ab80d5f022
commit 2fb18eac1f
6 changed files with 294 additions and 11 deletions

View File

@@ -115,6 +115,46 @@
margin-bottom: var(--space-lg);
}
.detail-provenance {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-sm);
margin-bottom: var(--space-sm);
color: var(--text-muted);
}
.detail-provenance svg {
width: 1em;
height: 1em;
}
.detail-provenance-link {
padding: 0;
border: 0;
background: transparent;
color: var(--todo);
cursor: pointer;
text-decoration: none;
font: inherit;
}
.detail-provenance-link:hover {
text-decoration: underline;
}
.detail-provenance-link:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
border-radius: var(--radius-sm);
}
@media (max-width: 768px) {
.detail-provenance {
align-items: flex-start;
}
}
.detail-priority-chip {
display: inline-flex;
align-items: center;

View File

@@ -1,6 +1,6 @@
import "./TaskDetailModal.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pencil, Bot, X, ChevronDown, ChevronRight } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import ReactMarkdown from "react-markdown";
@@ -222,6 +222,64 @@ function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "sta
return executionMode === "fast" ? "fast" : "standard";
}
interface ProvenanceDisplay {
label: string;
parentTaskId?: string;
contextInfo?: string;
}
function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | undefined {
const issueUrl = metadata?.issueUrl;
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function getProvenanceLabel(task: Task | TaskDetail): ProvenanceDisplay | null {
switch (task.sourceType) {
case "dashboard_ui":
return { label: "Dashboard" };
case "quick_chat":
return { label: "Quick Chat" };
case "chat_session":
return { label: "Chat Session" };
case "agent_heartbeat":
return {
label: task.sourceAgentId ? `Agent (${task.sourceAgentId})` : "Agent",
};
case "automation":
return { label: "Automation" };
case "cron":
return { label: "Scheduled Task" };
case "workflow_step":
return { label: "Workflow Step" };
case "github_import": {
const issueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
return {
label: "GitHub Import",
contextInfo: issueUrl,
};
}
case "task_refine":
return {
label: "Refinement",
parentTaskId: task.sourceParentTaskId,
};
case "task_duplicate":
return {
label: "Duplicate",
parentTaskId: task.sourceParentTaskId,
};
case "cli":
return { label: "CLI" };
case "api":
return { label: "API" };
case "recovery":
return { label: "Recovery" };
case "unknown":
default:
return null;
}
}
const DESCRIPTION_TRUNCATE_LENGTH = 200;
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
@@ -301,6 +359,7 @@ export function TaskDetailModal({
(task.stuckKillCount ?? 0) > 0 ||
(task.recoveryRetryCount ?? 0) > 0 ||
Boolean(task.nextRecoveryAt);
const provenanceDisplay = getProvenanceLabel(workingTask);
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
useEffect(() => {
@@ -1488,6 +1547,27 @@ export function TaskDetailModal({
<span className={`detail-priority-chip detail-priority-chip--${normalizeTaskPriorityValue(task.priority)}`}>
Priority: {normalizeTaskPriorityValue(task.priority)}
</span>
{provenanceDisplay && (
<div className="detail-provenance">
<GitBranch aria-hidden="true" />
<span>
Created via {provenanceDisplay.label}
{provenanceDisplay.parentTaskId && (
<>
{" "}of{" "}
<button
type="button"
className="detail-provenance-link"
onClick={() => handleDepClick(provenanceDisplay.parentTaskId!)}
>
{provenanceDisplay.parentTaskId}
</button>
</>
)}
{provenanceDisplay.contextInfo ? ` (${provenanceDisplay.contextInfo})` : ""}
</span>
</div>
)}
</div>
</>
)}

View File

@@ -13,7 +13,7 @@ vi.mock("../../api", () => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
fetchTaskDetail: vi.fn(),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
approvePlan: vi.fn().mockResolvedValue({}),
@@ -57,6 +57,7 @@ vi.mock("lucide-react", () => ({
CircleDot: () => null,
XCircle: () => null,
GitMerge: () => null,
GitBranch: () => null,
}));
vi.mock("../../hooks/useAgentLogs", () => ({
@@ -136,6 +137,84 @@ describe("TaskDetailModal", () => {
localStorage.removeItem("fn.authToken");
});
describe("provenance display", () => {
it.each([
["dashboard_ui", undefined, "Created via Dashboard"],
["agent_heartbeat", "agent-123", "Created via Agent (agent-123)"],
] as const)("renders provenance text for %s", (sourceType, sourceAgentId, expectedText) => {
render(
<TaskDetailModal
task={makeTask({ sourceType, sourceAgentId })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(expectedText)).toBeInTheDocument();
});
it("renders parent task link for refinement provenance", async () => {
render(
<TaskDetailModal
task={makeTask({ sourceType: "task_refine", sourceParentTaskId: "FN-001" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText(/Created via Refinement/)).toBeInTheDocument();
const link = screen.getByRole("button", { name: "FN-001" });
expect(link).toBeInTheDocument();
await userEvent.click(link);
await waitFor(() => {
expect(noopOpenDetail).toHaveBeenCalled();
});
});
it("renders issue URL for github import provenance", () => {
render(
<TaskDetailModal
task={makeTask({
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
})}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.getByText("Created via GitHub Import (https://github.com/owner/repo/issues/42)")).toBeInTheDocument();
});
it.each(["unknown", undefined] as const)("omits provenance for %s source", (sourceType) => {
render(
<TaskDetailModal
task={makeTask({ sourceType })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(screen.queryByText(/Created via/)).not.toBeInTheDocument();
});
});
it("styles detail-body scrollbar rules", () => {
const css = loadAllAppCss();