From 88b0db0f4db86e74911fc062cdcbd5f91f8b6983 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 18 Jul 2026 12:41:42 -0700 Subject: [PATCH] FN-8309: add report screenshot and activity capture Add opt-in local screenshot attachments and scrubbed activity traces to reports. - Capture, preview, confirm, and locally retain report screenshots without GitHub egress. - Include bounded activity traces in draft and filing context with server-side validation. - Add report pipeline coverage, documentation, and release metadata. Files changed: .changeset/fn-8309-report-capture.md | 7 ++ docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 10 ++ packages/dashboard/app/api/report.ts | 16 ++- packages/dashboard/app/components/ReportModal.css | 3 + packages/dashboard/app/components/ReportModal.tsx | 45 ++++++- .../app/utils/__tests__/activity-trace.test.ts | 19 +++ packages/dashboard/app/utils/activity-trace.ts | 37 ++++++ packages/dashboard/app/utils/capture-screenshot.ts | 22 ++++ packages/dashboard/package.json | 5 +- .../src/__tests__/report-pipeline.test.ts | 13 +++ packages/dashboard/src/report-pipeline.ts | 29 ++++- packages/dashboard/src/routes.ts | 2 +- .../dashboard/src/routes/register-report-routes.ts | 85 +++++++++++++- packages/dashboard/src/routes/types.ts | 4 +- pnpm-lock.yaml | 129 ++++++++++----------- 16 files changed, 341 insertions(+), 87 deletions(-) Fusion-Task-Id: FN-8309 Fusion-Task-Lineage: db9c14a6-1721-4e81-8891-cfd301473cd0 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-8309-report-capture.md | 7 + docs/dashboard-guide.md | 2 + packages/dashboard/app/App.tsx | 10 ++ packages/dashboard/app/api/report.ts | 16 ++- .../dashboard/app/components/ReportModal.css | 3 + .../dashboard/app/components/ReportModal.tsx | 49 ++++++- .../utils/__tests__/activity-trace.test.ts | 19 +++ .../dashboard/app/utils/activity-trace.ts | 37 +++++ .../dashboard/app/utils/capture-screenshot.ts | 22 +++ packages/dashboard/package.json | 5 +- .../src/__tests__/report-pipeline.test.ts | 13 ++ packages/dashboard/src/report-pipeline.ts | 29 +++- packages/dashboard/src/routes.ts | 2 +- .../src/routes/register-report-routes.ts | 85 +++++++++++- packages/dashboard/src/routes/types.ts | 4 +- pnpm-lock.yaml | 129 ++++++++---------- 16 files changed, 343 insertions(+), 89 deletions(-) create mode 100644 .changeset/fn-8309-report-capture.md create mode 100644 packages/dashboard/app/utils/__tests__/activity-trace.test.ts create mode 100644 packages/dashboard/app/utils/activity-trace.ts create mode 100644 packages/dashboard/app/utils/capture-screenshot.ts diff --git a/.changeset/fn-8309-report-capture.md b/.changeset/fn-8309-report-capture.md new file mode 100644 index 0000000000..e471a26228 --- /dev/null +++ b/.changeset/fn-8309-report-capture.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add scrubbed activity context and optional local report screenshots. +category: feature +dev: Screenshots require preview confirmation and never egress to GitHub. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index dae152bc48..1b1e67ccec 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -2090,3 +2090,5 @@ The Header **Report** menu is available on desktop and mobile and offers **Bug** Fusion gathers available task/agent context, structures the prompt into a report, scrubs secrets, local paths, project names, home-directory identities, email addresses, and likely personal names, then checks **open** GitHub issues or Discussions for duplicates. Scrubbing is mandatory for every route and is repeated on the server when a reviewed draft is edited before filing. A strong duplicate receives a visible 👍 reaction and one scrubbed data-point comment instead of a new issue or Discussion. Bug and Idea reports use issues; Feedback and unresolved Help reports use repository Discussions. If preparation or filing cannot reach GitHub, Fusion preserves the draft and shows a retryable error. In **Settings → General**, choose **Review draft before filing** (the default) or **File automatically**. Both paths show the resulting issue, Discussion, or endorsement link. Help checks Fusion's local knowledge index on every server report path first and only escalates when it cannot find an answer. + +Reports include a default-on activity trace of up to 20 recent views, actions, and client errors (at most 4,000 characters). It is ordinary report text and receives the same server-side scrub as every other report field. You can optionally attach a screenshot of the current Fusion view. Fusion captures only the dashboard DOM, shows the exact preview, and requires confirmation before it uploads the PNG/JPEG (up to 2MB) as a local image artifact. In automatic mode this confirmation and local artifact upload happen before filing begins. The server validates the resulting artifact reference (format, image type, MIME type, and report-upload provenance) before it can appear as a text-only local-retention note in a report. Screenshot pixels are never sent to GitHub. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index c5fbc89638..930eeef485 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -48,6 +48,7 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; +import { recordActivity } from "./utils/activity-trace"; import { closeViewShortcut, retainViewNavRevert } from "./utils/dashboardShortcutToggles"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useGithubSetupWarningDelay } from "./hooks/useGithubSetupWarningDelay"; @@ -421,6 +422,15 @@ function AppInner() { setThemeMode, }); + useEffect(() => { + recordActivity({ kind: "view", label: String(viewMode) }); + }, [viewMode]); + useEffect(() => { + const recordError = (event: ErrorEvent) => recordActivity({ kind: "client-error", label: event.message }); + window.addEventListener("error", recordError); + return () => window.removeEventListener("error", recordError); + }, []); + const { views: rawPluginDashboardViews } = usePluginDashboardViews(currentProject?.id); const graphPluginTaskView = useMemo(() => { // Prefer API response for the graph view (supports dynamic plugin discovery) diff --git a/packages/dashboard/app/api/report.ts b/packages/dashboard/app/api/report.ts index 487a1df0e0..ae9dfe3d78 100644 --- a/packages/dashboard/app/api/report.ts +++ b/packages/dashboard/app/api/report.ts @@ -6,6 +6,18 @@ async function post(path: string, body: unknown) { return response.json(); } -export function reportDraft(input: { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string } }) { return post("/api/report/draft", input); } -export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string }) { return post("/api/report/file", input); } +export interface ReportActivityTraceEntry { ts: string; kind: string; label: string; } +export interface ReportContextInput { actionType: ReportActionType; userPrompt: string; contextRefs?: { taskId?: string; agentId?: string }; activityTrace?: ReportActivityTraceEntry[]; screenshotArtifactId?: string; } + +export async function uploadReportScreenshot(blob: Blob, contextRefs?: { taskId?: string; agentId?: string }): Promise<{ artifactId: string; uri?: string }> { + const form = new FormData(); + form.append("screenshot", blob, "report-screenshot.jpg"); + if (contextRefs) form.append("contextRefs", JSON.stringify(contextRefs)); + const response = await fetch("/api/report/attachment", { method: "POST", body: form }); + if (!response.ok) throw new Error((await response.json().catch(() => ({ error: response.statusText }))).error ?? response.statusText); + return response.json(); +} + +export function reportDraft(input: ReportContextInput) { return post("/api/report/draft", input); } +export function reportFile(input: { actionType: ReportActionType; report: unknown; endorseIssueNumber?: number; endorseDiscussionId?: string; activityTrace?: ReportActivityTraceEntry[]; screenshotArtifactId?: string }) { return post("/api/report/file", input); } export function reportHelp(question: string) { return post("/api/report/help", { question }); } diff --git a/packages/dashboard/app/components/ReportModal.css b/packages/dashboard/app/components/ReportModal.css index e93c0bfbf6..9fbca59af6 100644 --- a/packages/dashboard/app/components/ReportModal.css +++ b/packages/dashboard/app/components/ReportModal.css @@ -3,4 +3,7 @@ .report-modal textarea { min-block-size: var(--space-32); resize: vertical; } .report-modal__close { position: absolute; inset-block-start: var(--space-2); inset-inline-end: var(--space-2); } .report-modal__error { color: var(--color-error); } +.report-modal__screenshot-option { display: flex; gap: var(--space-2); align-items: center; } +.report-modal__screenshot-preview { display: grid; gap: var(--space-2); padding: var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-raised) 85%, transparent); } +.report-modal__screenshot-preview img { max-inline-size: 100%; max-block-size: var(--space-96); object-fit: contain; } @media (max-width: 768px) { .report-modal { inline-size: 100%; padding: var(--space-4); } } diff --git a/packages/dashboard/app/components/ReportModal.tsx b/packages/dashboard/app/components/ReportModal.tsx index fd960efa05..9b93c92412 100644 --- a/packages/dashboard/app/components/ReportModal.tsx +++ b/packages/dashboard/app/components/ReportModal.tsx @@ -1,6 +1,8 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { ReportActionType } from "@fusion/core"; -import { reportDraft, reportFile, reportHelp } from "../api"; +import { reportDraft, reportFile, reportHelp, uploadReportScreenshot } from "../api"; +import { captureAppScreenshot, type CapturedScreenshot } from "../utils/capture-screenshot"; +import { recordActivity, snapshotActivityTrace } from "../utils/activity-trace"; import "./ReportModal.css"; const prompts: Record = { 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?" }; @@ -16,16 +18,43 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: const [result, setResult] = useState(); const [busy, setBusy] = useState(false); const [error, setError] = useState(); - const submit = async () => { - if (!prompt.trim()) return; + const [screenshotEnabled, setScreenshotEnabled] = useState(false); + const [capturedScreenshot, setCapturedScreenshot] = useState(); + const [screenshotConfirmed, setScreenshotConfirmed] = useState(false); + const [screenshotArtifactId, setScreenshotArtifactId] = useState(); + useEffect(() => () => { if (capturedScreenshot) URL.revokeObjectURL(capturedScreenshot.previewUrl); }, [capturedScreenshot]); + const captureScreenshot = async () => { setBusy(true); setError(undefined); try { + const captured = await captureAppScreenshot(); + setCapturedScreenshot(captured); + setScreenshotConfirmed(false); + } catch (captureError) { + setScreenshotEnabled(false); + setError(captureError instanceof Error ? captureError.message : "We could not capture the current screen."); + } finally { setBusy(false); } + }; + const submit = async () => { + if (!prompt.trim()) return; + if (screenshotEnabled && (!capturedScreenshot || !screenshotConfirmed)) { + setError("Preview and confirm the screenshot before continuing."); + return; + } + setBusy(true); + setError(undefined); + try { + recordActivity({ kind: "report", label: `${actionType} report submitted` }); if (actionType === "help") { const help = await reportHelp(prompt); if (help.answered) { setResult({ kind: "help", answer: help.answer }); return; } } - setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs })); + const attachment = screenshotEnabled && capturedScreenshot && !screenshotArtifactId + ? await uploadReportScreenshot(capturedScreenshot.blob, contextRefs) + : undefined; + const artifactId = attachment?.artifactId ?? screenshotArtifactId; + if (artifactId) setScreenshotArtifactId(artifactId); + setResult(await reportDraft({ actionType, userPrompt: prompt, contextRefs, activityTrace: snapshotActivityTrace(), screenshotArtifactId: artifactId })); } catch { setError("We could not prepare your report. Check your connection and try again."); } finally { setBusy(false); } @@ -35,7 +64,7 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: setBusy(true); setError(undefined); try { - setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId })); + setResult(await reportFile({ actionType, report: result.report, endorseIssueNumber, endorseDiscussionId, activityTrace: snapshotActivityTrace(), screenshotArtifactId })); } catch { setError("We could not send your report. Your draft is still here; try again."); } finally { setBusy(false); } @@ -43,7 +72,13 @@ export function ReportModal({ actionType, onClose, contextRefs }: { actionType: return
{error &&

{error}

} - {!result && <>

{actionType[0].toUpperCase() + actionType.slice(1)}