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:
7
.changeset/fn-9104-mailbox-recommendation-create.md
Normal file
7
.changeset/fn-9104-mailbox-recommendation-create.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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%; } }
|
||||||
127
packages/dashboard/app/components/MailboxTaskRecommendations.tsx
Normal file
127
packages/dashboard/app/components/MailboxTaskRecommendations.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user