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:
gsxdsm
2026-07-17 01:12:08 -07:00
parent 7f175b036b
commit 6183621ca3
5 changed files with 333 additions and 21 deletions

View 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.

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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" />

View File

@@ -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} />);