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:
gsxdsm
2026-07-18 13:09:38 -07:00
parent 551074537f
commit 13f936eb7b
21 changed files with 397 additions and 4 deletions

View 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.

View File

@@ -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. |

View 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);
});
});

View File

@@ -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

View File

@@ -494,6 +494,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
modelPresets: [],
autoSelectModelPreset: false,
completionDocumentationMode: "off",
reviewArtifacts: "off",
defaultPresetBySize: {},
autoResolveConflicts: true,
smartConflictResolution: true,

View File

@@ -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

View File

@@ -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];

View File

@@ -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">

View File

@@ -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",

View File

@@ -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 () => {

View File

@@ -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":

View File

@@ -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>
);
}

View File

@@ -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");
});
});

View File

@@ -64,6 +64,7 @@ const PROJECT_SECTION_KEYS: Record<string, readonly string[]> = {
"chatRoomRecentVerbatimMessages",
"chatRoomSummaryMaxChars",
"completionDocumentationMode",
"reviewArtifacts",
"enabledBuiltinWorkflowIds",
"ephemeralAgentTaskCreationPolicy",
"ephemeralAgentsEnabled",

View File

@@ -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:

View File

@@ -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",

View File

@@ -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);

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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.",