Files
fusion/packages/dashboard/app/components/MailboxStructuralItem.tsx
gsxdsm 98ad663011 FN-8872: add structural mail reports from chat
Add a report-oriented mail workflow that turns completed chat responses into structured, reviewable messages.

- Render structural report items with dedicated visual treatment and tests across mail surfaces.
- Add report-mode composer fields and route Send as report handoffs from completed chat responses.
- Document the mail/chat workflow and publish a Fusion changeset.

Files changed:
 .changeset/fn-8872-structural-mail-ui.md           |   7 +
 docs/dashboard-guide.md                            |   3 +
 packages/dashboard/app/App.tsx                     |   9 +
 packages/dashboard/app/components/ChatView.tsx     |  36 ++--
 packages/dashboard/app/components/MailboxModal.tsx |  28 ++-
 .../app/components/MailboxStructuralItem.css       |  73 +++++++
 .../app/components/MailboxStructuralItem.tsx       | 100 ++++++++++
 packages/dashboard/app/components/MailboxView.tsx  |  50 ++++-
 .../dashboard/app/components/MessageComposer.css   |  18 ++
 .../dashboard/app/components/MessageComposer.tsx   |  57 +++++-
 .../__tests__/ChatMailReportRouting.test.tsx       | 144 ++++++++++++++
 .../__tests__/ChatView.send-as-report.test.tsx     |  96 +++++++++
 .../__tests__/MailboxStructuralItem.test.tsx       |  90 +++++++++
 .../MailboxStructuralMail.surfaces.test.tsx        | 217 +++++++++++++++++++++
 .../app/components/__tests__/MailboxView.test.tsx  |  28 +++
 .../__tests__/MessageComposer.report-mode.test.tsx | 106 ++++++++++
 .../components/__tests__/chatReportHandoff.test.ts |  26 +++
 .../dashboard/app/components/chatReportHandoff.ts  |  65 ++++++
 .../app/components/dashboard/MainContent.tsx       |   4 +
 .../dashboard/app/components/dashboard/types.ts    |   3 +
 20 files changed, 1135 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-8872

Fusion-Task-Lineage: 55e4bf5e-e7f3-48a5-8084-4090ab24710a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-09 06:07:01 -07:00

101 lines
5.7 KiB
TypeScript

import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ApprovalRequestStatus, MessageMetadata } from "@fusion/core";
import type { ApprovalRequestDetail } from "../api/chat/messaging";
import { decideApproval, fetchApprovalDetail } from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent";
import "./MailboxStructuralItem.css";
export function isStructuralMail(metadata?: MessageMetadata): boolean {
return metadata?.mailKind === "report" || metadata?.mailKind === "approval";
}
export function MailboxKindBadge({ metadata }: { metadata?: MessageMetadata }) {
const { t } = useTranslation("app");
if (!isStructuralMail(metadata)) return null;
const kind = metadata!.mailKind;
return <span className={`mailbox-kind-badge mailbox-kind-badge--${kind}`} data-testid="mailbox-kind-badge">{kind === "report" ? t("mailbox.report", "Report") : t("mailbox.approval", "Approval")}</span>;
}
function MailboxApprovalDecision({ approvalRequestId, projectId, addToast, onDecided }: { approvalRequestId?: string; projectId?: string; addToast?: (message: string, type?: "success" | "error") => void; onDecided?: () => void }) {
const [detail, setDetail] = useState<ApprovalRequestDetail | null>(null);
const [loading, setLoading] = useState(true);
const [unresolvable, setUnresolvable] = useState(false);
const [deciding, setDeciding] = useState(false);
const [comment, setComment] = useState("");
/* FNXC:StructuralMail 2026-08-09-11:33: A decision response may finish after selection or project changes; never overwrite the current approval reference with an old-project response. */
const currentApprovalTargetRef = useRef({ approvalRequestId, projectId });
currentApprovalTargetRef.current = { approvalRequestId, projectId };
useEffect(() => {
let active = true;
setDetail(null);
setComment("");
if (!approvalRequestId?.trim()) {
setLoading(false);
setUnresolvable(true);
return () => { active = false; };
}
setLoading(true);
setUnresolvable(false);
void fetchApprovalDetail(approvalRequestId, projectId).then((response) => {
if (!active) return;
setDetail(response);
setLoading(false);
}).catch(() => {
if (!active) return;
setUnresolvable(true);
setLoading(false);
});
return () => { active = false; };
}, [approvalRequestId, projectId]);
const decide = async (decision: "approve" | "deny") => {
if (!approvalRequestId || deciding) return;
setDeciding(true);
try {
const response = await decideApproval(approvalRequestId, { decision, comment: comment || undefined }, projectId);
const currentTarget = currentApprovalTargetRef.current;
if (currentTarget.approvalRequestId !== approvalRequestId || currentTarget.projectId !== projectId) return;
setDetail(response);
setComment("");
onDecided?.();
} catch (error) {
addToast?.(error instanceof Error ? error.message : "Failed to submit decision", "error");
} finally {
setDeciding(false);
}
};
if (loading) return <span className="mailbox-approval-loading">Loading approval…</span>;
if (unresolvable || !detail) return <span className="mailbox-approval-unresolvable" data-testid="mailbox-approval-unresolvable">This approval request is no longer available.</span>;
const status: ApprovalRequestStatus = detail.status;
if (status !== "pending") return <span className="mailbox-inline-approval-status" data-testid="mailbox-inline-approval-status">Approval {status}</span>;
return <div className="mailbox-inline-approval">
<textarea className="input" value={comment} onChange={(event) => setComment(event.target.value)} placeholder="Optional comment" data-testid="mailbox-inline-approval-comment" />
<div className="mailbox-inline-approval-actions">
<button type="button" className="btn btn-sm btn-primary" disabled={deciding} onClick={() => void decide("approve")} data-testid="mailbox-inline-approval-approve">Approve</button>
<button type="button" className="btn btn-sm btn-secondary" disabled={deciding} onClick={() => void decide("deny")} data-testid="mailbox-inline-approval-deny">Deny</button>
</div>
</div>;
}
/**
* FNXC:StructuralMail 2026-08-09-09:09:
* Mail carries structural reports and ApprovalRequest references, never copied approval snapshots.
* The approval lifecycle is pending, approved, denied, or completed; an unresolved reference is the
* terminal-unknown case because there is no expired status. Ordinary mail returns no shell.
*/
export const MailboxStructuralItem = memo(function MailboxStructuralItem({ metadata, projectId, onOpenTask, addToast, onDecided }: { metadata?: MessageMetadata; projectId?: string; onOpenTask?: (taskId: string) => void; addToast?: (message: string, type?: "success" | "error") => void; onDecided?: () => void }) {
if (!isStructuralMail(metadata)) return null;
if (metadata!.mailKind === "approval") return <section className="mailbox-structural-approval" data-testid="mailbox-structural-approval"><MailboxApprovalDecision approvalRequestId={metadata!.approvalRequestId} projectId={projectId} addToast={addToast} onDecided={onDecided} /></section>;
const report = metadata!.report;
return <section className="mailbox-structural-report" data-testid="mailbox-structural-report">
{report?.title?.trim() ? <h3>{report.title}</h3> : null}
{report?.sections.map((section, index) => <div className="mailbox-report-section" data-testid="mailbox-report-section" key={`${section.heading}-${index}`}>
<h4>{section.heading}</h4>
<MailboxMessageContent content={section.body} onOpenTask={onOpenTask} testId="mailbox-report-section-body" />
</div>)}
</section>;
});