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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user