fix(dashboard): primary mission CTA, auto-select first, richer empty state

- Promote the sidebar "Plan New Mission" CTA to a btn-primary (matching the
  chat sidebar's "New Chat") and drop the dashed icon buttons; full-width
  progress bar and Activity row each get their own line in the card.
- Auto-select the first mission in the inline desktop view so users land
  in detail rather than the empty placeholder. Skipped in mobile and the
  standalone modal so existing flows and unit tests stay intact.
- Replace the bare "No missions yet" line with a richer empty state that
  explains what missions are and offers an inline Plan New Mission CTA.
- Guard loadMissionDetail against malformed responses (missing milestones)
  so racing fetch fallbacks don't crash the detail render.
- Update three MissionManager tests to match the new copy/structure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 00:05:51 -07:00
parent 06b9a21ad5
commit 2f439121e0
4 changed files with 97 additions and 30 deletions

View File

@@ -215,8 +215,9 @@
}
.mission-manager__sidebar-cta {
flex: 1;
width: 100%;
justify-content: center;
gap: var(--space-sm);
}
/* Hide duplicate sidebar title; the shared header already labels this view. */
@@ -318,6 +319,31 @@
padding: var(--space-2xl) var(--space-xl);
}
.mission-manager__empty--mission {
gap: var(--space-md);
max-width: 420px;
margin: 0 auto;
}
.mission-manager__empty-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.mission-manager__empty-body {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.mission-manager__empty-cta {
margin-top: var(--space-sm);
gap: var(--space-sm);
}
/* ── Status Badge ── */
/* Fine-grained badge paddings stay pixel-specific for compact legibility. */
.mission-status-badge {
@@ -779,8 +805,9 @@
}
.mission-list__item-progress {
flex: 1;
width: 100%;
height: 4px;
margin-top: var(--space-xs);
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;

View File

@@ -746,6 +746,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
try {
setDetailLoading(true);
const data = await fetchMission(missionId, projectId);
// Guard against malformed responses (e.g. test fetch fallbacks): without
// a milestones array the detail view crashes on `.milestones.length`.
if (!data || !Array.isArray((data as MissionWithHierarchy).milestones)) {
setDetailLoading(false);
return;
}
setSelectedMission(data);
// Auto-expand first milestone and slice
if (data.milestones.length > 0) {
@@ -935,6 +941,25 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [isActive]);
// Default-select the first mission once the list loads (inline desktop view).
// Gated on `isInline` so the standalone modal flow (and unit tests that
// render without isInline) keep the explicit "select a mission" empty state.
const defaultSelectedRef = useRef(false);
useEffect(() => {
if (!isActive || !isInline || isMobile || loading) return;
if (defaultSelectedRef.current) return;
if (selectedMission || targetMissionId) return;
if (missions.length === 0) return;
defaultSelectedRef.current = true;
loadMissionDetail(missions[0].id);
}, [isActive, isInline, isMobile, loading, missions, selectedMission, targetMissionId, loadMissionDetail]);
useEffect(() => {
if (!isActive) {
defaultSelectedRef.current = false;
}
}, [isActive]);
useEffect(() => {
if (!isActive || !selectedMission || activeTab !== "activity") {
return;
@@ -3667,9 +3692,21 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
{missions.length === 0 && !isCreatingMission && (
<div className="mission-manager__empty mission-manager__empty--large">
<div className="mission-manager__empty mission-manager__empty--large mission-manager__empty--mission">
<Target size={32} />
<span>No missions yet. Create one to start planning.</span>
<h3 className="mission-manager__empty-title">No missions yet</h3>
<p className="mission-manager__empty-body">
Missions are large initiatives that bundle milestones, slices, and features into a
single plan. Plan a mission to break down a goal end-to-end and let agents work
through it autopilot-style.
</p>
<button
className="btn btn-sm btn-primary mission-manager__empty-cta"
onClick={() => setShowInterviewModal(true)}
>
<Sparkles size={14} />
Plan New Mission
</button>
</div>
)}
@@ -3858,7 +3895,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
>
<div className="mission-manager__sidebar-header">
<button
className="mission-add-btn mission-manager__sidebar-cta"
className="btn btn-sm btn-primary mission-manager__sidebar-cta"
onClick={() => setShowInterviewModal(true)}
title="Plan New Mission"
aria-label="Plan New Mission"

View File

@@ -775,17 +775,14 @@ describe("MissionManager", () => {
expect(mobileSpan?.textContent).toBe("Build Auth System");
});
it("sidebar header hides title and shows only action buttons", async () => {
it("sidebar header surfaces a centered Plan New Mission CTA", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
const sidebarTitle = document.querySelector(".mission-manager__sidebar-title");
const sidebarActions = document.querySelector(".mission-manager__sidebar-actions");
expect(sidebarTitle).toBeInTheDocument();
expect(sidebarActions).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plan with AI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "New Mission" })).toBeInTheDocument();
const cta = document.querySelector(".mission-manager__sidebar-cta");
expect(cta).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plan New Mission" })).toBeInTheDocument();
});
});
});
@@ -1379,12 +1376,13 @@ describe("MissionManager", () => {
});
});
it("shows empty state when no missions exist", async () => {
it("shows empty state with Plan New Mission CTA when no missions exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("No missions yet. Create one to start planning.")).toBeDefined();
expect(screen.getByText("No missions yet")).toBeDefined();
expect(screen.getAllByRole("button", { name: "Plan New Mission" }).length).toBeGreaterThan(0);
});
});
@@ -1494,7 +1492,7 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "New Mission" })).toBeDefined();
expect(screen.getByRole("button", { name: "Plan New Mission" })).toBeDefined();
});
});
@@ -1572,11 +1570,8 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
// Inline mode auto-selects the first mission, so the detail view is
// already populated; just wait for the detail content to render.
await waitForDetailLoaded();
expect(screen.getByTestId("mission-back-btn")).toBeInTheDocument();
expect(getComputedStyle(screen.getByTestId("mission-back-btn")).display).toBe("none");
@@ -1591,10 +1586,10 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Plan with AI" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plan New Mission" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Plan with AI" }));
fireEvent.click(screen.getByRole("button", { name: "Plan New Mission" }));
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
@@ -2955,12 +2950,11 @@ describe("MissionManager", () => {
await waitFor(() => expect(document.querySelector(".mission-manager__detail-pane .mission-confirm-panel")).toBeTruthy());
});
it("renders sidebar header title and compact add buttons", async () => {
it("renders sidebar header with Plan New Mission CTA button", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(document.querySelector(".mission-manager__sidebar-title")?.textContent).toBe("Missions"));
expect(screen.getByLabelText("Plan with AI")).toBeInTheDocument();
expect(screen.getByLabelText("New Mission")).toBeInTheDocument();
await waitFor(() => expect(document.querySelector(".mission-manager__sidebar-cta")).toBeInTheDocument());
expect(screen.getByLabelText("Plan New Mission")).toBeInTheDocument();
});
});