From 6183621ca373f817928f9ff0ea35860eb00eb493 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 17 Jul 2026 01:12:08 -0700 Subject: [PATCH] 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) --- .../fn-8196-mobile-footer-stat-tooltips.md | 7 + docs/dashboard-guide.md | 2 + .../app/components/ExecutorStatusBar.css | 47 ++++- .../app/components/ExecutorStatusBar.tsx | 166 ++++++++++++++++-- .../__tests__/ExecutorStatusBar.test.tsx | 132 +++++++++++++- 5 files changed, 333 insertions(+), 21 deletions(-) create mode 100644 .changeset/fn-8196-mobile-footer-stat-tooltips.md diff --git a/.changeset/fn-8196-mobile-footer-stat-tooltips.md b/.changeset/fn-8196-mobile-footer-stat-tooltips.md new file mode 100644 index 0000000000..e5dda558a3 --- /dev/null +++ b/.changeset/fn-8196-mobile-footer-stat-tooltips.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f617ecd29d..a43deb6f81 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1370,6 +1370,8 @@ The global AI engine stop/start control and triage pause/resume control live in 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. diff --git a/packages/dashboard/app/components/ExecutorStatusBar.css b/packages/dashboard/app/components/ExecutorStatusBar.css index d30c55fc6b..5b3e06ac44 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.css +++ b/packages/dashboard/app/components/ExecutorStatusBar.css @@ -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); } diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index f1e55b7701..5da86b8d3a 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -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
{children}
; + + const tooltipId = `executor-status-bar-tooltip-${id}`; + return ( + + ); +} + 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(null); const engineControlMenuRef = useRef(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 (
@@ -213,17 +297,29 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleH )} {/* Queued tasks */} -
+
+ {/* Separator */}