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>
66 lines
2.5 KiB
TypeScript
66 lines
2.5 KiB
TypeScript
import { useCallback, useRef, useState } from "react";
|
|
|
|
export interface ChatReportHandoff {
|
|
body: string;
|
|
title: string;
|
|
}
|
|
|
|
export const CHAT_REPORT_MAX_BODY = 2000;
|
|
|
|
/**
|
|
* FNXC:StructuralMail 2026-08-09-09:09:
|
|
* Report-mode mail rejects a blank title, so chat derives the required title before routing rather
|
|
* than opening a composer that cannot send. The bounded body matches the composer limit.
|
|
*/
|
|
export interface ChatMailReportRouting {
|
|
mailComposerPrefill: (ChatReportHandoff & { nonce: number }) | null;
|
|
onSendAsReport: (handoff: ChatReportHandoff) => void;
|
|
}
|
|
|
|
/**
|
|
* FNXC:StructuralMail 2026-08-09-11:59:
|
|
* App owns report handoff navigation and Quick Chat dismissal. Keep that stateful seam reusable by
|
|
* the real routing test so it cannot drift into a test-only approximation of the production chain.
|
|
*/
|
|
export function useChatMailReportRouting(
|
|
navigateToMailbox: () => void,
|
|
closeQuickChat: () => void,
|
|
): ChatMailReportRouting {
|
|
const [mailComposerPrefill, setMailComposerPrefill] = useState<(ChatReportHandoff & { nonce: number }) | null>(null);
|
|
const nonceRef = useRef(0);
|
|
const onSendAsReport = useCallback((handoff: ChatReportHandoff) => {
|
|
const nonce = Math.max(Date.now(), nonceRef.current + 1);
|
|
nonceRef.current = nonce;
|
|
setMailComposerPrefill({ ...handoff, nonce });
|
|
navigateToMailbox();
|
|
closeQuickChat();
|
|
}, [closeQuickChat, navigateToMailbox]);
|
|
return { mailComposerPrefill, onSendAsReport };
|
|
}
|
|
|
|
export function buildChatReportHandoff(rawBody: string, fallbackTitle: string): { handoff: ChatReportHandoff | null; truncated: boolean } {
|
|
const trimmed = rawBody.trim();
|
|
if (!trimmed) return { handoff: null, truncated: false };
|
|
const truncated = rawBody.length > CHAT_REPORT_MAX_BODY;
|
|
const body = rawBody.slice(0, CHAT_REPORT_MAX_BODY);
|
|
let inFence = false;
|
|
let proseCandidate: string | undefined;
|
|
let headingCandidate: string | undefined;
|
|
for (const line of trimmed.split("\n")) {
|
|
const value = line.trim();
|
|
if (value.startsWith("```") || value.startsWith("~~~")) {
|
|
inFence = !inFence;
|
|
continue;
|
|
}
|
|
if (inFence || !value) continue;
|
|
const heading = value.match(/^#{1,6}\s+(.+)$/)?.[1]?.trim();
|
|
if (heading) {
|
|
headingCandidate = heading;
|
|
break;
|
|
}
|
|
if (!proseCandidate && /[\p{L}\p{N}]/u.test(value)) proseCandidate = value;
|
|
}
|
|
const title = (headingCandidate?.slice(0, 80) || proseCandidate?.slice(0, 80) || fallbackTitle.trim() || "Chat report").trim();
|
|
return { handoff: { body, title }, truncated };
|
|
}
|