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 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.
- **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 {
buildTaskRecommendationNoticeContent,
buildTaskRecommendationNoticeIdempotencyKey,
notifyOperatorOfTaskRecommendations,
registerTaskRecommendationNoticeMailbox,
@@ -39,6 +40,8 @@ describe("task recommendation notice", () => {
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) {
expect(sent[0].input.content).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);
});
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", () => {
expect(buildTaskRecommendationNoticeIdempotencyKey(task.id, recommendations)).toBe(
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
not invoke this for a rolled-back handoff, `linkTaskRecommendation`, or the create-from-
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
independent from mailbox availability.
PROSE PLACEMENT — operator-facing prose belongs only in mailbox content. Metadata contains ids,
enums, and counts; this module writes no run-audit prose.
MAILBOX ACTIONS — dashboard notices now expose inline Create task controls. Operator-facing 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";
@@ -62,7 +63,7 @@ export function buildTaskRecommendationNoticeContent(
"",
...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");
}

View File

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