From 872d260ab8654717805aa1063e996d81a3a8179e Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 15 Aug 2026 17:34:40 -0700 Subject: [PATCH] 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) --- .../fn-9104-mailbox-recommendation-create.md | 7 + docs/dashboard-guide.md | 2 +- .../task-recommendation-notice.test.ts | 7 + .../core/src/task-recommendation-notice.ts | 9 +- .../dashboard/app/components/MailboxModal.tsx | 3 + .../components/MailboxTaskRecommendations.css | 9 ++ .../components/MailboxTaskRecommendations.tsx | 127 ++++++++++++++++++ .../dashboard/app/components/MailboxView.tsx | 3 + ...ilboxTaskRecommendations.surfaces.test.tsx | 77 +++++++++++ .../MailboxTaskRecommendations.test.tsx | 72 ++++++++++ 10 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 .changeset/fn-9104-mailbox-recommendation-create.md create mode 100644 packages/dashboard/app/components/MailboxTaskRecommendations.css create mode 100644 packages/dashboard/app/components/MailboxTaskRecommendations.tsx create mode 100644 packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.surfaces.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.test.tsx diff --git a/.changeset/fn-9104-mailbox-recommendation-create.md b/.changeset/fn-9104-mailbox-recommendation-create.md new file mode 100644 index 0000000000..bbcdd13dd4 --- /dev/null +++ b/.changeset/fn-9104-mailbox-recommendation-create.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4f8203e9db..6c3fc1357d 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/core/src/__tests__/task-recommendation-notice.test.ts b/packages/core/src/__tests__/task-recommendation-notice.test.ts index 0569a3b4c8..5dfd8c7585 100644 --- a/packages/core/src/__tests__/task-recommendation-notice.test.ts +++ b/packages/core/src/__tests__/task-recommendation-notice.test.ts @@ -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()), diff --git a/packages/core/src/task-recommendation-notice.ts b/packages/core/src/task-recommendation-notice.ts index 3f44449ff3..1d9a9418bb 100644 --- a/packages/core/src/task-recommendation-notice.ts +++ b/packages/core/src/task-recommendation-notice.ts @@ -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"); } diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 736482e2c6..ab27c3dec7 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -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({ /> + ); })} @@ -1082,6 +1084,7 @@ export function MailboxModal({ /> + )} diff --git a/packages/dashboard/app/components/MailboxTaskRecommendations.css b/packages/dashboard/app/components/MailboxTaskRecommendations.css new file mode 100644 index 0000000000..281cc98235 --- /dev/null +++ b/packages/dashboard/app/components/MailboxTaskRecommendations.css @@ -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%; } } diff --git a/packages/dashboard/app/components/MailboxTaskRecommendations.tsx b/packages/dashboard/app/components/MailboxTaskRecommendations.tsx new file mode 100644 index 0000000000..420e760d78 --- /dev/null +++ b/packages/dashboard/app/components/MailboxTaskRecommendations.tsx @@ -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(null); + const [unavailable, setUnavailable] = useState(false); + const [createdIds, setCreatedIds] = useState>({}); + const [creatingActions, setCreatingActions] = useState>({}); + const [errorActions, setErrorActions] = useState>({}); + const creatingIdsRef = useRef(new Set()); + + 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

Recommendations are no longer available.

; + if (!recommendations) return null; + + return
+ {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
+
+

{recommendation.title}

{recommendation.category}
+

{recommendation.description}

+
+ {createdTaskId ? ( + + ) : ( +
+ + {failed && Could not create task. Try again.} +
+ )} +
; + })} +
; +} diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 6abb501b22..6159a54474 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -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({ /> + ); })} @@ -1139,6 +1141,7 @@ export function MailboxView({ /> + )} diff --git a/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.surfaces.test.tsx b/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.surfaces.test.tsx new file mode 100644 index 0000000000..45df606be6 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.surfaces.test.tsx @@ -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", "desktop", "conversation", (props: any) => ], + ["MailboxView", "mobile", "selected", (props: any) => ], + ["MailboxView", "mobile", "conversation", (props: any) => ], + ["MailboxModal", "desktop", "selected", (props: any) => ], + ["MailboxModal", "desktop", "conversation", (props: any) => ], + ["MailboxModal", "mobile", "selected", (props: any) => ], + ["MailboxModal", "mobile", "conversation", (props: any) => ], + ] 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(); + 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) => ], + ["MailboxModal", (props: any) => ], + ])("keeps ordinary %s messages shell-free", async (_name, Host) => { + const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 }); + render(); + await user.click(await screen.findByTestId("mailbox-item-ordinary")); + expect(screen.queryByTestId("mailbox-task-recommendations")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.test.tsx b/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.test.tsx new file mode 100644 index 0000000000..2fd549860d --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxTaskRecommendations.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + unmount(); + } + }); + + it("keeps a failed parent lookup inert", async () => { + vi.mocked(fetchTaskDetail).mockRejectedValue(new Error("not found")); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +});