Files
fusion/packages/dashboard/app/components/chatReportHandoff.ts
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

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