FN-9104: Add mailbox recommendation task actions

Add guarded task creation directly to completed-task recommendation notices.

- Render live recommendation details and task actions in mailbox detail and conversation surfaces.
- Prevent duplicate creates, expose existing task links, and support unavailable and retry states.
- Cover desktop and mobile surfaces, update notice copy and documentation, and add a release changeset.

Files changed:
 .../fn-9104-mailbox-recommendation-create.md       |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../__tests__/task-recommendation-notice.test.ts   |   7 ++
 packages/core/src/task-recommendation-notice.ts    |   9 +-
 packages/dashboard/app/components/MailboxModal.tsx |   3 +
 .../app/components/MailboxTaskRecommendations.css  |   9 ++
 .../app/components/MailboxTaskRecommendations.tsx  | 127 +++++++++++++++++++++
 packages/dashboard/app/components/MailboxView.tsx  |   3 +
 .../MailboxTaskRecommendations.surfaces.test.tsx   |  77 +++++++++++++
 .../__tests__/MailboxTaskRecommendations.test.tsx  |  72 ++++++++++++
 10 files changed, 311 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-9104

Fusion-Task-Lineage: 94f53bfe-f57f-42ee-970e-56658129c1e0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 17:34:40 -07:00
parent 81b1860f95
commit 872d260ab8
10 changed files with 311 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Create follow-up tasks directly from mailbox recommendation notices.
category: feature
dev: Adds guarded recommendation-create actions to mailbox detail and conversation surfaces.

View File

@@ -812,7 +812,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio
## Mailbox View ## Mailbox View
Mailbox view shows inbox/outbox communication threads and unread state. When an ephemeral worker is configured for follow-up validation, its task proposals include a **Create task** action; created proposals link directly to the resulting task. Mailbox view shows inbox/outbox communication threads and unread state. When an ephemeral worker is configured for follow-up validation, its task proposals include a **Create task** action; created proposals link directly to the resulting task. Completed-task recommendation notices also show an inline **Create task** button for every live recommendation, so operators can file optional follow-ups without leaving the message. Already-created recommendations instead provide **View task FN-NNNN**; unavailable parents remain informational and failed creates offer a retry. The controls are available in both desktop and mobile message detail and conversation views, with mobile actions using the message width.
- Mail composers can attach a native mission, milestone, goal, persisted insight, eval result, or roadmap item by dragging it from its owning view, or through the keyboard/mobile **Attach structure** picker. Roadmap feature-row drag is available on fine pointers; touch and keyboard use the picker. The shared `nativeStructureDrag` payload is copied into the same first-class mail embed metadata as picker attachments, while a dropped payload from another project is rejected. - Mail composers can attach a native mission, milestone, goal, persisted insight, eval result, or roadmap item by dragging it from its owning view, or through the keyboard/mobile **Attach structure** picker. Roadmap feature-row drag is available on fine pointers; touch and keyboard use the picker. The shared `nativeStructureDrag` payload is copied into the same first-class mail embed metadata as picker attachments, while a dropped payload from another project is rejected.
- **Draft with AI** opens a compact compose-chat scratch session that uses attached structures as context. **Use draft** replaces an empty message body; replacing typed text requires confirmation, and attached embeds remain in place. - **Draft with AI** opens a compact compose-chat scratch session that uses attached structures as context. **Use draft** replaces an empty message body; replacing typed text requires confirmation, and attached embeds remain in place.

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildTaskRecommendationNoticeContent,
buildTaskRecommendationNoticeIdempotencyKey, buildTaskRecommendationNoticeIdempotencyKey,
notifyOperatorOfTaskRecommendations, notifyOperatorOfTaskRecommendations,
registerTaskRecommendationNoticeMailbox, registerTaskRecommendationNoticeMailbox,
@@ -39,6 +40,8 @@ describe("task recommendation notice", () => {
categories: ["improvement", "feature"], categories: ["improvement", "feature"],
}, },
}); });
expect(sent[0].input.content).toContain("Use the **Create task** button beside each recommendation");
expect(sent[0].input.content).toContain(`open ${task.id}'s **Recommendations** tab`);
for (const recommendation of recommendations) { for (const recommendation of recommendations) {
expect(sent[0].input.content).toContain(recommendation.title); expect(sent[0].input.content).toContain(recommendation.title);
expect(JSON.stringify(sent[0].input.metadata)).not.toContain(recommendation.title); expect(JSON.stringify(sent[0].input.metadata)).not.toContain(recommendation.title);
@@ -66,6 +69,10 @@ describe("task recommendation notice", () => {
await expect(notifyOperatorOfTaskRecommendations(failingStore as never, task, recommendations, {})).resolves.toBe(false); await expect(notifyOperatorOfTaskRecommendations(failingStore as never, task, recommendations, {})).resolves.toBe(false);
}); });
it("keeps the inline-action copy in the standalone content builder", () => {
expect(buildTaskRecommendationNoticeContent(task, recommendations)).toContain("Use the **Create task** button beside each recommendation");
});
it("dedupes equal id sets but changes keys for changed ids", () => { it("dedupes equal id sets but changes keys for changed ids", () => {
expect(buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations)).toBe( expect(buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations)).toBe(
buildTaskRecommendationNoticeIdempotencyKey(task.id, [...recommendations].reverse()), buildTaskRecommendationNoticeIdempotencyKey(task.id, [...recommendations].reverse()),

View File

@@ -1,5 +1,5 @@
/* /*
FNXC:TaskRecommendations 2026-08-13-03:56: FNXC:TaskRecommendations 2026-08-15-22:44:
SCOPE — notices fire only for a new, non-empty recommendation list at an ACCEPTED completion. Do SCOPE — notices fire only for a new, non-empty recommendation list at an ACCEPTED completion. Do
not invoke this for a rolled-back handoff, `linkTaskRecommendation`, or the create-from- not invoke this for a rolled-back handoff, `linkTaskRecommendation`, or the create-from-
recommendation route: those only stamp `createdTaskId` on an existing proposal. recommendation route: those only stamp `createdTaskId` on an existing proposal.
@@ -8,8 +8,9 @@ BEST-EFFORT + NON-BLOCKING — callers dispatch without awaiting. This is the so
failures may be observed, and it swallows them so recommendation capture and task completion remain failures may be observed, and it swallows them so recommendation capture and task completion remain
independent from mailbox availability. independent from mailbox availability.
PROSE PLACEMENT — operator-facing prose belongs only in mailbox content. Metadata contains ids, MAILBOX ACTIONS — dashboard notices now expose inline Create task controls. Operator-facing prose
enums, and counts; this module writes no run-audit prose. still belongs only in content; metadata remains ids, enums, and counts so the dashboard resolves
current recommendation text and link state from the parent task rather than storing a duplicate snapshot.
*/ */
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -62,7 +63,7 @@ export function buildTaskRecommendationNoticeContent(
"", "",
...recommendations.map((recommendation) => `- **${recommendation.title}** — \`${recommendation.category}\`: ${recommendation.description}`), ...recommendations.map((recommendation) => `- **${recommendation.title}** — \`${recommendation.category}\`: ${recommendation.description}`),
"", "",
`Open ${task.id}'s **Recommendations** tab to review them. **Create task** files one through normal guarded intake.`, `Use the **Create task** button beside each recommendation to file it through guarded intake. You can also open ${task.id}'s **Recommendations** tab to review them.`,
].join("\n"); ].join("\n");
} }

View File

@@ -44,6 +44,7 @@ import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import { MailboxRelatedWorkLink, hasRelatedTaskLink } from "./MailboxRelatedWorkLink"; import { MailboxRelatedWorkLink, hasRelatedTaskLink } from "./MailboxRelatedWorkLink";
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds"; import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
import { MailboxTaskProposal } from "./MailboxTaskProposal"; import { MailboxTaskProposal } from "./MailboxTaskProposal";
import { MailboxTaskRecommendations } from "./MailboxTaskRecommendations";
import { MailboxKindBadge, MailboxStructuralItem, isStructuralMail } from "./MailboxStructuralItem"; import { MailboxKindBadge, MailboxStructuralItem, isStructuralMail } from "./MailboxStructuralItem";
import type { Agent } from "../api"; import type { Agent } from "../api";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -1040,6 +1041,7 @@ export function MailboxModal({
/> />
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} /> <MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} /> <MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
<MailboxTaskRecommendations metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</div> </div>
); );
})} })}
@@ -1082,6 +1084,7 @@ export function MailboxModal({
/> />
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} /> <MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} /> <MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
<MailboxTaskRecommendations metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,9 @@
.mailbox-task-recommendations { margin-block: var(--space-2); display: grid; gap: var(--space-2); }
.mailbox-task-recommendations__item { display: grid; gap: var(--space-3); padding: var(--space-3); }
.mailbox-task-recommendations__heading { display: flex; align-items: center; gap: var(--space-2); }
.mailbox-task-recommendations__heading h3, .mailbox-task-recommendations__content p { margin: 0; }
.mailbox-task-recommendations__heading span { border: var(--border-width) solid var(--color-border); border-radius: var(--radius-md); padding-inline: var(--space-2); background: var(--color-surface); }
.mailbox-task-recommendations__action { display: grid; gap: var(--space-2); justify-items: start; }
.mailbox-task-recommendations__error { color: var(--color-error); }
.mailbox-task-recommendations__unavailable { margin-block: var(--space-2); color: var(--color-warning); }
@media (max-width: 768px) { .mailbox-task-recommendations .btn { inline-size: 100%; } }

View File

@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "react";
import type { MessageMetadata, TaskRecommendation } from "@fusion/core";
import { createTaskFromRecommendation, fetchTaskDetail } from "../api";
import "./MailboxTaskRecommendations.css";
type TaskRecommendationNoticeMetadata = MessageMetadata & {
taskId?: string;
recommendationIds?: unknown;
};
function getNoticeTarget(metadata?: MessageMetadata): { taskId: string; recommendationIds: string[] } | null {
if (metadata?.kind !== "task-recommendation-notice") return null;
const taskId = (metadata as TaskRecommendationNoticeMetadata).taskId?.trim();
const recommendationIds = (metadata as TaskRecommendationNoticeMetadata).recommendationIds;
if (!taskId || !Array.isArray(recommendationIds) || recommendationIds.length === 0) return null;
const ids = recommendationIds.filter((id): id is string => typeof id === "string" && id.trim().length > 0);
return ids.length > 0 ? { taskId, recommendationIds: ids } : null;
}
export function MailboxTaskRecommendations({
metadata,
projectId,
onOpenTask,
}: {
metadata?: MessageMetadata;
projectId?: string;
onOpenTask?: (taskId: string) => void;
}) {
const target = getNoticeTarget(metadata);
const [recommendations, setRecommendations] = useState<TaskRecommendation[] | null>(null);
const [unavailable, setUnavailable] = useState(false);
const [createdIds, setCreatedIds] = useState<Record<string, string>>({});
const [creatingActions, setCreatingActions] = useState<Record<string, true>>({});
const [errorActions, setErrorActions] = useState<Record<string, true>>({});
const creatingIdsRef = useRef(new Set<string>());
const taskId = target?.taskId;
const recommendationIds = target?.recommendationIds;
const recommendationIdsKey = recommendationIds?.join("\u0000");
useEffect(() => {
let active = true;
creatingIdsRef.current.clear();
setRecommendations(null);
setUnavailable(false);
setCreatedIds({});
setCreatingActions({});
setErrorActions({});
if (!taskId || !recommendationIds) return () => { active = false; };
/*
FNXC:TaskRecommendations 2026-08-15-22:39:
Mailbox metadata deliberately contains only durable identifiers, counts, and categories; resolve
live recommendation prose and link state from the parent task so notices never copy operator text
into metadata or offer stale creates after a task has already been linked.
*/
void fetchTaskDetail(taskId, projectId).then((task) => {
if (!active) return;
const allowedIds = new Set(recommendationIds);
const matched = (task.recommendations ?? []).filter((recommendation) => allowedIds.has(recommendation.id));
setRecommendations(matched);
setUnavailable(matched.length === 0);
}).catch(() => {
if (!active) return;
setUnavailable(true);
});
return () => { active = false; };
}, [projectId, recommendationIdsKey, taskId]);
if (!target) return null;
const createRecommendation = async (recommendation: TaskRecommendation) => {
const actionKey = `${target.taskId}:${recommendation.id}`;
if (creatingIdsRef.current.has(actionKey) || recommendation.createdTaskId || createdIds[actionKey]) return;
/*
FNXC:TaskRecommendations 2026-08-15-22:39:
Recommendation creation stays behind the server's idempotent completed-task guard. The per-parent
action key prevents rapid duplicate mailbox clicks while allowing different recommendations to act independently.
*/
creatingIdsRef.current.add(actionKey);
setCreatingActions((current) => ({ ...current, [actionKey]: true }));
setErrorActions((current) => {
const { [actionKey]: _cleared, ...remaining } = current;
return remaining;
});
try {
const response = await createTaskFromRecommendation(target.taskId, recommendation.id, projectId);
setCreatedIds((current) => ({ ...current, [actionKey]: response.task.id }));
} catch {
setErrorActions((current) => ({ ...current, [actionKey]: true }));
} finally {
creatingIdsRef.current.delete(actionKey);
setCreatingActions((current) => {
const { [actionKey]: _cleared, ...remaining } = current;
return remaining;
});
}
};
if (unavailable) return <p className="mailbox-task-recommendations__unavailable" data-testid="mailbox-task-recommendations-unavailable">Recommendations are no longer available.</p>;
if (!recommendations) return null;
return <section className="mailbox-task-recommendations" data-testid="mailbox-task-recommendations" aria-label="Task recommendations">
{recommendations.map((recommendation) => {
const actionKey = `${target.taskId}:${recommendation.id}`;
const createdTaskId = recommendation.createdTaskId ?? createdIds[actionKey];
const creating = creatingActions[actionKey] === true;
const failed = errorActions[actionKey] === true;
return <article className="mailbox-task-recommendations__item card" key={recommendation.id}>
<div className="mailbox-task-recommendations__content">
<div className="mailbox-task-recommendations__heading"><h3>{recommendation.title}</h3><span>{recommendation.category}</span></div>
<p>{recommendation.description}</p>
</div>
{createdTaskId ? (
<button type="button" className="btn btn-primary" onClick={() => onOpenTask?.(createdTaskId)}>View task {createdTaskId}</button>
) : (
<div className="mailbox-task-recommendations__action">
<button type="button" className="btn btn-primary" disabled={creating} onClick={() => void createRecommendation(recommendation)}>
{creating ? "Creating…" : failed ? "Retry creating task" : "Create task"}
</button>
{failed && <span className="mailbox-task-recommendations__error" role="status">Could not create task. Try again.</span>}
</div>
)}
</article>;
})}
</section>;
}

View File

@@ -45,6 +45,7 @@ import { MailboxArtifactAttachment } from "./MailboxArtifactAttachment";
import { MailboxRelatedWorkLink, hasRelatedTaskLink } from "./MailboxRelatedWorkLink"; import { MailboxRelatedWorkLink, hasRelatedTaskLink } from "./MailboxRelatedWorkLink";
import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds"; import { MailboxNativeStructureEmbeds } from "./MailboxNativeStructureEmbeds";
import { MailboxTaskProposal } from "./MailboxTaskProposal"; import { MailboxTaskProposal } from "./MailboxTaskProposal";
import { MailboxTaskRecommendations } from "./MailboxTaskRecommendations";
import { MailboxKindBadge, MailboxStructuralItem, isStructuralMail } from "./MailboxStructuralItem"; import { MailboxKindBadge, MailboxStructuralItem, isStructuralMail } from "./MailboxStructuralItem";
import type { ChatReportHandoff } from "./chatReportHandoff"; import type { ChatReportHandoff } from "./chatReportHandoff";
import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer"; import { MessageComposer, type NativeStructureCandidate } from "./MessageComposer";
@@ -1103,6 +1104,7 @@ export function MailboxView({
/> />
<MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} /> <MailboxNativeStructureEmbeds message={msg} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} /> <MailboxTaskProposal messageId={msg.id} metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
<MailboxTaskRecommendations metadata={msg.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</div> </div>
); );
})} })}
@@ -1139,6 +1141,7 @@ export function MailboxView({
/> />
<MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} /> <MailboxNativeStructureEmbeds message={selectedMessage} projectId={projectId} onOpen={onOpenNativeStructure} />
<MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} /> <MailboxTaskProposal messageId={selectedMessage.id} metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
<MailboxTaskRecommendations metadata={selectedMessage.metadata} projectId={projectId} onOpenTask={onOpenTask} />
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Message } from "@fusion/core";
import { MailboxView } from "../MailboxView";
import { MailboxModal } from "../MailboxModal";
import { useViewportMode } from "../../hooks/useViewportMode";
import { useViewportMode as useHeaderViewportMode } from "../Header";
vi.mock("../../api", () => ({
fetchInbox: vi.fn(), fetchOutbox: vi.fn(), fetchUnreadCount: vi.fn(), fetchAgentMailbox: vi.fn(), fetchAllAgentMailbox: vi.fn(),
markMessageRead: vi.fn(), markAllMessagesRead: vi.fn(), deleteMessage: vi.fn(), fetchConversation: vi.fn(), fetchMessage: vi.fn(),
sendMessage: vi.fn(), fetchAgents: vi.fn(), fetchApprovals: vi.fn(), fetchApprovalDetail: vi.fn(), decideApproval: vi.fn(),
artifactMediaUrlWithToken: vi.fn(), fetchNativeStructurePreview: vi.fn(), fetchTaskDetail: vi.fn(), createTaskFromRecommendation: vi.fn(), archiveMessage: vi.fn(), unarchiveMessage: vi.fn(),
}));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: vi.fn(() => "desktop"), isMobileViewport: () => false, isFullScreenSheetViewport: () => false, isShortViewport: () => false, getViewportMode: () => "desktop", isTabletTouchViewport: () => false }));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false })) }));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
vi.mock("../Header", () => ({ useViewportMode: vi.fn(() => "desktop") }));
vi.mock("../ComposeChatPanel", () => ({ ComposeChatPanel: () => null }));
vi.mock("lucide-react", () => ({ Mail: () => null, Send: () => null, Inbox: () => null, Bot: () => null, Trash2: () => null, Archive: () => null, CheckCheck: () => null, Loader2: () => null, RefreshCw: () => null, MessageSquare: () => null, User: () => null, X: () => null, Check: () => null, ChevronRight: () => null, ChevronDown: () => null, AlertCircle: () => null, Map: () => null, Flag: () => null, Lightbulb: () => null, BarChart3: () => null, Target: () => null, CircleAlert: () => null }));
import * as api from "../../api";
const agents = [{ id: "agent-1", name: "Agent", role: "executor", state: "idle", createdAt: "2026-08-15T00:00:00.000Z", updatedAt: "2026-08-15T00:00:00.000Z", metadata: {} }];
const recommendationNotice = (id: string): Message => ({ id, fromId: "agent-1", fromType: "agent", toId: "dashboard", toType: "user", type: "agent-to-user", read: true, content: "Recommendations", createdAt: "2026-08-15T00:00:00.000Z", updatedAt: "2026-08-15T00:00:00.000Z", metadata: { kind: "task-recommendation-notice", taskId: "FN-9100", recommendationIds: ["rec-1"] } });
const ordinary = (id: string): Message => ({ ...recommendationNotice(id), metadata: undefined, content: "Ordinary" });
/**
* FNXC:TaskRecommendations 2026-08-15-22:39:
* The create control is mounted separately in each selected and conversation body. Exercise real
* mailbox hosts at both breakpoints so a future one-site wiring regression cannot leave a hidden surface prose-only.
*/
describe("mailbox task recommendation production surfaces", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: vi.fn() });
const messages = [recommendationNotice("notice"), ordinary("ordinary")];
vi.mocked(api.fetchInbox).mockResolvedValue({ messages, total: messages.length, unreadCount: 0 });
vi.mocked(api.fetchOutbox).mockResolvedValue({ messages: [], total: 0 });
vi.mocked(api.fetchUnreadCount).mockResolvedValue({ unreadCount: 0 });
vi.mocked(api.fetchAgents).mockResolvedValue(agents as never);
vi.mocked(api.fetchAllAgentMailbox).mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
vi.mocked(api.fetchTaskDetail).mockResolvedValue({ id: "FN-9100", recommendations: [{ id: "rec-1", title: "Follow up", description: "Optional follow-up", category: "feature" }] } as never);
});
it.each([
["MailboxView", "desktop", "selected", (props: any) => <MailboxView {...props} />],
["MailboxView", "desktop", "conversation", (props: any) => <MailboxView {...props} />],
["MailboxView", "mobile", "selected", (props: any) => <MailboxView {...props} />],
["MailboxView", "mobile", "conversation", (props: any) => <MailboxView {...props} />],
["MailboxModal", "desktop", "selected", (props: any) => <MailboxModal isOpen onClose={vi.fn()} agents={agents as never} {...props} />],
["MailboxModal", "desktop", "conversation", (props: any) => <MailboxModal isOpen onClose={vi.fn()} agents={agents as never} {...props} />],
["MailboxModal", "mobile", "selected", (props: any) => <MailboxModal isOpen onClose={vi.fn()} agents={agents as never} {...props} />],
["MailboxModal", "mobile", "conversation", (props: any) => <MailboxModal isOpen onClose={vi.fn()} agents={agents as never} {...props} />],
] as const)("renders Create task in %s %s %s body", async (_name, viewport, pane, Host) => {
vi.mocked(useViewportMode).mockReturnValue(viewport);
vi.mocked(useHeaderViewportMode).mockReturnValue(viewport);
const messages = [recommendationNotice("notice"), { ...ordinary("ordinary"), metadata: { replyTo: { messageId: "notice" } } }];
vi.mocked(api.fetchConversation).mockResolvedValue(pane === "conversation" ? messages as never : []);
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
render(<Host addToast={vi.fn()} onOpenNativeStructure={vi.fn()} nativeStructureCandidates={[]} />);
await user.click(await screen.findByTestId("mailbox-item-notice"));
if (pane === "conversation") await waitFor(() => expect(screen.getByTestId("mailbox-conversation")).toBeInTheDocument());
expect(await screen.findByRole("button", { name: "Create task" })).toBeInTheDocument();
});
it.each([
["MailboxView", (props: any) => <MailboxView {...props} />],
["MailboxModal", (props: any) => <MailboxModal isOpen onClose={vi.fn()} agents={agents as never} {...props} />],
])("keeps ordinary %s messages shell-free", async (_name, Host) => {
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
render(<Host addToast={vi.fn()} onOpenNativeStructure={vi.fn()} nativeStructureCandidates={[]} />);
await user.click(await screen.findByTestId("mailbox-item-ordinary"));
expect(screen.queryByTestId("mailbox-task-recommendations")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,72 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { MessageMetadata } from "@fusion/core";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { createTaskFromRecommendation, fetchTaskDetail } from "../../api";
import { MailboxTaskRecommendations } from "../MailboxTaskRecommendations";
vi.mock("../../api", () => ({ createTaskFromRecommendation: vi.fn(), fetchTaskDetail: vi.fn() }));
const metadata: MessageMetadata = { kind: "task-recommendation-notice", taskId: "FN-9100", recommendationIds: ["recommendation-1"] };
const detail = { id: "FN-9100", recommendations: [{ id: "recommendation-1", title: "Follow up", description: "Finish the optional work.", category: "feature" }] };
describe("MailboxTaskRecommendations", () => {
beforeEach(() => vi.resetAllMocks());
it("renders nothing for non-notices, missing parents, and empty recommendation ids", () => {
for (const candidate of [{}, { kind: "task-recommendation-notice", recommendationIds: ["recommendation-1"] }, { kind: "task-recommendation-notice", taskId: "FN-9100", recommendationIds: [] }]) {
const { container, unmount } = render(<MailboxTaskRecommendations metadata={candidate} />);
expect(container).toBeEmptyDOMElement();
unmount();
}
});
it("keeps a failed parent lookup inert", async () => {
vi.mocked(fetchTaskDetail).mockRejectedValue(new Error("not found"));
render(<MailboxTaskRecommendations metadata={metadata} />);
await waitFor(() => expect(screen.getByTestId("mailbox-task-recommendations-unavailable")).toBeInTheDocument());
expect(screen.queryByRole("button", { name: "Create task" })).not.toBeInTheDocument();
});
it("creates once and replaces the action with the linked task", async () => {
const onOpenTask = vi.fn();
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
vi.mocked(createTaskFromRecommendation).mockResolvedValue({ task: { id: "FN-9101" }, parent: detail } as never);
render(<MailboxTaskRecommendations metadata={metadata} projectId="project-1" onOpenTask={onOpenTask} />);
await screen.findByRole("button", { name: "Create task" });
fireEvent.click(screen.getByRole("button", { name: "Create task" }));
await waitFor(() => expect(screen.getByRole("button", { name: "View task FN-9101" })).toBeInTheDocument());
expect(createTaskFromRecommendation).toHaveBeenCalledWith("FN-9100", "recommendation-1", "project-1");
fireEvent.click(screen.getByRole("button", { name: "View task FN-9101" }));
expect(onOpenTask).toHaveBeenCalledWith("FN-9101");
});
it("shows existing links without a duplicate Create action", async () => {
vi.mocked(fetchTaskDetail).mockResolvedValue({ ...detail, recommendations: [{ ...detail.recommendations[0], createdTaskId: "FN-9101" }] } as never);
render(<MailboxTaskRecommendations metadata={metadata} />);
expect(await screen.findByRole("button", { name: "View task FN-9101" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create task" })).not.toBeInTheDocument();
});
it("guards rapid duplicate clicks", async () => {
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
let resolveCreate!: (value: never) => void;
vi.mocked(createTaskFromRecommendation).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve; }));
render(<MailboxTaskRecommendations metadata={metadata} />);
await screen.findByRole("button", { name: "Create task" });
const button = screen.getByRole("button", { name: "Create task" });
fireEvent.click(button);
fireEvent.click(button);
expect(createTaskFromRecommendation).toHaveBeenCalledTimes(1);
resolveCreate({ task: { id: "FN-9101" }, parent: detail } as never);
await screen.findByRole("button", { name: "View task FN-9101" });
});
it("offers a retry after a rejected creation", async () => {
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
vi.mocked(createTaskFromRecommendation).mockRejectedValue(new Error("conflict"));
render(<MailboxTaskRecommendations metadata={metadata} />);
fireEvent.click(await screen.findByRole("button", { name: "Create task" }));
expect(await screen.findByRole("button", { name: "Retry creating task" })).toBeInTheDocument();
expect(screen.getByText("Could not create task. Try again.")).toBeInTheDocument();
});
});