Files
fusion/packages/dashboard/app/components/MailboxTaskRecommendations.tsx
gsxdsm 872d260ab8 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>
2026-08-15 17:36:32 -07:00

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>;
}