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>
128 lines
5.9 KiB
TypeScript
128 lines
5.9 KiB
TypeScript
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>;
|
|
}
|