From c16411db9afeaf75c0fff006e4ce37bd0955c74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:18:20 +0100 Subject: [PATCH] feat(web): polish mobile status drawer (#1929) * feat(web): polish mobile status drawer * fix(web): preserve mobile metrics row width --- .../app-status-bar/app-status-drawer.tsx | 45 ++++++++++++++++--- .../app-status-bar/connection-status-item.tsx | 13 ++++-- .../status-surface-metrics.test.tsx | 5 ++- .../system-metrics/status-surface-metrics.tsx | 12 +++-- .../plugins/mobile-status-drawer.spec.ts | 32 +++++++++++-- 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/apps/web/components/app-status-bar/app-status-drawer.tsx b/apps/web/components/app-status-bar/app-status-drawer.tsx index 8e0364c4b4..cdd89e9e34 100644 --- a/apps/web/components/app-status-bar/app-status-drawer.tsx +++ b/apps/web/components/app-status-bar/app-status-drawer.tsx @@ -1,7 +1,9 @@ "use client"; import { useMemo } from "react"; +import { IconActivity } from "@tabler/icons-react"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { cn } from "@kandev/ui/lib/utils"; import { useAppStatusItems, type AppStatusItem } from "./app-status-items"; import { useAppStatusBarOrder } from "./use-app-status-bar-order"; @@ -29,19 +31,48 @@ export function AppStatusDrawer({ const activeItems = useAppStatusItems(context); const { projected } = useAppStatusBarOrder(activeItems); const orderedItems = [...projected.left, ...projected.right]; + let drawerHeight = "h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]"; + if (orderedItems.length <= 1) { + drawerHeight = "h-[min(15rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]"; + } else if (orderedItems.length === 2) { + drawerHeight = "h-[min(20rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]"; + } return ( - +
- - Status + +
+
+
+
+

+ Workspace +

+ + Status + +
+
-
-
+
+
{orderedItems.map((item) => ( ))} @@ -56,7 +87,7 @@ export function AppStatusDrawer({ function DrawerStatusItem({ item, open }: { item: AppStatusItem; open: boolean }) { return (
{item.render({ presentation: "mobile-drawer", density: "full", drawerOpen: open })} diff --git a/apps/web/components/app-status-bar/connection-status-item.tsx b/apps/web/components/app-status-bar/connection-status-item.tsx index 27671f40e1..dfa56ee257 100644 --- a/apps/web/components/app-status-bar/connection-status-item.tsx +++ b/apps/web/components/app-status-bar/connection-status-item.tsx @@ -76,9 +76,16 @@ export function ConnectionStatusItem({ presentation }: { presentation: "bar" | " className={`size-1.5 shrink-0 rounded-full ${details.dotClass} ${details.animate ? "animate-pulse" : ""}`} aria-hidden="true" /> - - {presentation === "bar" ? details.label : details.description} - + {presentation === "bar" ? ( + {details.label} + ) : ( + + + Connection + + {details.description} + + )} {details.description} diff --git a/apps/web/components/system-metrics/status-surface-metrics.test.tsx b/apps/web/components/system-metrics/status-surface-metrics.test.tsx index 4099c7baf4..e127b7ab73 100644 --- a/apps/web/components/system-metrics/status-surface-metrics.test.tsx +++ b/apps/web/components/system-metrics/status-surface-metrics.test.tsx @@ -146,7 +146,10 @@ describe("StatusSurfaceMetrics", () => { renderMetrics(true); expect(subscribeMock).toHaveBeenCalledWith(true); - expect(screen.getByLabelText("Host metrics").parentElement?.className).toContain("min-h-11"); + const metricsRow = screen.getByLabelText("Host metrics").parentElement; + expect(metricsRow?.className).toContain("min-h-11"); + expect(metricsRow?.className).toContain("w-full"); + expect(metricsRow?.className).toContain("min-w-0"); }); it("omits the host marker and meters in simplified desktop mode", () => { diff --git a/apps/web/components/system-metrics/status-surface-metrics.tsx b/apps/web/components/system-metrics/status-surface-metrics.tsx index 3474996963..1d66df3666 100644 --- a/apps/web/components/system-metrics/status-surface-metrics.tsx +++ b/apps/web/components/system-metrics/status-surface-metrics.tsx @@ -40,8 +40,14 @@ export function StatusSurfaceMetrics({ const host = snapshot?.sources.find((source) => source.kind === "backend"); if (presentation === "mobile-drawer") { return ( -
-

System metrics

+
+

+ System metrics +

{!host ? ( ) : ( @@ -129,7 +135,7 @@ function DrawerSourceMetrics({ simplified: boolean; }) { return ( -
+
{!simplified ? : null}
diff --git a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts index ecb23ef83a..d3e0eb7365 100644 --- a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts @@ -22,6 +22,24 @@ test.describe("Mobile Status drawer", () => { await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); }); + test("keeps a single status signal compact", async ({ testPage, apiClient }) => { + const settingsResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + system_metrics_display: { show_in_topbar: false }, + }); + expect(settingsResponse.ok).toBe(true); + + await testPage.goto("/stats"); + await testPage.getByTestId("app-status-drawer-trigger").click(); + + const drawer = testPage.getByTestId("app-status-drawer"); + await expect(drawer).toBeVisible(); + await expect(drawer.locator("[data-status-item-id]")).toHaveCount(1); + const dialog = testPage.getByRole("dialog", { name: "Status" }); + const dialogBox = await dialog.boundingBox(); + if (!dialogBox) throw new Error("single-signal Status drawer has no geometry"); + expect(dialogBox.height).toBeLessThan(300); + }); + test("opens native Status paths without a persistent phone footer", async ({ testPage, apiClient, @@ -51,6 +69,10 @@ test.describe("Mobile Status drawer", () => { const drawer = testPage.getByTestId("app-status-drawer"); await expect(drawer).toBeVisible(); + const summary = drawer.getByTestId("app-status-drawer-summary"); + await expect(summary.getByRole("heading", { name: "Status" })).toBeVisible(); + await expect(summary).not.toContainText("Connection, system health, and workspace signals."); + await expect(drawer.getByTestId("app-status-drawer-scroll-region")).toBeVisible(); await expect(drawer.getByTestId("app-status-connection")).toBeVisible(); await expect(testPage.locator("#hello-status-left")).toContainText("mobile-drawer no-task"); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); @@ -94,10 +116,12 @@ test.describe("Mobile Status drawer", () => { expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( await testPage.evaluate(() => document.documentElement.clientWidth), ); - // Chromium may consume Escape immediately after the synthetic modifier drag. - await testPage - .locator('[data-slot="drawer-overlay"][data-state="open"]') - .click({ position: { x: 4, y: 4 } }); + // A vertical modifier drag can resolve as Vaul's native swipe-to-dismiss. + // Either dismissal path must leave phone ordering untouched. + const overlay = testPage.locator('[data-slot="drawer-overlay"][data-state="open"]'); + if (await overlay.isVisible()) { + await overlay.click({ position: { x: 4, y: 4 } }); + } await expect(drawer).toBeHidden(); const task = await apiClient.createTask(seedData.workspaceId, "Mobile status task", {