Files
fusion/packages/dashboard/app/components/ReportModal.tsx
gsxdsm a11f789b0c FN-8327: fall back to issues when Discussions are disabled
Reports can select a Discussion category and reliably file to Issues when Discussions are unavailable.

- Add Discussion-disabled detection across GitHub GraphQL operations.
- Let report submission select a category and disclose the actual filing destination.
- Preserve duplicate handling and document the fallback behavior.

Files changed:
 .changeset/fn-8327-discussion-fallback.md          |  7 +++
 docs/dashboard-guide.md                            |  4 +-
 packages/dashboard/app/api/report.ts               | 22 +++++--
 packages/dashboard/app/components/ReportModal.tsx  |  7 ++-
 .../app/components/__tests__/ReportModal.test.tsx  | 13 ++++
 .../src/__tests__/github-discussions.test.ts       | 29 +++++++--
 .../src/__tests__/report-pipeline.test.ts          | 63 ++++++++++++++-----
 .../dashboard/src/__tests__/report-routes.test.ts  | 15 +++--
 packages/dashboard/src/github.ts                   | 71 ++++++++++++++++------
 packages/dashboard/src/report-pipeline.ts          | 54 +++++++++++-----
 .../dashboard/src/routes/register-report-routes.ts | 12 +++-
 11 files changed, 227 insertions(+), 70 deletions(-)

Fusion-Task-Id: FN-8327

Fusion-Task-Lineage: 510d3d4f-ffc9-4167-bee8-7043e0ddbd9c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-19 20:42:05 -07:00

134 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ReportActionType, ReportTarget } from "@fusion/core";
import { reportAttachment, reportDraft, reportFile, reportHelp } from "../api";
import { captureScreenshot as captureScreen, getRecentActivity, recordActivity } from "../utils/report-capture";
import "./ReportModal.css";
const prompts: Record<ReportActionType, string> = { bug: "What went wrong?", feedback: "What would you like to share?", idea: "What would you like Fusion to do?", help: "What would you like help with?" };
type ModalResult = { kind: string; destination?: "issue" | "discussion"; report?: { userPrompt: string; sourcePrompt?: string; summary?: string; body?: string; context?: Record<string, unknown>; sessionToken?: string }; issue?: { number: number; url: string; title: string; discussionId?: string; roadmap?: true }; url?: string; answer?: { summary?: string; content?: string }; message?: string; screenshotNotAttached?: boolean };
/**
* FNXC:ReportPipeline 2026-07-16-12:00:
* The four actions start with a guided user prompt, not a raw issue form. The
* final filed or endorsed link remains visible after the selected mode acts.
*/
export function ReportModal({ actionType, onClose, contextRefs }: { actionType: ReportActionType; onClose: () => void; contextRefs?: { taskId?: string; agentId?: string } }) {
const { t } = useTranslation("app");
const [prompt, setPrompt] = useState("");
const [result, setResult] = useState<ModalResult>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
const [screenshotEnabled, setScreenshotEnabled] = useState(false);
// FNXC:ReportPipeline 2026-07-16-21:00: A reporter can override the inherited target for this filing only.
const [targetType, setTargetType] = useState<ReportTarget | undefined>();
const [screenshotArtifactId, setScreenshotArtifactId] = useState<string>();
const [retentionConfirmed, setRetentionConfirmed] = useState(false);
const captureGeneration = useRef(0);
const captureScreenshot = async (generation: number) => {
setBusy(true);
setError(undefined);
try {
const captured = await captureScreen();
if (!captured) throw new Error("Screen capture was unavailable or denied.");
const { artifactId } = await reportAttachment(captured);
if (captureGeneration.current !== generation) return;
setScreenshotArtifactId(artifactId);
} catch (captureError) {
if (captureGeneration.current !== generation) return;
setScreenshotEnabled(false);
setError(captureError instanceof Error ? captureError.message : "We could not capture the current screen.");
} finally {
if (captureGeneration.current === generation) setBusy(false);
}
};
const submit = async () => {
if (!prompt.trim()) return;
if (screenshotEnabled && (!screenshotArtifactId || !retentionConfirmed)) {
setError("Capture and confirm local screenshot retention before continuing, or turn attachment off.");
return;
}
setBusy(true);
setError(undefined);
try {
recordActivity("report");
if (actionType === "help") {
const help = await reportHelp(prompt);
if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; }
}
setResult(await reportDraft({ actionType, targetType, userPrompt: prompt, contextRefs, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not prepare your report. Check your connection and try again.");
} finally { setBusy(false); }
};
const file = async (endorseIssueNumber?: number, endorseDiscussionId?: string, endorseRoadmapIssueNumber?: number) => {
if (!result?.report) return;
setBusy(true);
setError(undefined);
try {
recordActivity("report");
setResult(await reportFile({ actionType, targetType, report: result.report, endorseIssueNumber, endorseDiscussionId, endorseRoadmapIssueNumber, activityTrace: getRecentActivity(), screenshotArtifactId: screenshotEnabled && retentionConfirmed ? screenshotArtifactId : undefined }));
} catch {
setError("We could not send your report. Your draft is still here; try again.");
} finally { setBusy(false); }
};
return <div className="report-modal-backdrop" role="presentation"><section className="card report-modal" role="dialog" aria-modal="true" aria-label={`${actionType} report`}>
<button className="btn-icon report-modal__close" type="button" aria-label="Close report" onClick={onClose}>×</button>
{error && <p className="report-modal__error" role="alert">{error}</p>}
{!result && <><h2>{actionType[0].toUpperCase() + actionType.slice(1)}</h2><label htmlFor="report-prompt">{prompts[actionType]}</label><textarea id="report-prompt" className="input" value={prompt} onChange={(event) => setPrompt(event.target.value)} maxLength={4000} />
{/* FNXC:ReportPipeline 2026-07-19-10:00: Screenshot storage is opt-in and
requires retention confirmation before its artifact reference is sent. A
capture that finishes after opt-out is discarded rather than restoring it. */}
<label className="report-modal__screenshot-option"><input type="checkbox" checked={screenshotEnabled} onChange={(event) => { const enabled = event.target.checked; const generation = ++captureGeneration.current; setScreenshotEnabled(enabled); setScreenshotArtifactId(undefined); setRetentionConfirmed(false); if (enabled) void captureScreenshot(generation); else setBusy(false); }} /> Store a screenshot locally</label>
{screenshotEnabled && <div className="report-modal__screenshot-preview">
{screenshotArtifactId ? <label className="report-modal__screenshot-option"><input type="checkbox" checked={retentionConfirmed} onChange={(event) => setRetentionConfirmed(event.target.checked)} /> I confirm Fusion may retain this screenshot locally for this report.</label> : <p>Capturing and storing locally…</p>}
</div>}
<label htmlFor="report-target">{t("report.targetLabel", "Filing target")}</label><select id="report-target" className="input" value={targetType ?? ""} onChange={(event) => setTargetType((event.target.value || undefined) as ReportTarget | undefined)}><option value="">{t("report.targetInherit", "Use configured action target")}</option><option value="issue">{t("report.targetIssue", "GitHub Issue")}</option><option value="discussion">{t("report.targetDiscussion", "GitHub Discussion")}</option></select>
<details className="report-modal__activity-trace"><summary>Activity trace to send</summary><ul>{getRecentActivity().map((entry, index) => <li key={`${entry}-${index}`}>{entry}</li>)}</ul></details>
<button className="btn btn-primary" type="button" disabled={!prompt.trim() || busy} onClick={() => void submit()}>{error ? "Retry" : "Continue"}</button></>}
{result?.kind === "draft-ready" && result.report && <><h2>Review your report</h2><label htmlFor="report-review-prompt">Report summary</label><textarea id="report-review-prompt" className="input" value={result.report.userPrompt} onChange={(event) => {
const userPrompt = event.target.value;
// FNXC:ReportPipeline 2026-07-16-18:45:
// Keep the original derivation marker when the guided prompt changes.
// The server then rebuilds every derived section with newly gathered
// context, rather than discarding the report's reproduction/environment
// sections while the user is editing a draft.
setResult({ ...result, report: { ...result.report!, userPrompt } });
}} /><label htmlFor="report-review-body">Structured report</label><textarea id="report-review-body" className="input" value={result.report.body ?? ""} onChange={(event) => setResult({ ...result, report: { ...result.report!, body: event.target.value } })} /><button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file()}>File report</button></>}
{result?.kind === "duplicate-found" && result.issue && result.report && <>
{/*
FNXC:ReportPipeline 2026-07-16-21:30:
Draft-review applies to duplicate endorsements too. Show the scrubbed
structured data point and require an explicit confirmation after edits
instead of posting a dedupe match immediately.
*/}
{/* FNXC:ReportPipeline 2026-07-18-20:45: A public-roadmap duplicate is endorsed through the same reviewed data-point UI as an issue, so reporters strengthen the tracked item rather than opening a parallel thread. */}
<h2>{result.issue.roadmap ? t("report.roadmapDuplicate.title", "Already on the roadmap — add your data point?") : "Review data point for a similar open issue"}</h2>
<a href={result.issue.url} target="_blank" rel="noreferrer">{result.issue.title}</a>
<label htmlFor="report-duplicate-prompt">Report summary</label>
<textarea id="report-duplicate-prompt" className="input" value={result.report.userPrompt} onChange={(event) => {
const userPrompt = event.target.value;
setResult({ ...result, report: { ...result.report!, userPrompt } });
}} />
<label htmlFor="report-duplicate-body">Structured data point</label>
<textarea id="report-duplicate-body" className="input" value={result.report.body ?? ""} onChange={(event) => setResult({ ...result, report: { ...result.report!, body: event.target.value } })} />
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void file(result.issue!.discussionId ? undefined : result.issue!.roadmap ? undefined : result.issue!.number, result.issue!.discussionId, result.issue!.roadmap ? result.issue!.number : undefined)}>Confirm and add data point</button>
</>}
{(result?.kind === "filed" || result?.kind === "endorsed") && <>
{/* FNXC:ReportPipeline 2026-07-18-12:30: When disabled Discussions fall back to
Issues, state the actual filed destination rather than implying the report became a Discussion. */}
<h2>{result.kind === "filed" && result.destination === "issue" ? "Report filed as an Issue" : "Report sent"}</h2><a href={result.url} target="_blank" rel="noreferrer">View on GitHub</a>{result.report?.body && <><label htmlFor="filed-report">Final report</label><textarea id="filed-report" className="input" value={result.report.body} readOnly /></>}</>}
{result?.kind === "help" && <><h2>Suggested help</h2><p>{result.answer?.summary ?? result.answer?.content}</p></>}
{result?.kind === "unavailable" && <>
<p role="alert">{result.message}</p>
<button className="btn btn-secondary" type="button" onClick={() => { setResult(undefined); setError(undefined); }}>Return to prompt</button>
</>}
</section></div>;
}