feat(web): polish mobile status drawer (#1929)

* feat(web): polish mobile status drawer

* fix(web): preserve mobile metrics row width
This commit is contained in:
José Almeida
2026-07-27 10:18:20 +01:00
committed by GitHub
parent 2850086db0
commit c16411db9a
5 changed files with 89 additions and 18 deletions
@@ -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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[min(32rem,calc(100dvh-16px))] max-h-[calc(100dvh-16px)] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<DrawerContent
className={cn(
drawerHeight,
"max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] overflow-hidden pb-[max(0.75rem,env(safe-area-inset-bottom))] before:rounded-[20px]",
)}
>
<div
data-testid="app-status-drawer"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-card text-card-foreground antialiased [font-family:var(--font-geist-sans)] shadow-[0_1px_2px_rgb(0_0_0_/_0.04),0_0_0_1px_rgb(0_0_0_/_0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255_/_0.08)]"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>Status</DrawerTitle>
<DrawerHeader
data-testid="app-status-drawer-summary"
className="shrink-0 border-b border-border/80 px-5 pb-4 pt-3 text-left"
>
<div className="flex items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary shadow-[0_1px_2px_rgb(0_0_0_/_0.04)]">
<IconActivity className="size-4" stroke={1.8} aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Workspace
</p>
<DrawerTitle className="text-base font-semibold tracking-[-0.015em] text-balance">
Status
</DrawerTitle>
</div>
</div>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
<section className="space-y-1" aria-label="Application status items">
<div
data-testid="app-status-drawer-scroll-region"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-3"
>
<section className="space-y-2" aria-label="Application status items">
{orderedItems.map((item) => (
<DrawerStatusItem key={item.id} item={item} open={open} />
))}
@@ -56,7 +87,7 @@ export function AppStatusDrawer({
function DrawerStatusItem({ item, open }: { item: AppStatusItem; open: boolean }) {
return (
<div
className="flex min-h-11 w-full min-w-0 items-center rounded-md px-3 hover:bg-muted/60 empty:hidden"
className="flex min-h-11 w-full min-w-0 items-center rounded-lg bg-muted/50 px-3 py-2 shadow-[0_1px_2px_rgb(0_0_0_/_0.03),0_0_0_1px_rgb(0_0_0_/_0.04)] transition-[background-color,box-shadow] duration-150 ease-out hover:bg-muted/80 focus-within:bg-muted/80 focus-within:shadow-[0_1px_2px_rgb(0_0_0_/_0.06),0_0_0_1px_rgb(0_0_0_/_0.06)] empty:hidden dark:shadow-[0_0_0_1px_rgb(255_255_255_/_0.06)]"
data-status-item-id={item.id}
>
{item.render({ presentation: "mobile-drawer", density: "full", drawerOpen: open })}
@@ -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"
/>
<span className={presentation === "bar" ? "sr-only" : "text-foreground"}>
{presentation === "bar" ? details.label : details.description}
</span>
{presentation === "bar" ? (
<span className="sr-only">{details.label}</span>
) : (
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Connection
</span>
<span className="text-sm font-medium text-foreground">{details.description}</span>
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent>{details.description}</TooltipContent>
@@ -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", () => {
@@ -40,8 +40,14 @@ export function StatusSurfaceMetrics({
const host = snapshot?.sources.find((source) => source.kind === "backend");
if (presentation === "mobile-drawer") {
return (
<section data-testid="app-status-metrics" className="space-y-1" aria-label="System metrics">
<h3 className="px-1 text-sm font-medium">System metrics</h3>
<section
data-testid="app-status-metrics"
className="space-y-2 py-0.5"
aria-label="System metrics"
>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
System metrics
</h3>
{!host ? (
<EmptyMetrics drawer />
) : (
@@ -129,7 +135,7 @@ function DrawerSourceMetrics({
simplified: boolean;
}) {
return (
<div className="flex min-h-11 items-center gap-2 rounded-md px-3 text-sm hover:bg-muted/60">
<div className="flex min-h-11 w-full min-w-0 items-center gap-2 px-0 text-sm">
{!simplified ? <SourceBadge source={source} updatedAt={updatedAt} showLabel /> : null}
<div className="flex min-w-0 flex-1 items-center justify-end gap-3 overflow-hidden">
<MetricValues source={source} updatedAt={updatedAt} limit={4} simplified={simplified} />
@@ -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", {