feat(FN-4124): address drafts UX review feedback for MissionManager
Fixes UX review feedback for the MissionManager drafts feature, refactoring the component logic and updating corresponding tests. Minor CSS adjustments for the updated UI. Fusion-Task-Id: FN-4124
This commit is contained in:
@@ -536,6 +536,8 @@
|
||||
|
||||
.mission-list__resume-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
@@ -2431,6 +2433,7 @@
|
||||
|
||||
/* Touch-friendly controls */
|
||||
.mission-list__item-actions .mission-icon-btn,
|
||||
.mission-list__item-actions .mission-btn,
|
||||
.mission-milestone__actions .mission-icon-btn,
|
||||
.mission-slice__actions .mission-icon-btn,
|
||||
.mission-feature__actions .mission-icon-btn {
|
||||
@@ -2466,6 +2469,7 @@
|
||||
|
||||
/* Fix touch targets to meet 36px minimum */
|
||||
.mission-list__item-actions .mission-icon-btn,
|
||||
.mission-list__item-actions .mission-btn,
|
||||
.mission-milestone__actions .mission-icon-btn,
|
||||
.mission-slice__actions .mission-icon-btn,
|
||||
.mission-feature__actions .mission-icon-btn {
|
||||
|
||||
@@ -3585,6 +3585,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
|
||||
const isErrored = session.status === "error";
|
||||
const isGenerating = session.status === "generating";
|
||||
const resumeActionLabel = getInterviewActionLabel(session.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -3610,22 +3611,24 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
: "Interview is waiting for your next response."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mission-list__item-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="mission-list__item-actions mission-list__resume-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<button
|
||||
className={`mission-icon-btn ${isErrored ? "mission-icon-btn--danger" : "mission-icon-btn--success"}`}
|
||||
className="mission-btn mission-btn--ghost mission-btn--sm"
|
||||
onClick={() => handleResumeInterviewSession(session.id)}
|
||||
title={getInterviewActionLabel(session.status)}
|
||||
aria-label={getInterviewActionLabel(session.status)}
|
||||
title={resumeActionLabel}
|
||||
aria-label={resumeActionLabel}
|
||||
>
|
||||
{isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />}
|
||||
<span>{isErrored ? "Retry" : "Resume"}</span>
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
className="mission-btn mission-btn--danger mission-btn--sm"
|
||||
onClick={() => setDeleteConfirmId({ type: "interview_draft", id: session.id })}
|
||||
title="Discard draft"
|
||||
aria-label="Discard draft"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span>Discard</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3890,7 +3893,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missions.length === 0 && !isCreatingMission && (
|
||||
{missions.length === 0 && missionInterviewDrafts.length === 0 && !isCreatingMission && (
|
||||
<div className="mission-manager__empty mission-manager__empty--large mission-manager__empty--mission">
|
||||
<Target size={32} />
|
||||
<h3 className="mission-manager__empty-title">No missions yet</h3>
|
||||
@@ -3925,39 +3928,45 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
);
|
||||
};
|
||||
|
||||
const renderDeleteConfirmPanel = () => (
|
||||
<div className="mission-confirm-panel mission-confirm-panel--danger">
|
||||
<div className="mission-confirm-panel__content">
|
||||
<p>
|
||||
Delete this {deleteConfirmId?.type}? This cannot be undone.
|
||||
</p>
|
||||
<div className="mission-confirm-panel__actions">
|
||||
<button
|
||||
className="mission-btn mission-btn--danger"
|
||||
onClick={async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
if (deleteConfirmId.type === "mission") {
|
||||
await handleDeleteMission(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "milestone") {
|
||||
await handleDeleteMilestone(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "slice") {
|
||||
await handleDeleteSlice(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "feature") {
|
||||
await handleDeleteFeature(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "interview_draft") {
|
||||
await handleDiscardInterviewSession(deleteConfirmId.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button className="mission-btn mission-btn--ghost" onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
const renderDeleteConfirmPanel = () => {
|
||||
const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft";
|
||||
|
||||
return (
|
||||
<div className="mission-confirm-panel mission-confirm-panel--danger">
|
||||
<div className="mission-confirm-panel__content">
|
||||
<p>
|
||||
{isInterviewDraftDelete
|
||||
? "Discard this interview draft? This removes the saved draft and cannot be undone."
|
||||
: `Delete this ${deleteConfirmId?.type}? This cannot be undone.`}
|
||||
</p>
|
||||
<div className="mission-confirm-panel__actions">
|
||||
<button
|
||||
className="mission-btn mission-btn--danger"
|
||||
onClick={async () => {
|
||||
if (!deleteConfirmId) return;
|
||||
if (deleteConfirmId.type === "mission") {
|
||||
await handleDeleteMission(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "milestone") {
|
||||
await handleDeleteMilestone(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "slice") {
|
||||
await handleDeleteSlice(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "feature") {
|
||||
await handleDeleteFeature(deleteConfirmId.id);
|
||||
} else if (deleteConfirmId.type === "interview_draft") {
|
||||
await handleDiscardInterviewSession(deleteConfirmId.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isInterviewDraftDelete ? "Discard" : "Delete"}
|
||||
</button>
|
||||
<button className="mission-btn mission-btn--ghost" onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const renderLinkTaskPanel = () => (
|
||||
<div className="mission-confirm-panel mission-confirm-panel--link">
|
||||
|
||||
@@ -2206,7 +2206,7 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders mission interview drafts with discard actions", async () => {
|
||||
it("renders mission interview drafts with explicit resume and discard actions", async () => {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
|
||||
{
|
||||
id: "draft-awaiting",
|
||||
@@ -2237,8 +2237,16 @@ describe("MissionManager", () => {
|
||||
expect(screen.getByText("Awaiting input")).toBeInTheDocument();
|
||||
expect(screen.getByText("Needs retry")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByLabelText("Discard draft")[0]!);
|
||||
fireEvent.click(screen.getByText("Delete"));
|
||||
const awaitingRow = screen.getByText("Draft awaiting input").closest(".mission-list__item");
|
||||
const errorRow = screen.getByText("Draft with error").closest(".mission-list__item");
|
||||
expect(awaitingRow).not.toBeNull();
|
||||
expect(errorRow).not.toBeNull();
|
||||
expect(within(awaitingRow!).getByRole("button", { name: "Resume interview" })).toBeInTheDocument();
|
||||
expect(within(awaitingRow!).getByRole("button", { name: "Discard draft" })).toBeInTheDocument();
|
||||
expect(within(errorRow!).getByRole("button", { name: "Retry interview" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(awaitingRow!).getByRole("button", { name: "Discard draft" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-awaiting", undefined);
|
||||
@@ -2246,7 +2254,7 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a mission interview draft", async () => {
|
||||
it("resumes a mission interview draft from the explicit resume action", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
id: "draft-awaiting",
|
||||
type: "mission_interview",
|
||||
@@ -2283,7 +2291,10 @@ describe("MissionManager", () => {
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Draft awaiting input")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Resume interview"));
|
||||
const draftRow = screen.getByText("Draft awaiting input").closest(".mission-list__item");
|
||||
expect(draftRow).not.toBeNull();
|
||||
|
||||
fireEvent.click(within(draftRow!).getByRole("button", { name: "Resume interview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
|
||||
@@ -2302,6 +2313,27 @@ describe("MissionManager", () => {
|
||||
expect(screen.queryByText("Drafts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suppresses the empty mission state when drafts exist without missions", async () => {
|
||||
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([
|
||||
{
|
||||
id: "draft-only",
|
||||
title: "Draft only mission",
|
||||
status: "awaiting_input",
|
||||
projectId: null,
|
||||
createdAt: "2026-05-12T00:00:00.000Z",
|
||||
updatedAt: "2026-05-12T00:05:00.000Z",
|
||||
hasConversation: true,
|
||||
},
|
||||
]);
|
||||
globalThis.fetch = createFetchMockWithHealth([], {});
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("Drafts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Draft only mission")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No missions yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("logs a warning when pending interview session fetch fails", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const pendingFetchError = new Error("Pending sessions failed");
|
||||
|
||||
Reference in New Issue
Block a user