feat(dashboard): consistent ViewHeader on Skills/Mailbox/Agents; title metric on Missions

- Skills, Mailbox, Agents migrated to the shared ViewHeader (existing controls passed through actions; Skills icon -> Zap to match sidebar).
- Missions keeps its integrated header but adopts the shared 1.125rem title + size-20 icon.
- ChatView intentionally left as-is (height-sensitive two-pane; documented with FNXC).
- Tests updated for the new header markup (606 pass across the view suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 02:48:04 -07:00
parent b45df4c83b
commit e467394dc9
8 changed files with 118 additions and 87 deletions

View File

@@ -9,6 +9,7 @@ import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun,
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
import { AgentsOverviewBar } from "./AgentsOverviewBar";
import { ViewHeader } from "./ViewHeader";
import { AgentEmptyState } from "./AgentEmptyState";
import { useAgents } from "../hooks/useAgents";
import { useConfirm } from "../hooks/useConfirm";
@@ -1227,11 +1228,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
return (
<div className="agents-view">
<div className="agents-view-header">
<div className="agents-view-title">
<Bot size={24} />
<h2>{t("agents.title", "Agents")}</h2>
</div>
{/*
FNXC:Navigation 2026-06-22-01:10:
Agents adopts the shared ViewHeader (Command Center-modeled) title row for cross-view consistency. The deeply-integrated controls (view-toggle, controls popup, refresh, import, new-agent) keep working by passing the existing agents-view-controls cluster through the header actions prop. The agents-view-controls / agents-view-primary-actions class names are preserved so existing scoped CSS (incl. mobile rules covered by the CSS string-match test) still applies.
*/}
<ViewHeader
icon={Bot}
title={t("agents.title", "Agents")}
actions={
<div className="agents-view-controls">
<div className="view-toggle">
<button
@@ -1480,7 +1484,8 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
)}
</div>
</div>
</div>
}
/>
<NewAgentDialog
isOpen={isCreating}

View File

@@ -3182,6 +3182,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
const sidebarInlineStyle: React.CSSProperties | undefined = isMobile ? undefined : { width: `${sidebarWidth}px` };
return (
/*
FNXC:Navigation 2026-06-22-01:10:
Chat deliberately does NOT adopt the shared ViewHeader. Unlike the other main-content views, .chat-view has no view-level title row to swap: its root is a height-sensitive two-pane flex-row (sidebar | thread) and each pane already owns its own header (chat-thread-header / chat-sidebar). The thread height is driven by flex:1 + the mobile-keyboard drift compensation applied directly to .chat-thread, so prepending a full-width column header would change the flex/height contract that those keyboard/scroll/resize paths depend on. Stacking a redundant "Chat" title above the existing thread header would also be visually duplicative. Consistency is therefore intentionally scoped to the views that have a single title row.
*/
<div className="chat-view">
{/* Sidebar */}
<div

View File

@@ -39,6 +39,7 @@ import {
} from "../api";
import { MailboxMessageContent } from "./MailboxMessageContent";
import { MessageComposer } from "./MessageComposer";
import { ViewHeader } from "./ViewHeader";
import { WorktrunkInstallApprovalDetails } from "./WorktrunkInstallApprovalDetails";
import { subscribeSse } from "../sse-bus";
import { useViewportMode } from "../hooks/useViewportMode";
@@ -1189,55 +1190,58 @@ export function MailboxView({
return (
<div className="mailbox-view" style={containerKeyboardStyle} data-testid="mailbox-view">
{/* Header */}
<div className="mailbox-header">
<div className="mailbox-title">
<Mail size={18} />
<span>{t("mailbox.title", "Mailbox")}</span>
{unreadCount > 0 && (
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
{unreadCount}
</span>
)}
</div>
<div className="mailbox-header-actions">
<button
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title={t("mailbox.composeMessageTitle", "Compose message")}
data-testid="mailbox-header-compose"
>
<MessageSquare size={14} />
<span>{t("mailbox.compose", "Compose")}</span>
</button>
{activeTab === "inbox" && unreadCount > 0 && (
{/*
FNXC:Navigation 2026-06-22-01:10:
Mailbox adopts the shared ViewHeader (Command Center-modeled) for a consistent main-content title row. The unread count badge stays beside the title (preserving the mailbox-unread-badge test id), and Compose / Mark-all-read / Refresh controls move into the header actions cluster so they keep working. Tabs remain below the header as their own row.
*/}
<ViewHeader
icon={Mail}
title={t("mailbox.title", "Mailbox")}
actions={
<>
{unreadCount > 0 && (
<span className="mailbox-unread-badge" data-testid="mailbox-unread-badge">
{unreadCount}
</span>
)}
<button
className="btn btn-sm btn-secondary"
onClick={handleMarkAllRead}
title={t("mailbox.markAllReadTitle", "Mark all as read")}
data-testid="mailbox-mark-all-read"
className="btn btn-sm btn-primary"
onClick={handleOpenCompose}
title={t("mailbox.composeMessageTitle", "Compose message")}
data-testid="mailbox-header-compose"
>
<CheckCheck size={14} />
<span>{t("mailbox.markAllRead", "Mark all read")}</span>
<MessageSquare size={14} />
<span>{t("mailbox.compose", "Compose")}</span>
</button>
)}
<button
className="btn-icon"
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
title={t("mailbox.refreshTitle", "Refresh")}
data-testid="mailbox-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
</div>
</div>
{activeTab === "inbox" && unreadCount > 0 && (
<button
className="btn btn-sm btn-secondary"
onClick={handleMarkAllRead}
title={t("mailbox.markAllReadTitle", "Mark all as read")}
data-testid="mailbox-mark-all-read"
>
<CheckCheck size={14} />
<span>{t("mailbox.markAllRead", "Mark all read")}</span>
</button>
)}
<button
className="btn-icon"
onClick={() => {
if (activeTab === "inbox") loadInbox();
else if (activeTab === "outbox") loadOutbox();
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
else if (selectedAgentId === ALL_AGENTS_MAILBOX_ID) loadAllAgentsMailbox();
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
}}
disabled={isLoading}
title={t("mailbox.refreshTitle", "Refresh")}
data-testid="mailbox-refresh"
>
{isLoading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
</button>
</>
}
/>
{/* Tabs */}
<div className="mailbox-tabs" data-testid="mailbox-tabs">

View File

@@ -82,8 +82,12 @@
gap: var(--space-sm);
}
/*
FNXC:Navigation 2026-06-22-01:10:
Title metric matches the shared ViewHeader (1.125rem) so the Missions header reads consistently with Command Center and the other normalized main-content views.
*/
.mission-manager__title {
font-size: var(--space-lg);
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: var(--text);

View File

@@ -4815,6 +4815,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
aria-label={isInline ? undefined : t("missions.missionManagerAriaLabel", "Mission Manager")}
data-testid="mission-manager-dialog"
>
{/*
FNXC:Navigation 2026-06-22-01:10:
Missions keeps its own header element (not the shared ViewHeader component) because it owns a dynamic mobile title (mission title when one is selected), a back button for stacked list->detail nav, an inline-vs-modal padding variant, and the mission-header-title test id. To stay visually consistent with the Command Center-modeled ViewHeader, the title uses the same icon size (20) and 1.125rem title metric via .mission-manager__title.
*/}
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
<div className="mission-manager__header-title">
{selectedMission && (
@@ -4828,7 +4832,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<ChevronLeft size={18} />
</button>
)}
<Target size={18} className="mission-manager__header-icon" />
<Target size={20} className="mission-manager__header-icon" />
<h2 className="mission-manager__title" data-testid="mission-header-title">
<span className="mission-manager__title-text mission-manager__title-text--desktop">{t("missions.title", "Missions")}</span>
<span className="mission-manager__title-text mission-manager__title-text--mobile">

View File

@@ -1,7 +1,8 @@
import "./SkillsView.css";
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { Wrench, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react";
import { Zap, RefreshCw, X, ChevronRight, ChevronDown, AlertCircle, Loader2 } from "lucide-react";
import { ViewHeader } from "./ViewHeader";
import {
fetchDiscoveredSkills,
toggleExecutionSkill,
@@ -233,34 +234,34 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
return (
<div className="skills-view" data-testid="skills-view">
{/* Header */}
<div className="skills-view-header">
<div className="skills-view-title">
<h2>
<Wrench size={20} />
{t("skills.title", "Skills")}
</h2>
<span className="skills-view-count" aria-label={t("skills.discoveredCount", "{{count}} discovered skills", { count: discoveredSkills.length })}>{discoveredSkills.length} {t("skills.discovered", "discovered")}</span>
</div>
<div className="skills-view-actions">
<button
className="btn-icon skills-view-close touch-target"
onClick={onClose}
aria-label={t("skills.closeView", "Close skills view")}
>
<X size={16} />
</button>
<button
className="btn btn-sm touch-target"
onClick={() => void loadDiscoveredSkills()}
disabled={isLoadingDiscovered}
>
<RefreshCw size={14} className={isLoadingDiscovered ? "spin" : ""} />
{t("common.refresh", "Refresh")}
</button>
</div>
</div>
{/*
FNXC:Navigation 2026-06-22-01:10:
Skills adopts the shared ViewHeader (Command Center-modeled) for a consistent main-content title row. Icon matches the left-sidebar nav (Zap). The discovered-count badge plus Close and Refresh controls move into the header actions cluster so they keep working.
*/}
<ViewHeader
icon={Zap}
title={t("skills.title", "Skills")}
actions={
<>
<span className="skills-view-count" aria-label={t("skills.discoveredCount", "{{count}} discovered skills", { count: discoveredSkills.length })}>{discoveredSkills.length} {t("skills.discovered", "discovered")}</span>
<button
className="btn-icon skills-view-close touch-target"
onClick={onClose}
aria-label={t("skills.closeView", "Close skills view")}
>
<X size={16} />
</button>
<button
className="btn btn-sm touch-target"
onClick={() => void loadDiscoveredSkills()}
disabled={isLoadingDiscovered}
>
<RefreshCw size={14} className={isLoadingDiscovered ? "spin" : ""} />
{t("common.refresh", "Refresh")}
</button>
</>
}
/>
{/* Scrollable content area */}
<div className="skills-view-content">

View File

@@ -2013,9 +2013,12 @@ describe("MailboxView", () => {
// Verify root element with data-testid
expect(screen.getByTestId("mailbox-view")).toBeDefined();
// Verify header
const header = container.querySelector(".mailbox-header");
// FNXC:Navigation 2026-06-22-01:10: MailboxView migrated its bespoke
// .mailbox-header to the shared ViewHeader (.view-header) modeled after
// Command Center; assert the shared header element with the Mailbox title.
const header = container.querySelector(".view-header");
expect(header).toBeTruthy();
expect(header?.querySelector(".view-header__title")?.textContent).toContain("Mailbox");
// Verify tabs
const tabs = container.querySelector(".mailbox-tabs");

View File

@@ -565,11 +565,17 @@ describe("SkillsView", () => {
});
});
it("renders .skills-view-header and .skills-view-content sections", async () => {
it("renders the shared ViewHeader and .skills-view-content sections", async () => {
// FNXC:Navigation 2026-06-22-01:10: SkillsView migrated its bespoke
// .skills-view-header to the shared ViewHeader (.view-header) modeled
// after Command Center; assert the shared header element and that the
// content sections still render below it.
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelector(".skills-view-header")).toBeTruthy();
const header = document.querySelector(".view-header");
expect(header).toBeTruthy();
expect(header?.querySelector(".view-header__title")?.textContent).toContain("Skills");
expect(document.querySelector(".skills-view-section")).toBeTruthy();
});
});