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. -->
|
<!-- 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.
|
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
|
### 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.
|
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;
|
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 */
|
/* Max concurrent display */
|
||||||
.executor-status-bar__max {
|
.executor-status-bar__max {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -416,10 +459,6 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.executor-status-bar__label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.executor-status-bar__project-path {
|
.executor-status-bar__project-path {
|
||||||
max-width: min(26ch, 30vw);
|
max-width: min(26ch, 30vw);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "./ExecutorStatusBar.css";
|
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 type { TFunction } from "i18next";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,44 @@ import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControl
|
|||||||
import { TerminalLauncher } from "./TerminalLauncher";
|
import { TerminalLauncher } from "./TerminalLauncher";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
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 {
|
interface ExecutorStatusBarProps {
|
||||||
/** Task list (shared with the board to keep counts in sync) */
|
/** Task list (shared with the board to keep counts in sync) */
|
||||||
tasks: Task[];
|
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) {
|
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 { t } = useTranslation("app");
|
||||||
const viewportMode = useViewportMode();
|
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:
|
* 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.
|
* 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 { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||||
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
||||||
|
const [openStatTooltip, setOpenStatTooltip] = useState<OpenStatTooltip | null>(null);
|
||||||
const engineControlMenuRef = useRef<EngineControlMenuHandle>(null);
|
const engineControlMenuRef = useRef<EngineControlMenuHandle>(null);
|
||||||
const hasRenderedPopulatedStatsRef = useRef(false);
|
const hasRenderedPopulatedStatsRef = useRef(false);
|
||||||
|
|
||||||
@@ -129,6 +170,49 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
|||||||
}
|
}
|
||||||
}, [error, loading]);
|
}, [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 stateDisplay = useMemo(() => getStateDisplay(stats.executorState, t), [stats.executorState, t]);
|
||||||
|
|
||||||
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]);
|
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt, t), [stats.lastActivityAt, t]);
|
||||||
@@ -194,7 +278,7 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
role="status"
|
||||||
aria-label={t("executor.status", "Executor status")}
|
aria-label={t("executor.status", "Executor status")}
|
||||||
>
|
>
|
||||||
@@ -213,17 +297,29 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Queued tasks */}
|
{/* 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__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__label">{t("executor.queued", "Queued")}</span>
|
||||||
<span className="executor-status-bar__count">{stats.queuedTaskCount}</span>
|
<span className="executor-status-bar__count">{stats.queuedTaskCount}</span>
|
||||||
</div>
|
</MobileStatSegment>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||||
|
|
||||||
{/* Running tasks */}
|
{/* 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
|
<span
|
||||||
className={`executor-status-bar__indicator executor-status-bar__indicator--running ${stats.runningTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
className={`executor-status-bar__indicator executor-status-bar__indicator--running ${stats.runningTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||||
aria-hidden="true"
|
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__count">{stats.runningTaskCount}</span>
|
||||||
<span className="executor-status-bar__separator" aria-hidden="true">/</span>
|
<span className="executor-status-bar__separator" aria-hidden="true">/</span>
|
||||||
<span className="executor-status-bar__max">{stats.maxConcurrent}</span>
|
<span className="executor-status-bar__max">{stats.maxConcurrent}</span>
|
||||||
</div>
|
</MobileStatSegment>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||||
@@ -240,17 +336,30 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH
|
|||||||
{/* Stuck tasks */}
|
{/* Stuck tasks */}
|
||||||
{stats.stuckTaskCount > 0 && (
|
{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__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__label">{t("executor.stuck", "Stuck")}</span>
|
||||||
<span className="executor-status-bar__count executor-status-bar__count--error">{stats.stuckTaskCount}</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" />
|
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Blocked tasks */}
|
{/* 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
|
<span
|
||||||
className={`executor-status-bar__indicator executor-status-bar__indicator--blocked ${stats.blockedTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
className={`executor-status-bar__indicator executor-status-bar__indicator--blocked ${stats.blockedTaskCount > 0 ? "executor-status-bar__indicator--active" : ""}`}
|
||||||
aria-hidden="true"
|
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" : ""}`}>
|
<span className={`executor-status-bar__count ${stats.blockedTaskCount > 0 ? "executor-status-bar__count--warning" : ""}`}>
|
||||||
{stats.blockedTaskCount}
|
{stats.blockedTaskCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</MobileStatSegment>
|
||||||
|
|
||||||
{/* Separator */}
|
{/* Separator */}
|
||||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||||
|
|
||||||
{/* In review count */}
|
{/* 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__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__label">{t("executor.inReview", "In Review")}</span>
|
||||||
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
||||||
</div>
|
</MobileStatSegment>
|
||||||
|
|
||||||
{highestOverlapBlocker && (
|
{highestOverlapBlocker && (
|
||||||
<>
|
<>
|
||||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
<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__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 className="executor-status-bar__label">{t("executor.overlapQueue", "Overlap queue")}</span>
|
||||||
<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)") : ""}
|
{t("executor.overlapSummary", "{{blockerId}} · {{count}} todo", { blockerId: highestOverlapBlocker.blockerId, count: highestOverlapBlocker.entry.overlapBlockedTodoCount })}{highestOverlapBlocker.entry.escalation ? t("executor.escalatedSuffix", " (escalated)") : ""}
|
||||||
</span>
|
</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 */}
|
{/* Spacer */}
|
||||||
<div className="executor-status-bar__spacer" />
|
<div className="executor-status-bar__spacer" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 userEvent from "@testing-library/user-event";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
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", () => {
|
describe("executor state", () => {
|
||||||
it("shows Running state with running executorState", () => {
|
it("shows Running state with running executorState", () => {
|
||||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||||
|
|||||||
Reference in New Issue
Block a user