FN-8196: add mobile footer stat tooltips
Make compact mobile executor footer statistics identifiable on tap. - Add accessible tap targets and portaled tooltips for mobile footer statistics. - Dismiss stat tooltips on repeat tap, outside interaction, Escape, scrolling, and viewport changes. - Cover mobile behavior while preserving desktop and tablet inline labels. - Document the interaction and add a patch changeset. Files changed: .changeset/fn-8196-mobile-footer-stat-tooltips.md | 7 + docs/dashboard-guide.md | 2 + .../dashboard/app/components/ExecutorStatusBar.css | 47 +++++- .../dashboard/app/components/ExecutorStatusBar.tsx | 166 +++++++++++++++++++-- .../__tests__/ExecutorStatusBar.test.tsx | 132 +++++++++++++++- 5 files changed, 333 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-8196 Fusion-Task-Lineage: c566b614-739d-4538-85c5-a8ff62720f4d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8196-mobile-footer-stat-tooltips.md
Normal file
7
.changeset/fn-8196-mobile-footer-stat-tooltips.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Add tap-to-reveal names for mobile executor footer stats.
|
||||
category: feature
|
||||
dev: Portals mobile stat tooltips beyond footer clipping while preserving desktop labels.
|
||||
@@ -1370,6 +1370,8 @@ The global AI engine stop/start control and triage pause/resume control live in
|
||||
<!-- FNXC:ExecutorStatusBar 2026-06-27-00:00: FN-7163 makes footer stats loading initial-only so routine heartbeat refreshes keep the populated footer and open concurrency popover mounted instead of blinking to the loading branch. -->
|
||||
Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. Routine executor stats heartbeats also keep the populated footer mounted after initial load, so an open engine/concurrency popover stays open while counts refresh. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures.
|
||||
|
||||
On mobile, the compact footer hides stat names to preserve space. Tap a colored stat dot and count to show its name; tap it again, tap elsewhere, press Escape, or scroll to dismiss the tooltip. Desktop and tablet continue to show stat names inline.
|
||||
|
||||
### Engine status banner
|
||||
|
||||
When a project dashboard is open but no project engine is connected, Fusion shows a sticky **Engine disconnected** banner above the project content. This covers paused projects, failed or still-starting project engines, delayed reconciliation, and dashboard-only/dev launches where the UI is available before an engine manager is attached.
|
||||
|
||||
@@ -186,6 +186,49 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Mobile mode is component-owned so short landscape phones hide labels and
|
||||
expose tap targets under the same viewport contract. */
|
||||
.executor-status-bar--mobile .executor-status-bar__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.executor-status-bar__stat-tooltip {
|
||||
position: fixed;
|
||||
z-index: var(--z-popover, 60);
|
||||
transform: translate(-50%, calc(-100% - var(--space-sm)));
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
color: var(--text);
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.executor-status-bar__segment--stat {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.executor-status-bar--mobile .executor-status-bar__segment--stat {
|
||||
min-width: var(--executor-status-touch-size, calc(var(--space-lg) * 2 + var(--space-xs)));
|
||||
min-height: var(--executor-status-touch-size, calc(var(--space-lg) * 2 + var(--space-xs)));
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.executor-status-bar__segment--stat:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Max concurrent display */
|
||||
.executor-status-bar__max {
|
||||
font-family: var(--font-mono);
|
||||
@@ -416,10 +459,6 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.executor-status-bar__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.executor-status-bar__project-path {
|
||||
max-width: min(26ch, 30vw);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./ExecutorStatusBar.css";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -18,6 +19,44 @@ import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControl
|
||||
import { TerminalLauncher } from "./TerminalLauncher";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
|
||||
type FooterStatId = "queued" | "running" | "stuck" | "blocked" | "review" | "fanout";
|
||||
|
||||
interface OpenStatTooltip {
|
||||
id: FooterStatId;
|
||||
label: string;
|
||||
rect: DOMRect;
|
||||
}
|
||||
|
||||
interface MobileStatSegmentProps {
|
||||
className?: string;
|
||||
id: FooterStatId;
|
||||
isMobile: boolean;
|
||||
isOpen: boolean;
|
||||
label: string;
|
||||
onToggle: (id: FooterStatId, label: string, rect: DOMRect) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MobileStatSegment({ className = "", id, isMobile, isOpen, label, onToggle, children }: MobileStatSegmentProps) {
|
||||
const segmentClassName = `executor-status-bar__segment executor-status-bar__segment--stat ${className}`.trim();
|
||||
if (!isMobile) return <div className={segmentClassName}>{children}</div>;
|
||||
|
||||
const tooltipId = `executor-status-bar-tooltip-${id}`;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={segmentClassName}
|
||||
onClick={(event) => onToggle(id, label, event.currentTarget.getBoundingClientRect())}
|
||||
aria-label={label}
|
||||
aria-expanded={isOpen}
|
||||
aria-describedby={isOpen ? tooltipId : undefined}
|
||||
data-testid={`executor-stat-${id}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExecutorStatusBarProps {
|
||||
/** Task list (shared with the board to keep counts in sync) */
|
||||
tasks: Task[];
|
||||
@@ -112,14 +151,16 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st
|
||||
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const viewportMode = useViewportMode();
|
||||
const showTerminalLauncher = viewportMode !== "mobile" && Boolean(onToggleTerminal);
|
||||
const isMobile = viewportMode === "mobile";
|
||||
const showTerminalLauncher = !isMobile && Boolean(onToggleTerminal);
|
||||
/*
|
||||
* FNXC:ChatLauncher 2026-06-22-15:18:
|
||||
* Settings can route Quick Chat to a footer launcher beside Terminal, keep the draggable floating FAB, or hide the launcher entirely. Footer launch stays desktop/tablet-only like Terminal while mobile opens from the floating path as a full-screen modal.
|
||||
*/
|
||||
const showQuickChatFooterLauncher = viewportMode !== "mobile" && quickChatButtonMode === "footer" && Boolean(onOpenQuickChat);
|
||||
const showQuickChatFooterLauncher = !isMobile && quickChatButtonMode === "footer" && Boolean(onOpenQuickChat);
|
||||
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
||||
const [openStatTooltip, setOpenStatTooltip] = useState<OpenStatTooltip | null>(null);
|
||||
const engineControlMenuRef = useRef<EngineControlMenuHandle>(null);
|
||||
const hasRenderedPopulatedStatsRef = useRef(false);
|
||||
|
||||
@@ -129,6 +170,49 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
}
|
||||
}, [error, loading]);
|
||||
|
||||
/*
|
||||
* FNXC:MobileFooter 2026-07-16-00:00:
|
||||
* Mobile hides footer stat labels, so tapping a stat must reveal its name. The
|
||||
* executor-status-bar--mobile class and this interaction share isMobile so
|
||||
* short-landscape phones cannot expose both labels and tap controls. The
|
||||
* popover is portaled below to escape the footer's overflow clipping.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMobile || !openStatTooltip) return;
|
||||
|
||||
const dismissTooltip = () => setOpenStatTooltip(null);
|
||||
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||
if (event.target instanceof Element && event.target.closest(".executor-status-bar__segment--stat")) return;
|
||||
dismissTooltip();
|
||||
};
|
||||
const dismissOnFocus = (event: FocusEvent) => {
|
||||
if (event.target instanceof Element && event.target.closest(".executor-status-bar__segment--stat")) return;
|
||||
dismissTooltip();
|
||||
};
|
||||
const dismissOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") dismissTooltip();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", dismissOnPointerDown);
|
||||
document.addEventListener("focusin", dismissOnFocus);
|
||||
document.addEventListener("keydown", dismissOnEscape);
|
||||
window.addEventListener("scroll", dismissTooltip, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismissOnPointerDown);
|
||||
document.removeEventListener("focusin", dismissOnFocus);
|
||||
document.removeEventListener("keydown", dismissOnEscape);
|
||||
window.removeEventListener("scroll", dismissTooltip, true);
|
||||
};
|
||||
}, [isMobile, openStatTooltip]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) setOpenStatTooltip(null);
|
||||
}, [isMobile]);
|
||||
|
||||
const toggleStatTooltip = (id: FooterStatId, label: string, rect: DOMRect) => {
|
||||
setOpenStatTooltip((current) => current?.id === id ? null : { id, label, rect });
|
||||
};
|
||||
|
||||
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]);
|
||||
|
||||
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]);
|
||||
@@ -194,7 +278,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`executor-status-bar ${stats.executorState === "running" ? "executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
|
||||
className={`executor-status-bar${isMobile ? " executor-status-bar--mobile" : ""}${stats.executorState === "running" ? " executor-status-bar--running" : ""}${keyboardOpen ? " executor-status-bar--keyboard-open" : ""}`}
|
||||
role="status"
|
||||
aria-label={t("executor.status", "Executor status")}
|
||||
>
|
||||
@@ -213,17 +297,29 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
)}
|
||||
|
||||
{/* Queued tasks */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<MobileStatSegment
|
||||
id="queued"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "queued"}
|
||||
label={t("executor.queued", "Queued")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--queued" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">{t("executor.queued", "Queued")}</span>
|
||||
<span className="executor-status-bar__count">{stats.queuedTaskCount}</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
|
||||
{/* Separator */}
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
|
||||
{/* Running tasks */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<MobileStatSegment
|
||||
id="running"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "running"}
|
||||
label={t("executor.running", "Running")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span
|
||||
className={`executor-status-bar__indicator executor-status-bar__indicator--running ${stats.runningTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
@@ -232,7 +328,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<span className="executor-status-bar__count">{stats.runningTaskCount}</span>
|
||||
<span className="executor-status-bar__separator" aria-hidden="true">/</span>
|
||||
<span className="executor-status-bar__max">{stats.maxConcurrent}</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
|
||||
{/* Separator */}
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
@@ -240,17 +336,30 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
{/* Stuck tasks */}
|
||||
{stats.stuckTaskCount > 0 && (
|
||||
<>
|
||||
<div className="executor-status-bar__segment executor-status-bar__segment--stuck">
|
||||
<MobileStatSegment
|
||||
className="executor-status-bar__segment--stuck"
|
||||
id="stuck"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "stuck"}
|
||||
label={t("executor.stuck", "Stuck")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--stuck executor-status-bar__indicator--active" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">{t("executor.stuck", "Stuck")}</span>
|
||||
<span className="executor-status-bar__count executor-status-bar__count--error">{stats.stuckTaskCount}</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Blocked tasks */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<MobileStatSegment
|
||||
id="blocked"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "blocked"}
|
||||
label={t("executor.blocked", "Blocked")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span
|
||||
className={`executor-status-bar__indicator executor-status-bar__indicator--blocked ${stats.blockedTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||
aria-hidden="true"
|
||||
@@ -259,22 +368,35 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
<span className={`executor-status-bar__count ${stats.blockedTaskCount > 0 ? "executor-status-bar__count--warning" : ""}`}>
|
||||
{stats.blockedTaskCount}
|
||||
</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
|
||||
{/* Separator */}
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
|
||||
{/* In review count */}
|
||||
<div className="executor-status-bar__segment">
|
||||
<MobileStatSegment
|
||||
id="review"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "review"}
|
||||
label={t("executor.inReview", "In Review")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--review" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">{t("executor.inReview", "In Review")}</span>
|
||||
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
|
||||
{highestOverlapBlocker && (
|
||||
<>
|
||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
|
||||
<MobileStatSegment
|
||||
className="executor-status-bar__segment--fanout"
|
||||
id="fanout"
|
||||
isMobile={isMobile}
|
||||
isOpen={openStatTooltip?.id === "fanout"}
|
||||
label={t("executor.overlapQueue", "Overlap queue")}
|
||||
onToggle={toggleStatTooltip}
|
||||
>
|
||||
<span className="executor-status-bar__indicator executor-status-bar__indicator--fanout executor-status-bar__indicator--active" aria-hidden="true" />
|
||||
<span className="executor-status-bar__label">{t("executor.overlapQueue", "Overlap queue")}</span>
|
||||
<span
|
||||
@@ -289,7 +411,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
>
|
||||
{t("executor.overlapSummary", "{{blockerId}} · {{count}} todo", { blockerId: highestOverlapBlocker.blockerId, count: highestOverlapBlocker.entry.overlapBlockedTodoCount })}{highestOverlapBlocker.entry.escalation ? t("executor.escalatedSuffix", " (escalated)") : ""}
|
||||
</span>
|
||||
</div>
|
||||
</MobileStatSegment>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -321,6 +443,18 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
||||
</>
|
||||
)}
|
||||
|
||||
{openStatTooltip && typeof document !== "undefined" && createPortal(
|
||||
<div
|
||||
id={`executor-status-bar-tooltip-${openStatTooltip.id}`}
|
||||
className="executor-status-bar__stat-tooltip"
|
||||
role="tooltip"
|
||||
style={{ left: openStatTooltip.rect.left + openStatTooltip.rect.width / 2, top: openStatTooltip.rect.top }}
|
||||
>
|
||||
{openStatTooltip.label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="executor-status-bar__spacer" />
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -475,6 +475,136 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile stat tooltips", () => {
|
||||
const mobileStatIds = ["queued", "running", "blocked", "review"] as const;
|
||||
|
||||
beforeEach(() => {
|
||||
viewportModeMock.value = "mobile";
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "Queued"],
|
||||
["running", "Running"],
|
||||
["blocked", "Blocked"],
|
||||
["review", "In Review"],
|
||||
] as const)("reveals the %s stat name on tap", async (id, label) => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
await user.click(screen.getByTestId(`executor-stat-${id}`));
|
||||
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(label);
|
||||
expect(screen.getByTestId(`executor-stat-${id}`)).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("uses the mobile-mode class and tap targets for mobile including short landscape", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveClass("executor-status-bar--mobile");
|
||||
mobileStatIds.forEach((id) => expect(screen.getByTestId(`executor-stat-${id}`)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("dismisses the tooltip on a second tap, outside tap, Escape, and scroll", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
const queued = screen.getByTestId("executor-stat-queued");
|
||||
|
||||
await user.click(queued);
|
||||
expect(screen.getByRole("tooltip")).toBeInTheDocument();
|
||||
await user.click(queued);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
await user.click(queued);
|
||||
await user.click(document.body);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
await user.click(queued);
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
await user.click(queued);
|
||||
act(() => window.dispatchEvent(new Event("scroll")));
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("portals the fixed tooltip outside the clipped footer", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
const statusBar = screen.getByRole("status");
|
||||
|
||||
await user.click(screen.getByTestId("executor-stat-queued"));
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(statusBar.contains(tooltip)).toBe(false);
|
||||
const tooltipRule = getCssRuleBlock(executorStatusBarCss, ".executor-status-bar__stat-tooltip");
|
||||
expect(tooltipRule).toContain("position: fixed");
|
||||
expect(tooltipRule).toContain("z-index: var(--z-popover, 60)");
|
||||
expectNoHardcodedColors(tooltipRule);
|
||||
});
|
||||
|
||||
it("adds stat tooltips only for conditional segments that exist", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
expect(screen.queryByTestId("executor-stat-stuck")).toBeNull();
|
||||
expect(screen.queryByTestId("executor-stat-fanout")).toBeNull();
|
||||
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: { ...defaultStats, stuckTaskCount: 1 },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
const fanoutTasks = [
|
||||
makeTask("FN-010", "in-progress"),
|
||||
makeTask("FN-101", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-102", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-103", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-104", "todo", { blockedBy: "FN-010" }),
|
||||
makeTask("FN-105", "todo", { blockedBy: "FN-010" }),
|
||||
];
|
||||
rerender(<ExecutorStatusBar tasks={fanoutTasks} />);
|
||||
|
||||
await user.click(screen.getByTestId("executor-stat-stuck"));
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Stuck");
|
||||
await user.click(screen.getByTestId("executor-stat-fanout"));
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent("Overlap queue");
|
||||
});
|
||||
|
||||
it("keeps desktop and tablet labels inline without mobile tap controls", () => {
|
||||
viewportModeMock.value = "desktop";
|
||||
const { rerender } = render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const desktopStatus = screen.getByRole("status");
|
||||
expect(desktopStatus).not.toHaveClass("executor-status-bar--mobile");
|
||||
expect(getSegmentByLabel("Queued").tagName).toBe("DIV");
|
||||
expect(desktopStatus.querySelectorAll("button.executor-status-bar__segment--stat")).toHaveLength(0);
|
||||
|
||||
viewportModeMock.value = "tablet";
|
||||
rerender(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
expect(screen.getByRole("status")).not.toHaveClass("executor-status-bar--mobile");
|
||||
expect(screen.getByRole("status").querySelectorAll("button.executor-status-bar__segment--stat")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the label-hiding and tooltip styles tied to the mobile modifier", () => {
|
||||
const labelRule = getCssRuleBlock(executorStatusBarCss, ".executor-status-bar--mobile .executor-status-bar__label");
|
||||
expect(labelRule).toContain("display: none");
|
||||
expect(executorStatusBarCss).not.toMatch(/@media \(max-width: 768px\)[\s\S]*?\.executor-status-bar__label\s*\{\s*display:\s*none/);
|
||||
});
|
||||
|
||||
it("renders zero-count mobile stats without error", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: { ...defaultStats, queuedTaskCount: 0, runningTaskCount: 0, blockedTaskCount: 0, inReviewCount: 0 },
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
mobileStatIds.forEach((id) => expect(screen.getByTestId(`executor-stat-${id}`)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe("executor state", () => {
|
||||
it("shows Running state with running executorState", () => {
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
Reference in New Issue
Block a user