FN-8286: add review artifact controls and galleries
Add configurable review-deliverable policies and surface eligible artifacts in review workflows. - Add project and PROMPT.md review-artifact modes with task eligibility enforcement. - Display video and live-demo deliverables in Task Review and Command Center galleries. - Document, localize, and test the new review-artifact controls. Files changed: .changeset/fn-8286-review-artifacts.md | 7 ++ docs/settings-reference.md | 1 + .../core/src/__tests__/review-artifacts.test.ts | 53 +++++++++++++++ .../core/src/__tests__/settings-parity.test.ts | 5 ++ packages/core/src/index.ts | 4 +- packages/core/src/settings-schema.ts | 1 + packages/core/src/types.ts | 75 ++++++++++++++++++++++ packages/core/src/types/execution-and-ui.ts | 8 +++ .../dashboard/app/components/TaskReviewTab.tsx | 28 +++++++- .../__tests__/SettingsModal.general.test.tsx | 8 +++ .../components/__tests__/TaskReviewTab.test.tsx | 27 ++++++++ .../components/command-center/CommandCenter.tsx | 5 ++ .../command-center/areas/ReviewArtifactsArea.tsx | 59 +++++++++++++++++ .../areas/__tests__/ReviewArtifactsArea.test.tsx | 39 +++++++++++ .../app/components/settings/section-keys.ts | 1 + .../settings/sections/GeneralSection.tsx | 20 ++++++ .../settings-default-descriptions.test.tsx | 1 + .../src/__tests__/agent-artifact-tools.test.ts | 19 +++++- packages/engine/src/agent-tools.ts | 26 ++++++++ packages/i18n/locales/en/app.json | 7 ++ packages/i18n/src/resources.d.ts | 7 ++ 21 files changed, 397 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8286 Fusion-Task-Lineage: 357ccbe3-510d-4ffd-ba7c-6bf79fe40a09 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8286-review-artifacts.md
Normal file
7
.changeset/fn-8286-review-artifacts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add review artifact controls and deliverable galleries.
|
||||
category: feature
|
||||
dev: Adds reviewArtifacts project policy, PROMPT.md override, task eligibility gate, and review deliverable galleries.
|
||||
@@ -592,6 +592,7 @@ Default notes:
|
||||
| `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). |
|
||||
| `sandboxProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; autoApproveBackendIds?: string[] }` | `{}` | Approval policy for sandbox host-bootstrap operations (backend install/pull/probe during `SandboxBackend.prepare()`). Default posture is strict: `approvalMode` resolves to `always`; `autoApproveBackendIds` defaults to `["native"]`. |
|
||||
| `completionDocumentationMode` | `"off" \| "changeset" \| "changelog"` | `"off"` | Controls triage prompt injection for release-note artifacts in future task specs. `"changeset"` requires `.changeset/*.md` workflow guidance; `"changelog"` requires updating an existing changelog file (without inventing a new one); `"off"` disables this automation. |
|
||||
| `reviewArtifacts` | `"off" \| "user-facing" \| "on"` | `"off"` | Controls automatic review-deliverable generation. `"user-facing"` permits only user-facing tasks (identified by their `## Frontend UX Criteria` contract, or explicitly with `**Review Artifact Task Type:** user-facing`); backend/trivial classifications remain off. `"on"` permits every task classification. A task may override the project setting with its `PROMPT.md` header `**Review Artifacts:** off|user-facing|on`; header override takes precedence over project setting, then the conservative off fallback. |
|
||||
| `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. |
|
||||
| `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). |
|
||||
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
|
||||
|
||||
53
packages/core/src/__tests__/review-artifacts.test.ts
Normal file
53
packages/core/src/__tests__/review-artifacts.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
isProjectSettingsKey,
|
||||
isReviewArtifact,
|
||||
isReviewArtifactGenerationEligible,
|
||||
LIVE_DEMO_ARTIFACT_MIME_TYPE,
|
||||
parseReviewArtifactsModeOverride,
|
||||
resolveReviewArtifactsMode,
|
||||
type Artifact,
|
||||
} from "../types.js";
|
||||
|
||||
function artifact(type: Artifact["type"], mimeType?: string): Pick<Artifact, "type" | "mimeType"> {
|
||||
return { type, mimeType };
|
||||
}
|
||||
|
||||
describe("review artifact policy", () => {
|
||||
it("defaults conservatively and registers the project setting", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.reviewArtifacts).toBe("off");
|
||||
expect(isProjectSettingsKey("reviewArtifacts")).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves the persisted PROMPT.md override before project policy", () => {
|
||||
expect(parseReviewArtifactsModeOverride("**Review Artifacts:** user-facing")).toBe("user-facing");
|
||||
expect(parseReviewArtifactsModeOverride("**Review Artifacts:** ON")).toBe("on");
|
||||
expect(resolveReviewArtifactsMode({ reviewArtifacts: "on" }, "**Review Artifacts:** off")).toBe("off");
|
||||
expect(resolveReviewArtifactsMode({ reviewArtifacts: "user-facing" })).toBe("user-facing");
|
||||
expect(resolveReviewArtifactsMode({})).toBe("off");
|
||||
});
|
||||
|
||||
it("gates automatic generation by policy and task classification", () => {
|
||||
const userFacingPrompt = "## Frontend UX Criteria\n- visible behavior";
|
||||
const backendPrompt = "**Review Artifact Task Type:** backend";
|
||||
const trivialPrompt = "**Review Artifact Task Type:** trivial";
|
||||
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "off" }, userFacingPrompt)).toBe(false);
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, userFacingPrompt)).toBe(true);
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, backendPrompt)).toBe(false);
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "user-facing" }, trivialPrompt)).toBe(false);
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "on" }, trivialPrompt)).toBe(true);
|
||||
expect(isReviewArtifactGenerationEligible({ reviewArtifacts: "off" }, "**Review Artifacts:** on\n" + backendPrompt)).toBe(true);
|
||||
});
|
||||
|
||||
it("includes videos and explicitly marked live-demo descriptors in review surfaces", () => {
|
||||
expect(isReviewArtifact(artifact("video"))).toBe(true);
|
||||
expect(isReviewArtifact(artifact("document", LIVE_DEMO_ARTIFACT_MIME_TYPE))).toBe(true);
|
||||
expect(isReviewArtifact(artifact("document", `${LIVE_DEMO_ARTIFACT_MIME_TYPE}; charset=utf-8`))).toBe(true);
|
||||
expect(isReviewArtifact(artifact("document"))).toBe(false);
|
||||
expect(isReviewArtifact(artifact("image"))).toBe(false);
|
||||
expect(isReviewArtifact(artifact("audio"))).toBe(false);
|
||||
expect(isReviewArtifact(artifact("other"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ describe("settings key parity", () => {
|
||||
expect(isProjectSettingsKey("maxConcurrent")).toBe(true);
|
||||
expect(isProjectSettingsKey("heartbeatMultiplier")).toBe(true);
|
||||
expect(isProjectSettingsKey("completionDocumentationMode")).toBe(true);
|
||||
expect(isProjectSettingsKey("reviewArtifacts")).toBe(true);
|
||||
expect(isProjectSettingsKey("remoteAccess")).toBe(false);
|
||||
expect(isProjectSettingsKey("researchSettings")).toBe(true);
|
||||
expect(isGlobalSettingsKey("researchGlobalDefaults")).toBe(true);
|
||||
@@ -282,6 +283,10 @@ describe("settings key parity", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.completionDocumentationMode).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults reviewArtifacts to off", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.reviewArtifacts).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults directMergeCommitStrategy to always-squash and keeps it project-scoped", () => {
|
||||
expect(DEFAULT_PROJECT_SETTINGS.directMergeCommitStrategy).toBe("always-squash");
|
||||
expect(isProjectSettingsKey("directMergeCommitStrategy")).toBe(true);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -494,6 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
completionDocumentationMode: "off",
|
||||
reviewArtifacts: "off",
|
||||
defaultPresetBySize: {},
|
||||
autoResolveConflicts: true,
|
||||
smartConflictResolution: true,
|
||||
|
||||
@@ -127,6 +127,7 @@ import {
|
||||
PLANNER_OVERSIGHT_LEVELS,
|
||||
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
|
||||
COMPLETION_DOCUMENTATION_MODES,
|
||||
REVIEW_ARTIFACTS_MODES,
|
||||
THEME_MODES,
|
||||
COLOR_THEMES,
|
||||
SUPPORTED_LOCALES,
|
||||
@@ -137,6 +138,7 @@ import type {
|
||||
ExecutionMode,
|
||||
PlannerOversightLevel,
|
||||
CompletionDocumentationMode,
|
||||
ReviewArtifactsMode,
|
||||
ThemeMode,
|
||||
ColorTheme,
|
||||
Locale,
|
||||
@@ -149,6 +151,7 @@ export {
|
||||
PLANNER_OVERSIGHT_LEVELS,
|
||||
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
|
||||
COMPLETION_DOCUMENTATION_MODES,
|
||||
REVIEW_ARTIFACTS_MODES,
|
||||
THEME_MODES,
|
||||
COLOR_THEMES,
|
||||
SUPPORTED_LOCALES,
|
||||
@@ -159,6 +162,7 @@ export type {
|
||||
ExecutionMode,
|
||||
PlannerOversightLevel,
|
||||
CompletionDocumentationMode,
|
||||
ReviewArtifactsMode,
|
||||
ThemeMode,
|
||||
ColorTheme,
|
||||
Locale,
|
||||
@@ -793,6 +797,75 @@ export interface ArtifactWithTask extends Artifact {
|
||||
taskColumn?: string;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-12:00:
|
||||
Remote-desktop producers can register a document descriptor through the existing
|
||||
artifact registry by assigning this MIME type. The descriptor remains a document
|
||||
in the gallery, avoiding a raw external-session link while still making the
|
||||
review deliverable visible on both review surfaces.
|
||||
*/
|
||||
export const LIVE_DEMO_ARTIFACT_MIME_TYPE = "application/vnd.runfusion.live-demo+json";
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-12:00:
|
||||
Review surfaces admit feature videos and explicitly marked live-demo descriptors.
|
||||
Ordinary documents remain excluded; the marker uses the existing persisted
|
||||
mimeType field because agent artifact registration already forwards it without
|
||||
requiring a parallel schema or metadata-registration path.
|
||||
*/
|
||||
export function isReviewArtifact(artifact: Pick<Artifact, "type" | "mimeType">): boolean {
|
||||
return artifact.type === "video"
|
||||
|| (artifact.type === "document" && artifact.mimeType?.toLowerCase().split(";", 1)[0] === LIVE_DEMO_ARTIFACT_MIME_TYPE);
|
||||
}
|
||||
|
||||
/** Reads the persisted PROMPT.md override without adding task-store persistence. */
|
||||
export function parseReviewArtifactsModeOverride(prompt: string | undefined): ReviewArtifactsMode | undefined {
|
||||
if (!prompt) return undefined;
|
||||
const match = prompt.match(/^\*\*Review Artifacts:\*\*\s*(off|user-facing|on)\s*$/im);
|
||||
return match?.[1]?.toLowerCase() as ReviewArtifactsMode | undefined;
|
||||
}
|
||||
|
||||
/** Resolves review-artifact generation policy: PROMPT header → project setting → conservative default. */
|
||||
export function resolveReviewArtifactsMode(
|
||||
settings: Pick<ProjectSettings, "reviewArtifacts">,
|
||||
prompt?: string,
|
||||
): ReviewArtifactsMode {
|
||||
return parseReviewArtifactsModeOverride(prompt) ?? settings.reviewArtifacts ?? "off";
|
||||
}
|
||||
|
||||
export type ReviewArtifactTaskClassification = "user-facing" | "backend" | "trivial";
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-13:00:
|
||||
The `user-facing` policy must be a real generation gate, not a label that
|
||||
producers reinterpret. Triage may declare a task classification in PROMPT.md;
|
||||
otherwise a task with the standard frontend UX contract is user-facing and all
|
||||
other work conservatively remains backend. This keeps trivial/backend work from
|
||||
silently producing review media while allowing `on` or the existing mode header
|
||||
to explicitly opt in.
|
||||
*/
|
||||
export function classifyReviewArtifactTask(prompt: string | undefined): ReviewArtifactTaskClassification {
|
||||
const explicit = prompt?.match(/^\*\*Review Artifact Task Type:\*\*\s*(user-facing|backend|trivial)\s*$/im)?.[1]?.toLowerCase();
|
||||
if (explicit === "user-facing" || explicit === "backend" || explicit === "trivial") return explicit;
|
||||
if (/^##\s+Frontend UX Criteria\s*$/im.test(prompt ?? "")) return "user-facing";
|
||||
return "backend";
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an automatic review-artifact producer may generate media
|
||||
* for a task. A mode marker still wins policy resolution; task classification
|
||||
* controls the `user-facing` mode only.
|
||||
*/
|
||||
export function isReviewArtifactGenerationEligible(
|
||||
settings: Pick<ProjectSettings, "reviewArtifacts">,
|
||||
prompt?: string,
|
||||
classification = classifyReviewArtifactTask(prompt),
|
||||
): boolean {
|
||||
const mode = resolveReviewArtifactsMode(settings, prompt);
|
||||
return mode === "on" || (mode === "user-facing" && classification === "user-facing");
|
||||
}
|
||||
|
||||
/**
|
||||
* Goal-citation Slice 2 success-signal surfaces where goal IDs are extracted.
|
||||
*/
|
||||
@@ -3569,6 +3642,8 @@ export interface ProjectSettings {
|
||||
* - "changelog": require updating an existing changelog file (do not invent a new one)
|
||||
* Default: "off" */
|
||||
completionDocumentationMode?: CompletionDocumentationMode;
|
||||
/** Controls whether task review deliverables are generated: off, user-facing, or on. PROMPT.md may override it. */
|
||||
reviewArtifacts?: ReviewArtifactsMode;
|
||||
/** Mapping of task sizes to preset IDs used for auto-selection during task creation. */
|
||||
defaultPresetBySize?: { S?: string; M?: string; L?: string };
|
||||
/** When true, auto-merge will automatically resolve common conflict patterns
|
||||
|
||||
@@ -44,6 +44,14 @@ export const DEFAULT_PLANNER_OVERSIGHT_LEVEL: PlannerOversightLevel = "autonomou
|
||||
export const COMPLETION_DOCUMENTATION_MODES = ["off", "changeset", "changelog"] as const;
|
||||
export type CompletionDocumentationMode = (typeof COMPLETION_DOCUMENTATION_MODES)[number];
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-12:00:
|
||||
Review-artifact production is opt-in by default: backend and trivial work stay off,
|
||||
while user-facing tasks can opt in without making every task generate media.
|
||||
*/
|
||||
export const REVIEW_ARTIFACTS_MODES = ["off", "user-facing", "on"] as const;
|
||||
export type ReviewArtifactsMode = (typeof REVIEW_ARTIFACTS_MODES)[number];
|
||||
|
||||
/** Theme mode for light/dark/system preference */
|
||||
export const THEME_MODES = ["dark", "light", "system"] as const;
|
||||
export type ThemeMode = (typeof THEME_MODES)[number];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import { getErrorMessage, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core";
|
||||
import { getErrorMessage, isReviewArtifact, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -10,8 +10,10 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||
import { resolveReviewCommentAuthor } from "../utils/githubCommentAuthor";
|
||||
import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback";
|
||||
import { ArtifactsGallery } from "./ArtifactsGallery";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { useArtifacts } from "../hooks/useArtifacts";
|
||||
|
||||
interface Props {
|
||||
task: Task | TaskDetail;
|
||||
@@ -164,6 +166,18 @@ export function TaskReviewTab({
|
||||
);
|
||||
const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false);
|
||||
const [addressingPrFeedback, setAddressingPrFeedback] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches === true);
|
||||
const { artifacts } = useArtifacts({ projectId, taskId: task.id });
|
||||
const reviewArtifacts = useMemo(() => artifacts.filter(isReviewArtifact), [artifacts]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = typeof window === "undefined" ? undefined : window.matchMedia?.("(max-width: 768px)");
|
||||
if (!query) return;
|
||||
const update = () => setIsMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
const isPrMode = review?.source === "pull-request";
|
||||
const prSummary = isPrMode ? review?.summary as TaskReviewSummary | undefined : undefined;
|
||||
@@ -461,6 +475,18 @@ export function TaskReviewTab({
|
||||
FNXC:TaskReviewTab 2026-06-27-23:38:
|
||||
PR-linked tasks need Review-tab context that is already present in the GitHub review payload: decision, reviewers, checks, blockers, and per-item author/state/GitHub links. Keep this branch gated to pull-request mode so reviewer-agent reviews retain their established direct-mode layout.
|
||||
*/}
|
||||
{reviewArtifacts.length > 0 ? (
|
||||
<section className="task-review-tab__review-artifacts" aria-label={t("taskReview.reviewArtifacts", "Review artifacts")} data-testid="task-review-artifacts">
|
||||
<h2 className="task-review-tab__pr-summary-label">{t("taskReview.reviewArtifacts", "Review artifacts")}</h2>
|
||||
<ArtifactsGallery
|
||||
artifacts={reviewArtifacts}
|
||||
projectId={projectId}
|
||||
isMobile={isMobile}
|
||||
addToast={addToast}
|
||||
onOpenTask={() => {}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
{isPrMode ? (
|
||||
<section className="task-review-tab__pr-summary" aria-label={t("taskReview.prSummaryAria", "Pull request review summary")}>
|
||||
<div className="task-review-tab__pr-summary-section">
|
||||
|
||||
@@ -1286,6 +1286,14 @@ describe("SettingsModal", () => {
|
||||
scope: "project",
|
||||
expectedKey: "completionDocumentationMode",
|
||||
},
|
||||
{
|
||||
section: "General · Project",
|
||||
label: "Review Artifacts",
|
||||
kind: "select",
|
||||
value: "user-facing",
|
||||
scope: "project",
|
||||
expectedKey: "reviewArtifacts",
|
||||
},
|
||||
{
|
||||
section: "General · Project",
|
||||
label: "Auto-cleanup old chats",
|
||||
|
||||
@@ -4,7 +4,9 @@ FN-6441 rescued this orphaned component test after standalone dashboard-app exec
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render as rtlRender, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { LIVE_DEMO_ARTIFACT_MIME_TYPE } from "@fusion/core";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { useArtifacts } from "../../hooks/useArtifacts";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
@@ -18,12 +20,17 @@ const apiMocks = vi.hoisted(() => ({
|
||||
addressPrFeedback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useArtifacts", () => ({
|
||||
useArtifacts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchTaskReview: apiMocks.fetchTaskReview,
|
||||
refreshTaskReview: apiMocks.refreshTaskReview,
|
||||
reviseTaskReviewItems: apiMocks.reviseTaskReviewItems,
|
||||
updateTask: apiMocks.updateTask,
|
||||
addressPrFeedback: apiMocks.addressPrFeedback,
|
||||
artifactMediaUrlWithToken: vi.fn((id: string) => `/api/artifacts/${id}/media`),
|
||||
}));
|
||||
|
||||
async function renderWithAct(ui: Parameters<typeof rtlRender>[0]) {
|
||||
@@ -38,6 +45,26 @@ describe("TaskReviewTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
vi.mocked(useArtifacts).mockReturnValue({ artifacts: [], loading: false, error: null, refresh: vi.fn() });
|
||||
});
|
||||
|
||||
it("renders videos and marked live-demo descriptors while hiding an empty review-artifact affordance", async () => {
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: null });
|
||||
const { rerender } = await renderWithAct(<TaskReviewTab task={makeTask({ reviewState: undefined })} addToast={vi.fn()} projectId="project-1" />);
|
||||
expect(screen.queryByTestId("task-review-artifacts")).not.toBeInTheDocument();
|
||||
|
||||
vi.mocked(useArtifacts).mockReturnValue({
|
||||
artifacts: [
|
||||
{ id: "video", type: "video", title: "Feature walkthrough", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" },
|
||||
{ id: "document", type: "document", title: "Task notes", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" },
|
||||
{ id: "live-demo", type: "document", mimeType: LIVE_DEMO_ARTIFACT_MIME_TYPE, title: "Live demo descriptor", authorId: "agent", authorType: "agent", taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" },
|
||||
], loading: false, error: null, refresh: vi.fn(),
|
||||
});
|
||||
rerender(<TaskReviewTab task={makeTask({ reviewState: undefined })} addToast={vi.fn()} projectId="project-1" />);
|
||||
expect(await screen.findByTestId("task-review-artifacts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Feature walkthrough")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Task notes")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Live demo descriptor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders direct-mode empty state when no reviewer feedback exists", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TokensArea } from "./areas/TokensArea";
|
||||
import { ToolsArea } from "./areas/ToolsArea";
|
||||
import { ActivityArea } from "./areas/ActivityArea";
|
||||
import { ProductivityArea } from "./areas/ProductivityArea";
|
||||
import { ReviewArtifactsArea } from "./areas/ReviewArtifactsArea";
|
||||
import { TeamArea } from "./areas/TeamArea";
|
||||
import { WorkflowArea } from "./areas/WorkflowArea";
|
||||
import { EcosystemArea } from "./areas/EcosystemArea";
|
||||
@@ -40,6 +41,7 @@ type SubViewId =
|
||||
| "tools"
|
||||
| "activity"
|
||||
| "productivity"
|
||||
| "review-artifacts"
|
||||
| "team"
|
||||
| "workflows"
|
||||
| "ecosystem"
|
||||
@@ -84,6 +86,7 @@ function useSubViews(nodesEnabled: boolean): SubView[] {
|
||||
{ id: "tools", label: t("commandCenter.tabs.tools", "Tools") },
|
||||
{ id: "activity", label: t("commandCenter.tabs.activity", "Activity") },
|
||||
{ id: "productivity", label: t("commandCenter.tabs.productivity", "Productivity") },
|
||||
{ id: "review-artifacts", label: t("commandCenter.tabs.reviewArtifacts", "Review artifacts") },
|
||||
{ id: "team", label: t("commandCenter.tabs.team", "Team") },
|
||||
{ id: "workflows", label: t("commandCenter.tabs.workflows", "Workflows") },
|
||||
{ id: "ecosystem", label: t("commandCenter.tabs.ecosystem", "Ecosystem") },
|
||||
@@ -619,6 +622,8 @@ export function CommandCenter({
|
||||
return <ActivityArea range={range} projectId={projectId} />;
|
||||
case "productivity":
|
||||
return <ProductivityArea range={range} projectId={projectId} />;
|
||||
case "review-artifacts":
|
||||
return <ReviewArtifactsArea projectId={projectId} addToast={addToast} />;
|
||||
case "team":
|
||||
return <TeamArea range={range} projectId={projectId} addToast={addToast} />;
|
||||
case "workflows":
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isReviewArtifact } from "@fusion/core";
|
||||
import { ArtifactsGallery } from "../../ArtifactsGallery";
|
||||
import { useArtifacts } from "../../../hooks/useArtifacts";
|
||||
import type { ToastType } from "../../../hooks/useToast";
|
||||
import { AreaShell } from "./AreaShell";
|
||||
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-12:00:
|
||||
The Command Center Review artifacts panel is the cross-task deliverable surface.
|
||||
It reuses the registry gallery for videos and MIME-marked live-demo descriptors;
|
||||
ordinary documents remain hidden, and the descriptor stays a gallery document
|
||||
rather than becoming a raw external-session link before FN-8290's renderer.
|
||||
*/
|
||||
export function ReviewArtifactsArea({ projectId, addToast = () => {} }: { projectId?: string; addToast?: (message: string, type?: ToastType) => void }) {
|
||||
const { t } = useTranslation("app");
|
||||
const { artifacts, loading, error } = useArtifacts({ projectId });
|
||||
const [isMobile, setIsMobile] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches === true);
|
||||
const reviewArtifacts = useMemo(() => artifacts.filter((artifact) => Boolean(artifact.taskId) && isReviewArtifact(artifact)), [artifacts]);
|
||||
/*
|
||||
FNXC:ReviewArtifacts 2026-07-18-19:25:
|
||||
The cross-task Command Center cannot open a task through this area, so omit
|
||||
ArtifactsGallery's task-link affordance rather than render an interactive
|
||||
control with a no-op callback. The task ID is retained above solely to limit
|
||||
this panel to deliverables registered for a task.
|
||||
*/
|
||||
const galleryArtifacts = useMemo(() => reviewArtifacts.map(({ taskId: _taskId, taskTitle: _taskTitle, ...artifact }) => artifact), [reviewArtifacts]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = typeof window === "undefined" ? undefined : window.matchMedia?.("(max-width: 768px)");
|
||||
if (!query) return;
|
||||
const update = () => setIsMobile(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AreaShell
|
||||
testId="review-artifacts"
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
isEmpty={reviewArtifacts.length === 0}
|
||||
emptyMessage={t("commandCenter.reviewArtifacts.empty", "No review artifacts are available yet.")}
|
||||
>
|
||||
<section aria-label={t("commandCenter.reviewArtifacts.title", "Review artifacts")}>
|
||||
<h3 className="cc-area-section-title">{t("commandCenter.reviewArtifacts.title", "Review artifacts")}</h3>
|
||||
<ArtifactsGallery
|
||||
artifacts={galleryArtifacts}
|
||||
projectId={projectId}
|
||||
isMobile={isMobile}
|
||||
addToast={addToast}
|
||||
onOpenTask={() => undefined}
|
||||
/>
|
||||
</section>
|
||||
</AreaShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LIVE_DEMO_ARTIFACT_MIME_TYPE } from "@fusion/core";
|
||||
import { ReviewArtifactsArea } from "../ReviewArtifactsArea";
|
||||
import { useArtifacts } from "../../../../hooks/useArtifacts";
|
||||
|
||||
vi.mock("../../../../hooks/useArtifacts", () => ({ useArtifacts: vi.fn() }));
|
||||
vi.mock("../../../ArtifactsGallery", () => ({
|
||||
ArtifactsGallery: ({ artifacts }: { artifacts: Array<{ title: string; taskId?: string }> }) => (
|
||||
<div data-testid="review-gallery" data-task-link-count={artifacts.filter((artifact) => artifact.taskId).length}>
|
||||
{artifacts.map((artifact) => artifact.title).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockUseArtifacts = vi.mocked(useArtifacts);
|
||||
const base = { authorId: "agent", authorType: "agent" as const, taskId: "FN-1", createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z" };
|
||||
|
||||
describe("ReviewArtifactsArea", () => {
|
||||
it("degrades to an empty state when no video review deliverables exist", () => {
|
||||
mockUseArtifacts.mockReturnValue({ artifacts: [{ ...base, id: "doc", type: "document", title: "Descriptor" }], loading: false, error: null, refresh: vi.fn() });
|
||||
render(<ReviewArtifactsArea projectId="project-1" />);
|
||||
expect(screen.getByTestId("cc-area-review-artifacts-empty")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("review-gallery")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces task-scoped videos and marked live-demo descriptors while filtering ordinary documents", () => {
|
||||
mockUseArtifacts.mockReturnValue({ artifacts: [
|
||||
{ ...base, id: "video", type: "video", title: "Feature video" },
|
||||
{ ...base, id: "notes", type: "document", title: "Task notes" },
|
||||
{ ...base, id: "live-demo", type: "document", mimeType: LIVE_DEMO_ARTIFACT_MIME_TYPE, title: "Live-demo descriptor" },
|
||||
], loading: false, error: null, refresh: vi.fn() });
|
||||
render(<ReviewArtifactsArea projectId="project-1" />);
|
||||
expect(screen.getByTestId("review-gallery")).toHaveTextContent("Feature video");
|
||||
expect(screen.getByTestId("review-gallery")).toHaveTextContent("Live-demo descriptor");
|
||||
expect(screen.getByTestId("review-gallery")).not.toHaveTextContent("Task notes");
|
||||
expect(screen.getByTestId("review-gallery")).toHaveAttribute("data-task-link-count", "0");
|
||||
});
|
||||
});
|
||||
@@ -64,6 +64,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
|
||||
"chatRoomRecentVerbatimMessages",
|
||||
"chatRoomSummaryMaxChars",
|
||||
"completionDocumentationMode",
|
||||
"reviewArtifacts",
|
||||
"enabledBuiltinWorkflowIds",
|
||||
"ephemeralAgentTaskCreationPolicy",
|
||||
"ephemeralAgentsEnabled",
|
||||
|
||||
@@ -273,6 +273,26 @@ export function GeneralSection({ form, setForm, projectId, addToast, prefixError
|
||||
<option value="changelog">{t("settings.general.requireChangelogUpdateExistingChangelog", "Require changelog update (existing changelog)")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
FNXC:ReviewArtifacts 2026-07-17-12:00:
|
||||
Operators choose whether future tasks may generate review deliverables.
|
||||
Per-task PROMPT.md markers remain the final override, so conservative
|
||||
project policy does not require new task-store persistence.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<label htmlFor="reviewArtifacts">{t("settings.general.reviewArtifacts", "Review Artifacts")}</label>
|
||||
<SettingsHelpTip settingKey="reviewArtifacts">{t("settings.general.reviewArtifactsHint", " Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.")}</SettingsHelpTip>
|
||||
</div>
|
||||
<select id="reviewArtifacts" value={form.reviewArtifacts || "off"} onChange={(e) => setForm((f) => ({
|
||||
...f,
|
||||
reviewArtifacts: e.target.value as "off" | "user-facing" | "on",
|
||||
}))}>
|
||||
<option value="off">{t("settings.general.off", "Off")}</option>
|
||||
<option value="user-facing">{t("settings.general.userFacing", "User-facing work")}</option>
|
||||
<option value="on">{t("settings.general.on", "On")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
{/*
|
||||
FNXC:ReportPipeline 2026-07-16-19:15:
|
||||
|
||||
@@ -254,6 +254,7 @@ const SETTING_DESCRIPTION_KEYS: Record<string, string> = {
|
||||
chatRoomRecentVerbatimMessages: "general.numberOfMostRecentChatRoomMessagesKept",
|
||||
chatRoomSummaryMaxChars: "general.hardCapOnTheSynthesizedEarlierRoomContext",
|
||||
completionDocumentationMode: "general.workflowsOrChangelogModeWhenContributorsShouldUpdate",
|
||||
reviewArtifacts: "general.reviewArtifactsHint",
|
||||
ephemeralAgentTaskCreationPolicy: "general.ephemeralAgentTaskCreationPolicyHint",
|
||||
ephemeralAgentsEnabled: "general.whenEnabledDefaultFusionSpawnsShortLived",
|
||||
githubLinkImportedIssuesToTracking: "general.whenEnabledImportedGitHubIssuesUseTheirSource",
|
||||
|
||||
@@ -20,7 +20,7 @@ const TASK_ID = "FN-6778";
|
||||
const AUTHOR_ID = "agent-007";
|
||||
const PNG_IMAGE_BYTES = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
|
||||
|
||||
type ArtifactStore = Pick<TaskStore, "registerArtifact" | "getArtifact" | "listArtifacts">;
|
||||
type ArtifactStore = Pick<TaskStore, "registerArtifact" | "getArtifact" | "listArtifacts" | "getTask" | "getSettings">;
|
||||
|
||||
type ArtifactMessageStore = Pick<MessageStore, "sendMessage">;
|
||||
|
||||
@@ -159,6 +159,23 @@ describe("artifact register tool", () => {
|
||||
expect(getText(result)).not.toContain("ERROR:");
|
||||
});
|
||||
|
||||
it("gates task review-artifact producers by user-facing task eligibility", async () => {
|
||||
const registerArtifact = vi.fn<ArtifactStore["registerArtifact"]>().mockResolvedValue(createMockArtifact({ type: "video" }));
|
||||
const getTask = vi.fn<ArtifactStore["getTask"]>();
|
||||
const getSettings = vi.fn<ArtifactStore["getSettings"]>().mockResolvedValue({ reviewArtifacts: "user-facing" });
|
||||
const store = { registerArtifact, getTask, getSettings } as unknown as TaskStore;
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
|
||||
getTask.mockResolvedValue({ prompt: "## Frontend UX Criteria\n- visible behavior" } as Awaited<ReturnType<TaskStore["getTask"]>>);
|
||||
await runTool(tool, "call-user-facing-video", { type: "video", title: "Walkthrough", taskId: TASK_ID });
|
||||
expect(registerArtifact).toHaveBeenCalledTimes(1);
|
||||
|
||||
getTask.mockResolvedValue({ prompt: "**Review Artifact Task Type:** backend" } as Awaited<ReturnType<TaskStore["getTask"]>>);
|
||||
const blocked = await runTool(tool, "call-backend-video", { type: "video", title: "Backend walkthrough", taskId: TASK_ID });
|
||||
expect(registerArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(getText(blocked)).toContain("Review artifact generation is disabled");
|
||||
});
|
||||
|
||||
it("rejects empty, non-image, and arbitrary-byte base64 payloads without registering", async () => {
|
||||
const { store, registerArtifact } = createMockStore();
|
||||
const tool = createArtifactRegisterTool(store, AUTHOR_ID);
|
||||
|
||||
@@ -1816,6 +1816,11 @@ async function registerArtifactForAgent(
|
||||
}
|
||||
const filePayload = await readArtifactFileFromPath(params, options?.baseDir);
|
||||
const data = filePayload ? filePayload.data : decodeArtifactDataBase64(params);
|
||||
await assertReviewArtifactGenerationEligible(store, {
|
||||
type: params.type,
|
||||
mimeType: filePayload?.mimeType ?? params.mimeType,
|
||||
taskId: params.taskId ?? options?.defaultTaskId,
|
||||
});
|
||||
const input: ArtifactCreateInput = {
|
||||
type: params.type,
|
||||
title: params.title,
|
||||
@@ -1849,6 +1854,27 @@ async function registerArtifactForAgent(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ReviewArtifacts 2026-07-17-13:00:
|
||||
* Automatic artifact producers share the core eligibility resolver at the
|
||||
* registration seam so `user-facing` excludes backend/trivial tasks instead of
|
||||
* relying on each future video/live-demo producer to recreate policy. Untargeted
|
||||
* artifacts remain registry-wide and are not a task review deliverable.
|
||||
*/
|
||||
async function assertReviewArtifactGenerationEligible(
|
||||
store: TaskStore,
|
||||
artifact: Pick<ArtifactCreateInput, "type" | "mimeType" | "taskId">,
|
||||
): Promise<void> {
|
||||
if (!artifact.taskId || !fusionCore.isReviewArtifact(artifact)) return;
|
||||
if (typeof store.getTask !== "function" || typeof store.getSettings !== "function") return;
|
||||
|
||||
const [task, settings] = await Promise.all([store.getTask(artifact.taskId), store.getSettings()]);
|
||||
if (!fusionCore.isReviewArtifactGenerationEligible(settings, task.prompt)) {
|
||||
throw new Error(`Review artifact generation is disabled for task ${artifact.taskId} by its reviewArtifacts policy.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ArtifactRegistry 2026-06-29-00:00:
|
||||
* Agents need a portable way to create task-scoped image artifacts without reading arbitrary local files. `dataBase64` decodes inside the tool and then uses TaskStore's existing binary persistence path so registry rows continue to store only managed artifact URIs.
|
||||
|
||||
@@ -1765,6 +1765,10 @@
|
||||
"volumeHint": "volume, not outcome",
|
||||
"volumeTitle": "Volume (proxy)"
|
||||
},
|
||||
"reviewArtifacts": {
|
||||
"empty": "No review artifacts are available yet.",
|
||||
"title": "Review artifacts"
|
||||
},
|
||||
"range": {
|
||||
"custom": "Custom range",
|
||||
"dialogLabel": "Select date range",
|
||||
@@ -1811,6 +1815,7 @@
|
||||
"nodes": "Nodes",
|
||||
"overview": "Overview",
|
||||
"productivity": "Productivity",
|
||||
"reviewArtifacts": "Review artifacts",
|
||||
"reliability": "Reliability",
|
||||
"signals": "Signals",
|
||||
"system": "System",
|
||||
@@ -5879,6 +5884,8 @@
|
||||
"chatHistory": "Chat history",
|
||||
"chatRooms": "Chat Rooms",
|
||||
"completionDocumentationAutomation": "Completion Documentation Automation",
|
||||
"reviewArtifacts": "Review Artifacts",
|
||||
"reviewArtifactsHint": "Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.",
|
||||
"controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ",
|
||||
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ",
|
||||
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.",
|
||||
|
||||
7
packages/i18n/src/resources.d.ts
vendored
7
packages/i18n/src/resources.d.ts
vendored
@@ -1719,6 +1719,10 @@ export default interface Resources {
|
||||
"volumeHint": "volume, not outcome",
|
||||
"volumeTitle": "Volume (proxy)"
|
||||
},
|
||||
"reviewArtifacts": {
|
||||
"empty": "No review artifacts are available yet.",
|
||||
"title": "Review artifacts"
|
||||
},
|
||||
"range": {
|
||||
"custom": "Custom range",
|
||||
"dialogLabel": "Select date range",
|
||||
@@ -1765,6 +1769,7 @@ export default interface Resources {
|
||||
"nodes": "Nodes",
|
||||
"overview": "Overview",
|
||||
"productivity": "Productivity",
|
||||
"reviewArtifacts": "Review artifacts",
|
||||
"reliability": "Reliability",
|
||||
"signals": "Signals",
|
||||
"system": "System",
|
||||
@@ -5840,6 +5845,8 @@ export default interface Resources {
|
||||
"chatHistory": "Chat history",
|
||||
"chatRooms": "Chat Rooms",
|
||||
"completionDocumentationAutomation": "Completion Documentation Automation",
|
||||
"reviewArtifacts": "Review Artifacts",
|
||||
"reviewArtifactsHint": "Controls whether eligible future tasks generate review deliverables. User-facing limits generation to user-facing work; on enables it for all eligible tasks. Individual PROMPT.md headers can override this. Default: off.",
|
||||
"controlsHowFutureTaskSpecsHandleReleaseNote": " Controls how future task specs handle release-note artifacts at completion. Use changeset mode for repositories that follow ",
|
||||
"controlsWhetherNewlyCreatedTasksHaveGitHubIssue": " Controls whether newly created tasks have GitHub issue tracking enabled by default. Individual tasks can still override this from the task detail modal. ",
|
||||
"defaultRepoUsedWhenCreatingGitHubIssuesFor": "Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.",
|
||||
|
||||
Reference in New Issue
Block a user