fix(web): make mobile topbar actions scrollable (#2769)
This commit is contained in:
@@ -244,7 +244,28 @@
|
||||
|
||||
function MainTopBarSlot(props) {
|
||||
var slotProps = props.slotProps || {};
|
||||
return jsx("span", { id: "hello-main-top-bar" }, "Hello " + slotProps.currentPage);
|
||||
var label = "Hello " + slotProps.currentPage;
|
||||
return jsx(
|
||||
ui.Button,
|
||||
{
|
||||
id: "hello-main-top-bar",
|
||||
variant: "outline",
|
||||
size: "icon-sm",
|
||||
"aria-label": label,
|
||||
},
|
||||
jsx(
|
||||
"svg",
|
||||
{
|
||||
className: "h-4 w-4",
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
jsx("path", { d: "M5 12h14M12 5l7 7l-7 7" }),
|
||||
),
|
||||
jsx("span", { className: "sr-only" }, label),
|
||||
);
|
||||
}
|
||||
|
||||
// Debounce delay for the Notes panel's autosave — short, so e2e specs
|
||||
|
||||
@@ -27,6 +27,14 @@ export type PluginIcon = string | Component<PluginIconProps>;
|
||||
/** Placement for a registered nav item; see `PluginRegistry.registerNavItem`. */
|
||||
export type PluginNavSection = "main" | "settings" | "integrations" | "sidebar-footer";
|
||||
|
||||
/** Context passed to components registered for the `main-top-bar` slot. */
|
||||
export interface MainTopBarSlotProps {
|
||||
workspaceId: string | null;
|
||||
workspaceLabel?: string;
|
||||
currentPage: "kanban" | "tasks";
|
||||
presentation: "desktop" | "mobile";
|
||||
}
|
||||
|
||||
export type StateUpdater<Value> = Value | ((previous: Value) => Value);
|
||||
export type StateSetter<Value> = (value: StateUpdater<Value>) => void;
|
||||
|
||||
|
||||
@@ -98,10 +98,12 @@ describe("KanbanHeaderMobile", () => {
|
||||
it("renders page title and workspace label for non-Home pages", () => {
|
||||
renderHeader("Tasks", undefined, undefined, "tasks");
|
||||
|
||||
const leftActions = screen.getByTestId(LEFT_ACTIONS_TEST_ID);
|
||||
expect(leftActions.textContent).toContain("Tasks");
|
||||
expect(leftActions.textContent).toContain("/root/kandev");
|
||||
expect(leftActions.firstElementChild?.className).toContain("max-w-[38vw]");
|
||||
const actionStrip = screen.getByTestId("mobile-topbar-action-strip");
|
||||
expect(actionStrip.textContent).toContain("Tasks");
|
||||
expect(actionStrip.textContent).toContain("/root/kandev");
|
||||
expect(
|
||||
actionStrip.querySelector("[data-testid='mobile-topbar-page-context']")?.className,
|
||||
).toContain("max-w-[38vw]");
|
||||
});
|
||||
|
||||
it("opens quick chat from the header action when a workspace is active", () => {
|
||||
@@ -114,11 +116,11 @@ describe("KanbanHeaderMobile", () => {
|
||||
it("opens quick terminal immediately before quick chat", () => {
|
||||
renderHeader("Home", ACTIVE_WORKSPACE_ID);
|
||||
|
||||
const terminal = screen.getByTestId(QUICK_TERMINAL_TEST_ID);
|
||||
const quickChat = screen.getByTestId(QUICK_CHAT_TEST_ID);
|
||||
expect(terminal.nextElementSibling).toBe(quickChat);
|
||||
const terminalTarget = screen.getByTestId("mobile-quick-terminal-hit-target");
|
||||
const quickChatTarget = screen.getByTestId("mobile-quick-chat-hit-target");
|
||||
expect(terminalTarget.nextElementSibling).toBe(quickChatTarget);
|
||||
|
||||
fireEvent.click(terminal);
|
||||
fireEvent.click(screen.getByTestId(QUICK_TERMINAL_TEST_ID));
|
||||
expect(quickChatMocks.openQuickTerminal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -132,11 +134,36 @@ describe("KanbanHeaderMobile", () => {
|
||||
it("places quick chat immediately before search", () => {
|
||||
renderHeader("Home", "workspace-1", vi.fn());
|
||||
|
||||
const quickChat = screen.getByTestId(QUICK_CHAT_TEST_ID);
|
||||
const quickChat = screen.getByTestId("mobile-quick-chat-hit-target");
|
||||
const search = screen.getByTestId("mobile-search-toggle");
|
||||
expect(quickChat.nextElementSibling).toBe(search);
|
||||
});
|
||||
|
||||
it("keeps the brand and menu outside the middle action strip", () => {
|
||||
renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn());
|
||||
|
||||
const strip = screen.getByTestId("mobile-topbar-action-strip");
|
||||
const menu = screen.getByTestId("mobile-topbar-menu");
|
||||
expect(strip.parentElement).toBe(menu.parentElement);
|
||||
expect(strip.previousElementSibling).not.toBe(menu);
|
||||
expect(menu.previousElementSibling).toBe(strip);
|
||||
});
|
||||
|
||||
it("uses the shared compact icon geometry for native mobile actions", () => {
|
||||
renderHeader("Home", ACTIVE_WORKSPACE_ID, vi.fn());
|
||||
|
||||
for (const id of [
|
||||
QUICK_TERMINAL_TEST_ID,
|
||||
QUICK_CHAT_TEST_ID,
|
||||
"mobile-search-toggle",
|
||||
"mobile-topbar-menu",
|
||||
]) {
|
||||
expect(screen.getByTestId(id).className).not.toContain("!size-11");
|
||||
}
|
||||
expect(screen.getByTestId("mobile-quick-terminal-hit-target").className).toContain("h-11");
|
||||
expect(screen.getByTestId("mobile-quick-chat-hit-target").className).toContain("h-11");
|
||||
});
|
||||
|
||||
it("describes a connectivity warning on the persistent Home menu trigger", () => {
|
||||
statusDrawerState.issueSeverity = "lost";
|
||||
renderHeader("Home");
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { IconMenu2, IconMessageCircle, IconSearch, IconTerminal2 } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { PageTopbar } from "@/components/page-topbar";
|
||||
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
|
||||
import { MainTopBarPluginActions } from "./main-top-bar-plugin-actions";
|
||||
import { MobileTopbarActionStrip } from "./mobile-topbar-action-strip";
|
||||
import { MobileMenuSheet } from "./mobile-menu-sheet";
|
||||
import type { TasksListDisplayOptions } from "./mobile-menu-task-list-options";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -37,12 +39,33 @@ function MobileBrandLink({ workspaceId }: Pick<KanbanHeaderMobileProps, "workspa
|
||||
href={workspaceHomeHref(workspaceId ? { id: workspaceId } : undefined)}
|
||||
aria-label={t("kanban:kandevHome")}
|
||||
className="relative z-10 shrink-0 cursor-pointer text-[15px] font-semibold leading-none transition-colors hover:text-foreground/80"
|
||||
data-testid="mobile-topbar-brand"
|
||||
>
|
||||
Kandev
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLauncherTarget({
|
||||
children,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="flex h-11 w-8 shrink-0 cursor-pointer items-center justify-center"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileQuickChatButton({
|
||||
workspaceId,
|
||||
onClick,
|
||||
@@ -54,72 +77,86 @@ function MobileQuickChatButton({
|
||||
const dot = useAppStore((state) => selectQuickChatHasUnseenIdle(state, workspaceId));
|
||||
const quickChatLabel = t(dot ? "sidebar:quickChatUnseen" : "sidebar:quickChat");
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
onClick={onClick}
|
||||
className="!size-11 cursor-pointer"
|
||||
aria-label={quickChatLabel}
|
||||
data-testid="mobile-quick-chat-button"
|
||||
>
|
||||
<span className="relative flex">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{dot && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
<MobileLauncherTarget onClick={onClick} testId="mobile-quick-chat-hit-target">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
aria-label={quickChatLabel}
|
||||
data-testid="mobile-quick-chat-button"
|
||||
>
|
||||
<span className="relative flex">
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
{dot && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-background"
|
||||
data-testid="quick-chat-unseen-dot"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</MobileLauncherTarget>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileHeaderActions({
|
||||
function MobileHeaderActionItems({
|
||||
workspaceId,
|
||||
workspaceLabel,
|
||||
title,
|
||||
hideTitle,
|
||||
currentPage,
|
||||
onSearchChange,
|
||||
isSearchOpen,
|
||||
handleOpenQuickChat,
|
||||
handleOpenQuickTerminal,
|
||||
toggleSearch,
|
||||
setMenuOpen,
|
||||
}: {
|
||||
workspaceId?: string;
|
||||
workspaceLabel: string;
|
||||
title: string;
|
||||
hideTitle: boolean;
|
||||
currentPage: "kanban" | "tasks";
|
||||
onSearchChange?: (query: string) => void;
|
||||
isSearchOpen: boolean;
|
||||
handleOpenQuickChat: () => void;
|
||||
handleOpenQuickTerminal: () => void;
|
||||
toggleSearch: () => void;
|
||||
setMenuOpen: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { issueSeverity } = useAppStatusDrawer();
|
||||
const issueDetails = useConnectionIssueCopy(issueSeverity);
|
||||
const isHome = currentPage !== "tasks";
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideTitle && !isHome && (
|
||||
<span
|
||||
className="flex shrink-0 min-w-0 max-w-[38vw] flex-col leading-tight"
|
||||
data-testid="mobile-topbar-page-context"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-muted-foreground">{title}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground/60">{workspaceLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<MainTopBarPluginActions
|
||||
workspaceId={workspaceId}
|
||||
workspaceLabel={workspaceLabel}
|
||||
currentPage={currentPage}
|
||||
presentation="mobile"
|
||||
/>
|
||||
<TopbarMetrics size="lg" />
|
||||
<TopbarMetrics size="lg" mobile />
|
||||
{workspaceId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
<MobileLauncherTarget
|
||||
onClick={handleOpenQuickTerminal}
|
||||
className="!size-11 cursor-pointer"
|
||||
aria-label={t("sidebar:quickTerminal")}
|
||||
data-testid="mobile-quick-terminal-button"
|
||||
testId="mobile-quick-terminal-hit-target"
|
||||
>
|
||||
<IconTerminal2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
aria-label={t("sidebar:quickTerminal")}
|
||||
data-testid="mobile-quick-terminal-button"
|
||||
>
|
||||
<IconTerminal2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</MobileLauncherTarget>
|
||||
)}
|
||||
{workspaceId && (
|
||||
<MobileQuickChatButton workspaceId={workspaceId} onClick={handleOpenQuickChat} />
|
||||
@@ -137,6 +174,25 @@ function MobileHeaderActions({
|
||||
<IconSearch className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileHeaderActions(
|
||||
props: Parameters<typeof MobileHeaderActionItems>[0] & {
|
||||
setMenuOpen: (open: boolean) => void;
|
||||
},
|
||||
) {
|
||||
const { setMenuOpen, ...actionProps } = props;
|
||||
const { t } = useTranslation();
|
||||
const { issueSeverity } = useAppStatusDrawer();
|
||||
const issueDetails = useConnectionIssueCopy(issueSeverity);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileTopbarActionStrip>
|
||||
<MobileHeaderActionItems {...actionProps} />
|
||||
</MobileTopbarActionStrip>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-lg"
|
||||
@@ -152,6 +208,7 @@ function MobileHeaderActions({
|
||||
: t("kanban:openMenu")
|
||||
}
|
||||
data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity}
|
||||
data-testid="mobile-topbar-menu"
|
||||
>
|
||||
<IconMenu2 className="h-4 w-4" />
|
||||
{issueDetails && (
|
||||
@@ -185,8 +242,6 @@ export function KanbanHeaderMobile({
|
||||
const setSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen);
|
||||
const handleOpenQuickChat = useQuickChatLauncher(workspaceId);
|
||||
const handleOpenQuickTerminal = useQuickTerminalLauncher(workspaceId);
|
||||
const isHome = currentPage !== "tasks";
|
||||
|
||||
const toggleSearch = () => {
|
||||
const next = !isSearchOpen;
|
||||
setSearchOpen(next);
|
||||
@@ -204,21 +259,13 @@ export function KanbanHeaderMobile({
|
||||
showStatusTrigger={false}
|
||||
className="h-10 px-3 py-1"
|
||||
variant="root"
|
||||
leftActions={
|
||||
hideTitle || isHome ? null : (
|
||||
<span className="flex min-w-0 max-w-[38vw] flex-col leading-tight">
|
||||
<span className="truncate text-sm font-medium text-muted-foreground">{title}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground/60">
|
||||
{workspaceLabel}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
actionsClassName="gap-2"
|
||||
actionsClassName="min-w-0 flex-1 !shrink gap-2"
|
||||
actions={
|
||||
<MobileHeaderActions
|
||||
workspaceId={workspaceId}
|
||||
workspaceLabel={workspaceLabel}
|
||||
title={title}
|
||||
hideTitle={hideTitle}
|
||||
currentPage={currentPage}
|
||||
onSearchChange={onSearchChange}
|
||||
isSearchOpen={isSearchOpen}
|
||||
|
||||
@@ -23,14 +23,14 @@ describe("MainTopBarPluginActions", () => {
|
||||
const ctx = slotProps as MainTopBarSlotProps;
|
||||
return (
|
||||
<div data-testid="plugin-app-bar">
|
||||
{`${ctx.workspaceId}|${ctx.workspaceLabel}|${ctx.currentPage}`}
|
||||
{`${ctx.workspaceId}|${ctx.workspaceLabel}|${ctx.currentPage}|${ctx.presentation}`}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
render(<MainTopBarPluginActions workspaceId="w1" workspaceLabel="Acme" currentPage="tasks" />);
|
||||
|
||||
expect(screen.getByTestId("plugin-app-bar").textContent).toBe("w1|Acme|tasks");
|
||||
expect(screen.getByTestId("plugin-app-bar").textContent).toBe("w1|Acme|tasks|desktop");
|
||||
});
|
||||
|
||||
it("normalizes an absent workspace id to null", () => {
|
||||
@@ -43,4 +43,23 @@ describe("MainTopBarPluginActions", () => {
|
||||
|
||||
expect(screen.getByTestId("plugin-app-bar").textContent).toBe("null");
|
||||
});
|
||||
|
||||
it("passes mobile presentation and applies the native icon-button contract", () => {
|
||||
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
|
||||
const ctx = slotProps as MainTopBarSlotProps;
|
||||
return (
|
||||
<button data-slot="button" data-testid="plugin-button">
|
||||
<svg />
|
||||
{ctx.presentation}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
render(<MainTopBarPluginActions currentPage="kanban" presentation="mobile" />);
|
||||
|
||||
expect(screen.getByTestId("plugin-button").textContent).toBe("mobile");
|
||||
expect(screen.getByTestId("mobile-main-top-bar-plugin-actions").className).toContain(
|
||||
"[&_[data-slot=button]]:!size-8",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import type { MainTopBarSlotProps } from "@/lib/plugins/types";
|
||||
|
||||
export type { MainTopBarSlotProps } from "@/lib/plugins/types";
|
||||
|
||||
/**
|
||||
* Props forwarded to every plugin component registered for the `main-top-bar`
|
||||
@@ -15,15 +18,6 @@ import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
* context a plugin gets is the active workspace and which listing view is
|
||||
* showing.
|
||||
*/
|
||||
export type MainTopBarSlotProps = {
|
||||
/** Workspace the top bar is currently showing, or null on the global home. */
|
||||
workspaceId: string | null;
|
||||
/** Human-readable label of that workspace, when known. */
|
||||
workspaceLabel?: string;
|
||||
/** Which listing the top bar belongs to. */
|
||||
currentPage: "kanban" | "tasks";
|
||||
};
|
||||
|
||||
/**
|
||||
* Plugin extension point in the default app top bar (Home / Kanban / Tasks),
|
||||
* rendered alongside the first-party controls (metrics, view toggle, display
|
||||
@@ -36,13 +30,24 @@ export function MainTopBarPluginActions(props: {
|
||||
workspaceId?: string;
|
||||
workspaceLabel?: string;
|
||||
currentPage: "kanban" | "tasks";
|
||||
presentation?: MainTopBarSlotProps["presentation"];
|
||||
}) {
|
||||
const { workspaceId, workspaceLabel, currentPage } = props;
|
||||
const { workspaceId, workspaceLabel, currentPage, presentation = "desktop" } = props;
|
||||
|
||||
const slotProps = useMemo<MainTopBarSlotProps>(
|
||||
() => ({ workspaceId: workspaceId ?? null, workspaceLabel, currentPage }),
|
||||
[workspaceId, workspaceLabel, currentPage],
|
||||
() => ({ workspaceId: workspaceId ?? null, workspaceLabel, currentPage, presentation }),
|
||||
[workspaceId, workspaceLabel, currentPage, presentation],
|
||||
);
|
||||
|
||||
return <PluginSlot name="main-top-bar" slotProps={slotProps} />;
|
||||
const content = <PluginSlot name="main-top-bar" slotProps={slotProps} />;
|
||||
if (presentation === "desktop") return content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 [&_[data-slot=button]]:!size-8 [&_[data-slot=button]]:!p-0 [&_[data-slot=button]_svg]:!size-4"
|
||||
data-testid="mobile-main-top-bar-plugin-actions"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MobileTopbarActionStrip } from "./mobile-topbar-action-strip";
|
||||
|
||||
const observers: Array<{ trigger: () => void }> = [];
|
||||
const VIEWPORT_TEST_ID = "mobile-topbar-action-strip-viewport";
|
||||
const LEFT_FADE_TEST_ID = "mobile-topbar-action-strip-left-fade";
|
||||
const RIGHT_FADE_TEST_ID = "mobile-topbar-action-strip-right-fade";
|
||||
|
||||
class MockResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback;
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push({ trigger: () => this.callback([], this as unknown as ResizeObserver) });
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function setScrollMetrics(
|
||||
element: HTMLElement,
|
||||
metrics: { clientWidth: number; scrollWidth: number; scrollLeft: number },
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
clientWidth: { configurable: true, value: metrics.clientWidth },
|
||||
scrollWidth: { configurable: true, value: metrics.scrollWidth },
|
||||
scrollLeft: { configurable: true, value: metrics.scrollLeft, writable: true },
|
||||
});
|
||||
}
|
||||
|
||||
describe("MobileTopbarActionStrip", () => {
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("does not render directional fades when all actions fit", () => {
|
||||
render(
|
||||
<MobileTopbarActionStrip>
|
||||
<span>Actions</span>
|
||||
</MobileTopbarActionStrip>,
|
||||
);
|
||||
|
||||
const viewport = screen.getByTestId(VIEWPORT_TEST_ID);
|
||||
expect((viewport.firstElementChild as HTMLElement).className).toContain("justify-end");
|
||||
setScrollMetrics(viewport, { clientWidth: 200, scrollWidth: 200, scrollLeft: 0 });
|
||||
act(() => observers[0]?.trigger());
|
||||
|
||||
expect(screen.queryByTestId(LEFT_FADE_TEST_ID)).toBeNull();
|
||||
expect(screen.queryByTestId(RIGHT_FADE_TEST_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the right fade when content overflows at the initial position", () => {
|
||||
render(
|
||||
<MobileTopbarActionStrip>
|
||||
<span>Actions</span>
|
||||
</MobileTopbarActionStrip>,
|
||||
);
|
||||
|
||||
const viewport = screen.getByTestId(VIEWPORT_TEST_ID);
|
||||
setScrollMetrics(viewport, { clientWidth: 100, scrollWidth: 240, scrollLeft: 0 });
|
||||
act(() => observers[0]?.trigger());
|
||||
|
||||
expect(screen.queryByTestId(LEFT_FADE_TEST_ID)).toBeNull();
|
||||
expect(screen.getByTestId(RIGHT_FADE_TEST_ID)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("updates both fades while scrolling through overflowing actions", () => {
|
||||
render(
|
||||
<MobileTopbarActionStrip>
|
||||
<span>Actions</span>
|
||||
</MobileTopbarActionStrip>,
|
||||
);
|
||||
|
||||
const viewport = screen.getByTestId(VIEWPORT_TEST_ID);
|
||||
setScrollMetrics(viewport, { clientWidth: 100, scrollWidth: 240, scrollLeft: 0 });
|
||||
act(() => observers[0]?.trigger());
|
||||
|
||||
act(() => {
|
||||
viewport.scrollLeft = 60;
|
||||
fireEvent.scroll(viewport);
|
||||
});
|
||||
expect(screen.getByTestId(LEFT_FADE_TEST_ID)).toBeTruthy();
|
||||
expect(screen.getByTestId(RIGHT_FADE_TEST_ID)).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
viewport.scrollLeft = 140;
|
||||
fireEvent.scroll(viewport);
|
||||
});
|
||||
expect(screen.getByTestId(LEFT_FADE_TEST_ID)).toBeTruthy();
|
||||
expect(screen.queryByTestId(RIGHT_FADE_TEST_ID)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
const SCROLL_END_TOLERANCE_PX = 1;
|
||||
|
||||
type ScrollCues = {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
};
|
||||
|
||||
function scrollCuesFor(element: HTMLDivElement): ScrollCues {
|
||||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth);
|
||||
return {
|
||||
left: element.scrollLeft > SCROLL_END_TOLERANCE_PX,
|
||||
right: maxScrollLeft - element.scrollLeft > SCROLL_END_TOLERANCE_PX,
|
||||
};
|
||||
}
|
||||
|
||||
export function MobileTopbarActionStrip({ children }: { children: ReactNode }) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollCues, setScrollCues] = useState<ScrollCues>({ left: false, right: false });
|
||||
|
||||
const updateScrollCues = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const next = scrollCuesFor(viewport);
|
||||
setScrollCues((current) =>
|
||||
current.left === next.left && current.right === next.right ? current : next,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
|
||||
updateScrollCues();
|
||||
viewport.addEventListener("scroll", updateScrollCues, { passive: true });
|
||||
window.addEventListener("resize", updateScrollCues);
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateScrollCues);
|
||||
observer?.observe(viewport);
|
||||
if (contentRef.current) observer?.observe(contentRef.current);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollCues);
|
||||
window.removeEventListener("resize", updateScrollCues);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [updateScrollCues]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-8 min-w-0 flex-1"
|
||||
data-testid="mobile-topbar-action-strip"
|
||||
data-can-scroll-left={scrollCues.left}
|
||||
data-can-scroll-right={scrollCues.right}
|
||||
>
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="absolute inset-x-0 top-1/2 h-11 min-w-0 -translate-y-1/2 overflow-x-auto overscroll-x-contain scrollbar-hide"
|
||||
data-testid="mobile-topbar-action-strip-viewport"
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex h-full w-max min-w-full items-center justify-end gap-2 pr-1"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
{scrollCues.left && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-7 bg-gradient-to-r from-background via-background/80 to-transparent"
|
||||
data-testid="mobile-topbar-action-strip-left-fade"
|
||||
/>
|
||||
)}
|
||||
{scrollCues.right && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-7 bg-gradient-to-l from-background via-background/80 to-transparent"
|
||||
data-testid="mobile-topbar-action-strip-right-fade"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,12 +23,14 @@ type StatusSurfaceMetricsProps = {
|
||||
presentation: "bar" | "mobile-drawer";
|
||||
density: "full" | "compact";
|
||||
drawerOpen: boolean;
|
||||
iconSize?: "size-3.5" | "size-4";
|
||||
};
|
||||
|
||||
export function StatusSurfaceMetrics({
|
||||
presentation,
|
||||
density,
|
||||
drawerOpen,
|
||||
iconSize = "size-3.5",
|
||||
}: StatusSurfaceMetricsProps) {
|
||||
const { t } = useTranslation();
|
||||
// Wire/storage name stays stable for existing user settings and API payloads.
|
||||
@@ -54,12 +56,13 @@ export function StatusSurfaceMetrics({
|
||||
{t("system:systemMetrics")}
|
||||
</h3>
|
||||
{!host ? (
|
||||
<EmptyMetrics drawer />
|
||||
<EmptyMetrics drawer iconSize={iconSize} />
|
||||
) : (
|
||||
<DrawerSourceMetrics
|
||||
source={host}
|
||||
updatedAt={snapshot?.timestamp}
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -73,7 +76,7 @@ export function StatusSurfaceMetrics({
|
||||
aria-label={t("system:systemMetrics")}
|
||||
>
|
||||
{!host ? (
|
||||
<EmptyMetrics />
|
||||
<EmptyMetrics iconSize={iconSize} />
|
||||
) : (
|
||||
<BarSourceMetrics
|
||||
source={host}
|
||||
@@ -81,13 +84,14 @@ export function StatusSurfaceMetrics({
|
||||
showSourceLabel={density === "full"}
|
||||
metricLimit={density === "compact" ? 2 : 4}
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyMetrics({ drawer = false }: { drawer?: boolean }) {
|
||||
function EmptyMetrics({ drawer = false, iconSize }: { drawer?: boolean; iconSize: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
@@ -97,7 +101,7 @@ function EmptyMetrics({ drawer = false }: { drawer?: boolean }) {
|
||||
: "flex h-full items-center gap-2 text-[11px] text-current opacity-70"
|
||||
}
|
||||
>
|
||||
<IconActivity className="h-3.5 w-3.5" />
|
||||
<IconActivity className={iconSize} />
|
||||
<span>{t("system:metricsUnavailable")}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -109,23 +113,31 @@ function BarSourceMetrics({
|
||||
showSourceLabel,
|
||||
metricLimit,
|
||||
simplified,
|
||||
iconSize,
|
||||
}: {
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
showSourceLabel: boolean;
|
||||
metricLimit: number;
|
||||
simplified: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full max-w-[360px] items-center gap-3 overflow-hidden text-[11px]">
|
||||
{!simplified ? (
|
||||
<SourceBadge source={source} updatedAt={updatedAt} showLabel={showSourceLabel} />
|
||||
<SourceBadge
|
||||
source={source}
|
||||
updatedAt={updatedAt}
|
||||
showLabel={showSourceLabel}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
) : null}
|
||||
<MetricValues
|
||||
source={source}
|
||||
updatedAt={updatedAt}
|
||||
limit={metricLimit}
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -135,16 +147,26 @@ function DrawerSourceMetrics({
|
||||
source,
|
||||
updatedAt,
|
||||
simplified,
|
||||
iconSize,
|
||||
}: {
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
simplified: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
{!simplified ? (
|
||||
<SourceBadge source={source} updatedAt={updatedAt} showLabel iconSize={iconSize} />
|
||||
) : 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} />
|
||||
<MetricValues
|
||||
source={source}
|
||||
updatedAt={updatedAt}
|
||||
limit={4}
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -154,10 +176,12 @@ function SourceBadge({
|
||||
source,
|
||||
updatedAt,
|
||||
showLabel,
|
||||
iconSize,
|
||||
}: {
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
showLabel: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateLocale();
|
||||
@@ -168,7 +192,7 @@ function SourceBadge({
|
||||
className="flex shrink-0 items-center gap-1.5 text-current"
|
||||
aria-label={t("system:hostMetrics")}
|
||||
>
|
||||
<IconServer className="size-3.5" stroke={1.6} />
|
||||
<IconServer className={iconSize} stroke={1.6} />
|
||||
{showLabel ? <span className="max-w-20 truncate">{t("system:host")}</span> : null}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -190,11 +214,13 @@ function MetricValues({
|
||||
updatedAt,
|
||||
limit,
|
||||
simplified,
|
||||
iconSize,
|
||||
}: {
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
limit: number;
|
||||
simplified: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
const metrics = source.metrics.slice(0, limit);
|
||||
if (metrics.length === 0) return <span className="text-muted-foreground">-</span>;
|
||||
@@ -207,6 +233,7 @@ function MetricValues({
|
||||
source={source}
|
||||
updatedAt={updatedAt}
|
||||
simplified={simplified}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
@@ -218,11 +245,13 @@ function MetricValue({
|
||||
source,
|
||||
updatedAt,
|
||||
simplified,
|
||||
iconSize,
|
||||
}: {
|
||||
metric: SystemMetricSample;
|
||||
source: SystemMetricsSource;
|
||||
updatedAt?: string;
|
||||
simplified: boolean;
|
||||
iconSize: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateLocale();
|
||||
@@ -234,7 +263,7 @@ function MetricValue({
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 tabular-nums ${metricColor(metric)}`}
|
||||
aria-label={`${metricLabel(t, metric.id)} ${formatMetric(metric)}`}
|
||||
>
|
||||
{metricIcon(metric.id)}
|
||||
{metricIcon(metric.id, iconSize)}
|
||||
{!simplified ? <MetricMeter metric={metric} /> : null}
|
||||
<span className="font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]">
|
||||
{formatMetric(metric)}
|
||||
@@ -275,7 +304,7 @@ function metricLabel(t: TFunction, id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function metricIcon(id: string) {
|
||||
function metricIcon(id: string, iconSize: string) {
|
||||
const Icon =
|
||||
{
|
||||
cpu_percent: IconCpu,
|
||||
@@ -284,7 +313,7 @@ function metricIcon(id: string) {
|
||||
cpu_temp: IconFlame,
|
||||
io_load: IconGauge,
|
||||
}[id] ?? IconActivity;
|
||||
return <Icon className="size-3.5 opacity-80" stroke={1.6} />;
|
||||
return <Icon className={`${iconSize} opacity-80`} stroke={1.6} />;
|
||||
}
|
||||
|
||||
function MetricMeter({ metric }: { metric: SystemMetricSample }) {
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock("@/hooks/use-system-metrics-subscription", () => ({
|
||||
useSystemMetricsSubscription: subscribeMock,
|
||||
}));
|
||||
|
||||
function renderMetrics(appStatusBarEnabled: boolean) {
|
||||
function renderMetrics(appStatusBarEnabled: boolean, mobile = false) {
|
||||
const initialState = {
|
||||
userSettings: {
|
||||
...defaultSettingsState.userSettings,
|
||||
@@ -40,7 +40,7 @@ function renderMetrics(appStatusBarEnabled: boolean) {
|
||||
return render(
|
||||
<StateProvider initialState={initialState}>
|
||||
<TooltipProvider>
|
||||
<TopbarMetrics />
|
||||
<TopbarMetrics mobile={mobile} />
|
||||
</TooltipProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
@@ -57,6 +57,7 @@ describe("TopbarMetrics preference fallback", () => {
|
||||
renderMetrics(false);
|
||||
|
||||
expect(screen.getByTestId("topbar-metrics")).toBeTruthy();
|
||||
expect(screen.getByTestId("topbar-metrics").className).toContain("h-8");
|
||||
expect(screen.getByLabelText("CPU 42%")).toBeTruthy();
|
||||
expect(subscribeMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
@@ -67,4 +68,12 @@ describe("TopbarMetrics preference fallback", () => {
|
||||
expect(screen.queryByTestId("topbar-metrics")).toBeNull();
|
||||
expect(subscribeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses 16px metric icons in the mobile topbar", () => {
|
||||
renderMetrics(false, true);
|
||||
|
||||
expect(screen.getByLabelText("CPU 42%").querySelector("svg")?.getAttribute("class")).toContain(
|
||||
"size-4",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,11 @@ import { StatusSurfaceMetrics } from "./status-surface-metrics";
|
||||
type TopbarMetricsProps = {
|
||||
activeSessionId?: string | null;
|
||||
size?: "sm" | "lg";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
/** Preserves topbar metrics while the user-owned status surface is hidden. */
|
||||
export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) {
|
||||
export function TopbarMetrics({ size = "lg", mobile = false }: TopbarMetricsProps) {
|
||||
const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled);
|
||||
const metricsEnabled = useAppStore(
|
||||
(state) => state.userSettings.systemMetricsDisplay.showInTopbar,
|
||||
@@ -19,10 +20,15 @@ export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center overflow-hidden ${size === "sm" ? "h-7" : "h-8"}`}
|
||||
className={`flex shrink-0 items-center overflow-hidden ${size === "sm" ? "h-7" : "h-8"}`}
|
||||
data-testid="topbar-metrics"
|
||||
>
|
||||
<StatusSurfaceMetrics presentation="bar" density="compact" drawerOpen />
|
||||
<StatusSurfaceMetrics
|
||||
presentation="bar"
|
||||
density="compact"
|
||||
drawerOpen
|
||||
iconSize={mobile ? "size-4" : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import {
|
||||
captureAppStatusBarSettings,
|
||||
restoreAppStatusBarSettings,
|
||||
type AppStatusBarSettingsBaseline,
|
||||
} from "../../helpers/app-status-bar-settings";
|
||||
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
|
||||
|
||||
const PLUGIN_ID = "kandev-plugin-e2e";
|
||||
const PACKAGE_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
|
||||
);
|
||||
|
||||
type SystemMetricsDisplayBaseline = {
|
||||
show_in_topbar: boolean;
|
||||
simplified?: boolean;
|
||||
};
|
||||
|
||||
async function installFixture(page: Page) {
|
||||
await page.goto("/settings/plugins");
|
||||
await page.getByTestId("install-plugin-trigger").tap();
|
||||
await page.getByTestId("install-plugin-tab-upload").tap();
|
||||
await page.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
|
||||
await page.getByTestId("install-plugin-upload-submit").tap();
|
||||
await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Mobile topbar action strip", () => {
|
||||
let metricsBaseline: SystemMetricsDisplayBaseline;
|
||||
let statusBarBaseline: AppStatusBarSettingsBaseline;
|
||||
|
||||
test.beforeEach(async ({ apiClient, testPage }) => {
|
||||
void testPage;
|
||||
const settings = await apiClient.getUserSettings();
|
||||
metricsBaseline = (settings.settings.system_metrics_display as
|
||||
| SystemMetricsDisplayBaseline
|
||||
| undefined) ?? { show_in_topbar: false };
|
||||
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
|
||||
|
||||
const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
app_status_bar_enabled: false,
|
||||
system_metrics_display: { show_in_topbar: true, simplified: false },
|
||||
});
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ apiClient }) => {
|
||||
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
|
||||
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
||||
system_metrics_display: metricsBaseline,
|
||||
});
|
||||
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
|
||||
});
|
||||
|
||||
test("scrolls plugin, metric, and native actions without moving fixed chrome", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await installFixture(testPage);
|
||||
await testPage.goto("/");
|
||||
await testPage.reload();
|
||||
|
||||
const strip = testPage.getByTestId("mobile-topbar-action-strip");
|
||||
const viewport = testPage.getByTestId("mobile-topbar-action-strip-viewport");
|
||||
const leftFade = testPage.getByTestId("mobile-topbar-action-strip-left-fade");
|
||||
const rightFade = testPage.getByTestId("mobile-topbar-action-strip-right-fade");
|
||||
const brand = testPage.getByTestId("mobile-topbar-brand");
|
||||
const menu = testPage.getByTestId("mobile-topbar-menu");
|
||||
const pluginButton = testPage.locator("#hello-main-top-bar");
|
||||
const metrics = testPage.getByTestId("topbar-metrics");
|
||||
const cpuMetric = metrics.getByLabel(/^CPU /);
|
||||
const terminal = testPage.getByTestId("mobile-quick-terminal-button");
|
||||
const quickChat = testPage.getByTestId("mobile-quick-chat-button");
|
||||
const terminalHitTarget = testPage.getByTestId("mobile-quick-terminal-hit-target");
|
||||
const quickChatHitTarget = testPage.getByTestId("mobile-quick-chat-hit-target");
|
||||
const search = testPage.getByTestId("mobile-search-toggle");
|
||||
|
||||
await expect(strip).toBeVisible();
|
||||
await expect(pluginButton).toBeVisible();
|
||||
await expect(metrics).toBeVisible();
|
||||
await expect(cpuMetric).toBeVisible();
|
||||
await expect(terminal).toBeVisible();
|
||||
await expect(quickChat).toBeVisible();
|
||||
await expect(search).toBeVisible();
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const nativeButtons = [terminal, quickChat, search, menu];
|
||||
const nativeBoxes = await Promise.all(
|
||||
nativeButtons.map((button, index) => requireBox(button, `native button ${index}`)),
|
||||
);
|
||||
for (const box of nativeBoxes) {
|
||||
expect(box.width).toBeCloseTo(32, 0);
|
||||
expect(box.height).toBeCloseTo(32, 0);
|
||||
}
|
||||
|
||||
const pluginBox = await requireBox(pluginButton, "plugin action");
|
||||
expect(pluginBox.width).toBeCloseTo(32, 0);
|
||||
expect(pluginBox.height).toBeCloseTo(32, 0);
|
||||
|
||||
const metricBox = await requireBox(metrics, "topbar metrics");
|
||||
expect(metricBox.height).toBeCloseTo(32, 0);
|
||||
const metricIconBox = await requireBox(cpuMetric.locator("svg").first(), "CPU metric icon");
|
||||
expect(metricIconBox.width).toBeCloseTo(16, 0);
|
||||
expect(metricIconBox.height).toBeCloseTo(16, 0);
|
||||
|
||||
const pluginIconBox = await requireBox(pluginButton.locator("svg").first(), "plugin icon");
|
||||
expect(pluginIconBox.width).toBeCloseTo(16, 0);
|
||||
expect(pluginIconBox.height).toBeCloseTo(16, 0);
|
||||
|
||||
const expectTouchTarget = async (target: Locator, label: string) => {
|
||||
const targetBox = await requireBox(target, `${label} hit target`);
|
||||
expect(targetBox.height).toBeCloseTo(44, 0);
|
||||
expect(targetBox.width).toBeCloseTo(32, 0);
|
||||
const hitTarget = await target.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const points = [rect.top + 1, rect.bottom - 1];
|
||||
return points.every((y) => document.elementFromPoint(x, y) === element);
|
||||
});
|
||||
expect(hitTarget, `${label} keeps a 44px hit area`).toBe(true);
|
||||
};
|
||||
|
||||
await expectTouchTarget(terminalHitTarget, "Quick Terminal");
|
||||
|
||||
const brandBefore = await requireBox(brand, "brand before scroll");
|
||||
const menuBefore = await requireBox(menu, "menu before scroll");
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-left", "false");
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-right", "true");
|
||||
await expect(leftFade).toHaveCount(0);
|
||||
await expect(rightFade).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar initial layout");
|
||||
|
||||
await viewport.evaluate((element) => {
|
||||
const node = element as HTMLElement;
|
||||
node.scrollLeft = Math.floor((node.scrollWidth - node.clientWidth) / 2);
|
||||
node.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-left", "true");
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-right", "true");
|
||||
await expect(leftFade).toBeVisible();
|
||||
await expect(rightFade).toBeVisible();
|
||||
|
||||
await viewport.evaluate((element) => {
|
||||
const node = element as HTMLElement;
|
||||
node.scrollLeft = node.scrollWidth;
|
||||
node.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-left", "true");
|
||||
await expect(strip).toHaveAttribute("data-can-scroll-right", "false");
|
||||
await expect(leftFade).toBeVisible();
|
||||
await expect(rightFade).toHaveCount(0);
|
||||
await expect(search).toBeInViewport();
|
||||
await expectTouchTarget(quickChatHitTarget, "Quick Chat");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile topbar scrolled layout");
|
||||
|
||||
const brandAfter = await requireBox(brand, "brand after scroll");
|
||||
const menuAfter = await requireBox(menu, "menu after scroll");
|
||||
expect(Math.abs(brandAfter.x - brandBefore.x)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(menuAfter.x - menuBefore.x)).toBeLessThanOrEqual(1);
|
||||
|
||||
await search.tap();
|
||||
await expect(testPage.getByTestId("mobile-search-bar")).toBeVisible();
|
||||
await expect(testPage.getByTestId("mobile-search-bar").getByRole("textbox")).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile search layout");
|
||||
});
|
||||
});
|
||||
@@ -60,13 +60,19 @@ test.describe("mobile quick terminal tabs", () => {
|
||||
try {
|
||||
const terminalButton = testPage.getByTestId("mobile-quick-terminal-button");
|
||||
const quickChatButton = testPage.getByTestId("mobile-quick-chat-button");
|
||||
const menuButton = testPage.getByTestId("mobile-topbar-menu");
|
||||
await expect(terminalButton).toBeVisible();
|
||||
await expect(quickChatButton).toBeVisible();
|
||||
await expect(menuButton).toBeVisible();
|
||||
const headerMenuBox = await menuButton.boundingBox();
|
||||
expect(headerMenuBox).not.toBeNull();
|
||||
if (!headerMenuBox) throw new Error("mobile menu geometry unavailable");
|
||||
for (const button of [terminalButton, quickChatButton]) {
|
||||
const buttonBox = await button.boundingBox();
|
||||
expect(buttonBox).not.toBeNull();
|
||||
expect(buttonBox!.width).toBeGreaterThanOrEqual(44);
|
||||
expect(buttonBox!.height).toBeGreaterThanOrEqual(44);
|
||||
if (!buttonBox) throw new Error("mobile launcher geometry unavailable");
|
||||
expect(buttonBox.width).toBeCloseTo(headerMenuBox.width, 1);
|
||||
expect(buttonBox.height).toBeCloseTo(headerMenuBox.height, 1);
|
||||
}
|
||||
|
||||
await terminalButton.tap();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PluginHostApi as PublicPluginHostApi,
|
||||
MainTopBarSlotProps as PublicMainTopBarSlotProps,
|
||||
PluginNavSection as PublicPluginNavSection,
|
||||
PluginRegistry as PublicPluginRegistry,
|
||||
RepositoryProviderRegistration as PublicRepositoryProviderRegistration,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
} from "@kandev/plugin-sdk";
|
||||
import type {
|
||||
PluginHostApi,
|
||||
MainTopBarSlotProps as HostMainTopBarSlotProps,
|
||||
PluginNavSection,
|
||||
PluginRegistry,
|
||||
RepositoryProviderRegistration,
|
||||
@@ -48,6 +50,10 @@ describe("public plugin SDK", () => {
|
||||
// `import type { PluginNavSection } from "@kandev/plugin-sdk"` verbatim, and
|
||||
// TS's structural typing can't otherwise tell an inline union from a named export.
|
||||
const navSectionIsCanonical: SameType<PluginNavSection, PublicPluginNavSection> = true;
|
||||
const mainTopBarSlotPropsAreCanonical: SameType<
|
||||
HostMainTopBarSlotProps,
|
||||
PublicMainTopBarSlotProps
|
||||
> = true;
|
||||
expect(hostHasEveryPublicKey).toBe(true);
|
||||
expect(registryHasEveryPublicKey).toBe(true);
|
||||
expect(publicHasEveryRegistryKey).toBe(true);
|
||||
@@ -57,6 +63,7 @@ describe("public plugin SDK", () => {
|
||||
expect(reviewSummaryIsCanonical).toBe(true);
|
||||
expect(associationIsCanonical).toBe(true);
|
||||
expect(navSectionIsCanonical).toBe(true);
|
||||
expect(mainTopBarSlotPropsAreCanonical).toBe(true);
|
||||
expect(publicHostContract).toBeTypeOf("function");
|
||||
expect(publicRegistryContract).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PluginUIApi } from "@kandev/plugin-sdk";
|
||||
export type {
|
||||
PluginContextApi,
|
||||
PluginHostRepository,
|
||||
MainTopBarSlotProps,
|
||||
PluginNavSection,
|
||||
PluginUIApi,
|
||||
} from "@kandev/plugin-sdk";
|
||||
@@ -117,7 +118,12 @@ export interface IntegrationSettingsRegistration {
|
||||
* sessionIds }`), "main-top-bar" (status/actions in the default app top bar on
|
||||
* the Home / Kanban / Tasks views, beside the CPU/DB metrics and the
|
||||
* view/display controls — the app-wide, task-agnostic counterpart to
|
||||
* "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage }`),
|
||||
* "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage,
|
||||
* presentation }`). On phones, `presentation` is "mobile": contributions
|
||||
* join the horizontally scrollable middle action strip between the fixed
|
||||
* Kandev link and menu button. Use the host `ui.Button` icon-button contract
|
||||
* there: a 32px box with a 16px SVG icon. Desktop contributions retain their
|
||||
* existing sizing.
|
||||
* "app-status-bar-left" / "app-status-bar-right" (receives
|
||||
* `AppStatusBarSlotProps` as `slotProps`), and
|
||||
* "plugin-settings" (inline UI on a plugin's own settings
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
spec: docs/specs/mobile-quick-chat-topbar/spec.md
|
||||
created: 2026-08-18
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Mobile Topbar Action Strip
|
||||
|
||||
## Overview
|
||||
|
||||
The phone header gives Quick Terminal and Quick Chat a 44px box. Search and menu use the shared
|
||||
32px icon size. The action container also refuses to shrink, so metrics and plugin actions can push
|
||||
the header past the viewport. This repair normalizes the phone controls and gives the middle actions
|
||||
one contained horizontal scroll owner with directional fades.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
- `apps/web/components/kanban/kanban-header-mobile.tsx` adds `!size-11` to the terminal and chat
|
||||
buttons. The shared `icon-lg` variant used by search and menu is 32px.
|
||||
- `apps/web/components/page-topbar.tsx` renders the full action cluster with `shrink-0`. The mobile
|
||||
header provides no nested overflow owner for optional metrics or plugin contributions.
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx` provides the nearest shipped fade
|
||||
treatment. `apps/web/components/task/task-sidebar-scroll-area.tsx` provides the resize and content
|
||||
observation pattern for conditional scroll cues.
|
||||
- `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts` records the regression as expected
|
||||
behavior by requiring the two oversized header launchers to be at least 44px.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Scrollable phone action strip
|
||||
|
||||
- Add `apps/web/components/kanban/mobile-topbar-action-strip.tsx`. It owns the horizontal viewport,
|
||||
content row, hidden scrollbar, and left and right fades. When the content fits, the row aligns
|
||||
its actions against the fixed menu; overflow retains the same horizontal scroll order.
|
||||
- Observe both the viewport and content row with `ResizeObserver`. Recalculate the fades after
|
||||
content changes, viewport changes, and horizontal scrolling.
|
||||
- Show each fade only while content remains hidden in that direction. Use the gradient treatment
|
||||
from the mobile chat-input controls.
|
||||
- Update `apps/web/components/kanban/kanban-header-mobile.tsx` so `PageTopbar` lets its actions use
|
||||
the remaining width. Keep the Kandev link outside the strip and keep the menu button outside it.
|
||||
- Put the optional non-Home context, plugin actions, metrics, terminal, chat, and search inside the
|
||||
strip. Preserve their current order. Remove the 44px overrides from terminal and chat.
|
||||
- Keep the current labels, launch handlers, search state, status indicator, and menu behavior.
|
||||
|
||||
### Native, metric, and plugin icon sizing
|
||||
|
||||
- Update `apps/web/components/system-metrics/topbar-metrics.tsx` so topbar metric icons inherit the
|
||||
same 16px size as native header icons. Keep the metrics container at 32px high.
|
||||
- Add a mobile presentation option to
|
||||
`apps/web/components/kanban/main-top-bar-plugin-actions.tsx`. The mobile wrapper normalizes
|
||||
`host.ui.Button` contributions to a 32px box and their SVG icons to 16px.
|
||||
- Keep Quick Terminal and Quick Chat as 32px visual buttons inside 44px interaction wrappers. The
|
||||
wrappers keep the strip item's width at 32px while preserving a real touch target.
|
||||
- Keep desktop plugin contribution sizing unchanged. Keep existing plugin slot fields and lifecycle
|
||||
compatible; the mobile presentation adds the `presentation` value described by the public SDK.
|
||||
- Export `MainTopBarSlotProps` from `@kandev/plugin-sdk` and assert the host binding remains an exact
|
||||
consumer of that public type.
|
||||
|
||||
### Plugin contract documentation
|
||||
|
||||
- Update `apps/web/lib/plugins/types.ts`, `docs/plans/plugins/PLUGIN-API.md`, and
|
||||
`docs/public/plugins-authoring.md` with the mobile `main-top-bar` sizing and overflow contract.
|
||||
- State that phone contributions join the middle scroll strip. Host UI icon buttons use the native
|
||||
32px box and 16px icon.
|
||||
- This public page is a reference document. No new page or navigation entry is required.
|
||||
|
||||
## Mobile design contract
|
||||
|
||||
- **Desktop outcome:** desktop Home, Kanban, and Tasks headers keep their current layout and sizes.
|
||||
- **Mobile entry point:** the existing Home and Tasks topbar remains the direct entry point.
|
||||
- **Nearest exemplars:** the mobile chat-input toolbar supplies the fade. The task sidebar supplies
|
||||
conditional cue measurement after content and viewport changes.
|
||||
- **Hierarchy:** Kandev is fixed on the left. Menu is fixed on the right. Page context and workspace
|
||||
actions scroll between them, align against the menu when they fit, and retain their current order.
|
||||
- **Presentation:** the actions stay inline because they are frequent, shallow controls. A drawer
|
||||
hides direct actions and adds an extra interaction.
|
||||
- **Scroll owner:** only the middle strip scrolls horizontally. The document and board do not gain
|
||||
horizontal overflow.
|
||||
- **Touch and state:** the visible boxes match the existing compact search and menu chrome. Launch,
|
||||
search, metrics, plugin, and menu state remain shared with wider layouts. Quick Terminal and Quick
|
||||
Chat keep a 44px coarse-pointer hit area around their 32px visible boxes.
|
||||
- **Mobile proof:** Pixel 5 coverage enables metrics, installs the real plugin fixture, and proves
|
||||
control equality, fixed edges, directional fades, contained scrolling, and action reachability.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** native mobile controls use one visual size and retain their order and handlers.
|
||||
**File:** `apps/web/components/kanban/kanban-header-mobile.test.tsx`.
|
||||
**How:** render the real header actions through the existing mocks. Assert shared size classes,
|
||||
the fixed-menu boundary, the title's scroll ownership, action order, and launcher callbacks.
|
||||
- **What:** fades reflect the available scroll direction and react to size changes.
|
||||
**File:** `apps/web/components/kanban/mobile-topbar-action-strip.test.tsx`.
|
||||
**How:** use controlled `clientWidth`, `scrollWidth`, and `scrollLeft` values with a mock
|
||||
`ResizeObserver`. Cover fitting content, initial overflow, middle scroll, and end scroll.
|
||||
- **What:** mobile plugin and metric wrappers normalize icon geometry without changing desktop.
|
||||
**Files:** `apps/web/components/kanban/main-top-bar-plugin-actions.test.tsx` and
|
||||
`apps/web/components/system-metrics/topbar-metrics.test.tsx`.
|
||||
**How:** render host UI plugin buttons and metric icons. Assert the mobile wrapper contract and
|
||||
unchanged desktop classes.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** GIVEN metrics and a real plugin contribution that overflow the phone header, WHEN
|
||||
Home renders, THEN Kandev and menu stay fixed while the middle strip scrolls with correct fades.
|
||||
**File:** `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`.
|
||||
**What to verify:** equal native button boxes, 32px metrics height, 16px metric icons, actual
|
||||
44px terminal/chat hit areas, initial and final fade states, successful search or launcher
|
||||
activation after scrolling, and no document horizontal overflow. Restore user settings and
|
||||
uninstall the fixture in `afterEach`.
|
||||
- **Scenario:** GIVEN the existing mobile terminal flow, WHEN the launcher renders, THEN its visible
|
||||
box matches search, chat, and menu instead of the old 44px expectation.
|
||||
**File:** `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts`.
|
||||
**What to verify:** relational header geometry while retaining the existing dialog, menu-row,
|
||||
terminal containment, scrolling, dismissal, and focus-return assertions.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- Focused Vitest: 5 files and 20 tests passed after the hit-target review fix.
|
||||
- Plugin SDK typecheck: passed.
|
||||
- Focused ESLint: passed with no errors or warnings on the changed frontend files.
|
||||
- Web typecheck: passed.
|
||||
- Public documentation tests: 61 passed, 0 failed; validator covered 41 published pages.
|
||||
- Managed Pixel 5 E2E: 1 test passed for the real plugin and metrics overflow scenario, including
|
||||
actual 44px terminal/chat hit-target checks when each action is in view.
|
||||
- Managed Pixel 5 quick-terminal regression: 1 test passed with relational header geometry.
|
||||
- Follow-up alignment fix: fitting action rows use right justification against the fixed menu; the
|
||||
focused action-strip suite passed 3 tests and focused ESLint passed.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [x] [Task 01: Build the mobile action strip](task-01-mobile-action-strip.md)
|
||||
|
||||
Wave 2:
|
||||
|
||||
- [x] [Task 02: Prove mobile overflow behavior](task-02-mobile-overflow-e2e.md)
|
||||
|
||||
The tasks run sequentially. The E2E task depends on the component and contract changes.
|
||||
|
||||
## Risks
|
||||
|
||||
- Plugin components can render arbitrary markup. Host-enforced normalization applies to
|
||||
`host.ui.Button` and SVG contributions, which are the documented plugin path.
|
||||
- Metrics and plugin contributions can appear after first paint. The observer must measure both the
|
||||
viewport and content row so the fades do not become stale.
|
||||
- The fixed menu status dot must remain visible and clickable outside the fading scroll viewport.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
id: "01-mobile-action-strip"
|
||||
title: "Build the mobile action strip"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mobile-quick-chat-topbar/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Build the mobile action strip
|
||||
|
||||
## Intent
|
||||
|
||||
Make the phone Home and Tasks action region compact and horizontally scrollable. Keep Kandev and
|
||||
the menu fixed. Normalize native, metric, and documented plugin icon geometry.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Terminal, chat, search, and menu use 32px visible boxes with 16px icons. Topbar metric and
|
||||
documented plugin icons also use 16px icons. Terminal and chat retain a 44px coarse-pointer hit
|
||||
area without widening their visible boxes.
|
||||
- Page context, plugin actions, metrics, terminal, chat, and search use one middle strip. Kandev and
|
||||
menu stay outside that scroll owner.
|
||||
- Each directional fade appears only while content remains hidden beyond its edge.
|
||||
- Desktop and tablet header geometry, state, handlers, and existing public plugin slot fields remain
|
||||
unchanged. The mobile presentation is exported from the public plugin SDK.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/kanban/mobile-topbar-action-strip.tsx`
|
||||
- `apps/web/components/kanban/mobile-topbar-action-strip.test.tsx`
|
||||
- `apps/web/components/kanban/kanban-header-mobile.tsx`
|
||||
- `apps/web/components/kanban/kanban-header-mobile.test.tsx`
|
||||
- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx`
|
||||
- `apps/web/components/kanban/main-top-bar-plugin-actions.test.tsx`
|
||||
- `apps/web/components/system-metrics/topbar-metrics.tsx`
|
||||
- `apps/web/components/system-metrics/topbar-metrics.test.tsx`
|
||||
- `apps/web/lib/plugins/types.ts`
|
||||
- `apps/packages/plugin-sdk/src/index.ts`
|
||||
- `apps/web/lib/plugins/sdk-contract.test.ts`
|
||||
- `docs/plans/plugins/PLUGIN-API.md`
|
||||
- `docs/public/plugins-authoring.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. The component, tests, and public contract describe one shared layout behavior.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec: `What`, `Scenarios`, and `Out of scope` in
|
||||
`docs/specs/mobile-quick-chat-topbar/spec.md`.
|
||||
- Plan: `Confirmed root cause`, `Frontend`, `Mobile design contract`, and `Tests` in `plan.md`.
|
||||
- Fade exemplar: `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx`.
|
||||
- Resize exemplar: `apps/web/components/task/task-sidebar-scroll-area.tsx`.
|
||||
- Shared button sizes: `apps/packages/ui/src/button.tsx`.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add failing tests for shared sizes, fixed boundaries, title ownership, and directional fades.
|
||||
2. Run the focused tests and record the expected failures.
|
||||
3. Add the smallest strip, sizing, and wrapper changes that make the tests pass.
|
||||
4. Update the plugin reference contract and run the public-doc checks.
|
||||
5. Run the focused tests, lint, and type check.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps && rtk pnpm install --frozen-lockfile)
|
||||
(cd apps && rtk pnpm --filter @kandev/web test -- --run components/kanban/mobile-topbar-action-strip.test.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/system-metrics/topbar-metrics.test.tsx)
|
||||
(cd apps && rtk pnpm --filter @kandev/web exec eslint components/kanban/mobile-topbar-action-strip.tsx components/kanban/mobile-topbar-action-strip.test.tsx components/kanban/kanban-header-mobile.tsx components/kanban/kanban-header-mobile.test.tsx components/kanban/main-top-bar-plugin-actions.tsx components/kanban/main-top-bar-plugin-actions.test.tsx components/system-metrics/topbar-metrics.tsx components/system-metrics/topbar-metrics.test.tsx lib/plugins/types.ts)
|
||||
(cd apps/web && rtk pnpm run typecheck)
|
||||
rtk node --test scripts/validate-public-docs.test.mjs
|
||||
rtk node scripts/validate-public-docs.mjs
|
||||
```
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed behavior, files, RED and GREEN results, public-doc result, blockers, and risks.
|
||||
Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Added the resize-aware mobile action strip with conditional directional fades.
|
||||
- Kept Kandev and the menu outside the scroll owner, and placed page context, plugin actions,
|
||||
metrics, terminal, chat, and search inside it.
|
||||
- Normalized native, metric, and documented host plugin icon geometry without changing desktop
|
||||
plugin presentation or existing slot data.
|
||||
- Updated the plugin API reference and public authoring guide with the mobile contract.
|
||||
- TDD RED: the new focused suite initially failed 9 assertions against the old layout.
|
||||
- TDD GREEN: focused Vitest passed 4 files and 19 tests.
|
||||
- Review-fix GREEN: focused Vitest passed 5 files and 20 tests after replacing clipped pseudo-element
|
||||
hit areas with real 44px interaction wrappers around the 32px launcher buttons.
|
||||
- Focused ESLint, web typecheck, and public documentation validation passed.
|
||||
- No blockers. The E2E task provides the remaining production-build coverage.
|
||||
- Review fixup: exported `MainTopBarSlotProps` from the public SDK, kept the host type canonical, and
|
||||
added a Pixel 5 `elementFromPoint` regression for the 44px terminal/chat hit areas.
|
||||
- Follow-up alignment fix: fitting action rows right-align against the fixed menu while overflowing
|
||||
rows retain their contained horizontal scroll behavior.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: "02-mobile-overflow-e2e"
|
||||
title: "Prove mobile overflow behavior"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ["01-mobile-action-strip"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/mobile-quick-chat-topbar/spec.md"
|
||||
---
|
||||
|
||||
# Task 02: Prove mobile overflow behavior
|
||||
|
||||
## Intent
|
||||
|
||||
Prove the phone header with real metrics and plugin content. Replace the obsolete 44px launcher
|
||||
expectation with relational geometry while preserving the complete terminal flow.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Pixel 5 coverage proves the Kandev link and menu stay fixed while middle actions scroll.
|
||||
- The test proves native control equality, metric and plugin icon sizing, directional fades, and no
|
||||
document horizontal overflow.
|
||||
- A scrolled action remains operable. Existing Quick Terminal dialog and focus behavior still pass.
|
||||
- The test restores user settings, uninstalls the plugin fixture, and leaves no test-owned state.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`
|
||||
- `apps/web/e2e/tests/terminal/mobile-quick-terminal.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Task 01 must be complete.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. This task verifies Task 01 against the production build.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Spec scenarios for equal sizing, fixed edges, directional fades, and contained overflow.
|
||||
- Plan section `E2E Tests`.
|
||||
- Plugin install and cleanup pattern in `apps/web/e2e/tests/plugins/mobile-plugin-nav.spec.ts`.
|
||||
- User-settings cleanup rules in `.agents/skills/e2e/references/ui-state-and-cleanup.md` and
|
||||
`apps/web/AGENTS.md`.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add the mobile plugin topbar scenario and update the old terminal geometry assertion.
|
||||
2. Run the focused specs against the current implementation and record the expected failures.
|
||||
3. Complete any minimal selector or geometry correction required by the specification.
|
||||
4. Run both focused specs against a fresh production build with retries disabled.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
(cd apps && rtk pnpm install --frozen-lockfile)
|
||||
(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/plugins/mobile-plugin-topbar.spec.ts -- --retries=0)
|
||||
(cd apps/web && rtk pnpm e2e:run --no-build --project mobile-chrome tests/terminal/mobile-quick-terminal.spec.ts -- --retries=0)
|
||||
```
|
||||
|
||||
## Output contract
|
||||
|
||||
Report test discovery counts, RED and GREEN results, artifact paths, cleanup evidence, blockers, and
|
||||
risks. Update this task and `plan.md` in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- Added `mobile-plugin-topbar.spec.ts` to the existing `mobile-chrome` Pixel 5 project.
|
||||
- The test installs the real fixture package, enables real metrics, checks native and plugin
|
||||
geometry, verifies the actual 44px terminal/chat hit areas, all directional fade states, scroll
|
||||
reachability, fixed edge positions, and document overflow, then restores settings and removes the
|
||||
fixture.
|
||||
- Updated the existing quick-terminal test to compare launcher geometry with the fixed menu instead
|
||||
of requiring the obsolete 44px launcher size.
|
||||
- Initial runner attempt caught a duplicate local variable in the edited test and was corrected
|
||||
before the production run.
|
||||
- Managed production E2E: 1 plugin topbar test passed with retries disabled.
|
||||
- Managed no-build regression E2E: 1 quick-terminal test passed with retries disabled.
|
||||
- Follow-up production-build E2E: 1 plugin topbar test passed with actual 44px hit-target checks;
|
||||
the quick-terminal regression also passed against that build with no rebuild.
|
||||
- No PR screenshot artifacts were created by these test runs.
|
||||
@@ -719,9 +719,14 @@ interface PluginRegistry {
|
||||
// carry the active session plus every kandev session id on the task.
|
||||
// "main-top-bar" renders status/actions in the default app top bar on the
|
||||
// Home / Kanban / Tasks views (beside the CPU/DB metrics and the view/display
|
||||
// controls) and forwards `{ workspaceId, workspaceLabel, currentPage }`. It is
|
||||
// the app-wide, task-agnostic counterpart to "chat-top-bar", so it carries no
|
||||
// task/session ids.
|
||||
// controls) and forwards `{ workspaceId, workspaceLabel, currentPage,
|
||||
// presentation }`, where presentation is "desktop" or "mobile". On a phone,
|
||||
// contributions join the horizontally scrollable middle action strip between
|
||||
// the fixed Kandev link and menu button. Documented host ui.Button icon
|
||||
// contributions are normalized to a 32px box with a 16px SVG icon on phones;
|
||||
// desktop contribution sizing is unchanged. It is the app-wide,
|
||||
// task-agnostic counterpart to "chat-top-bar", so it carries no task/session
|
||||
// ids.
|
||||
// "sidebar-workspace-actions" renders icon buttons after the built-in Quick
|
||||
// Terminal and Quick Chat actions in the desktop sidebar's New Task row and
|
||||
// in the shared phone navigation sheet. It forwards
|
||||
|
||||
@@ -1524,12 +1524,18 @@ type MainTopBarSlotProps = {
|
||||
workspaceId: string | null; // workspace the top bar is showing, null on global home
|
||||
workspaceLabel?: string; // human-readable workspace name, when known
|
||||
currentPage: "kanban" | "tasks";
|
||||
presentation: "desktop" | "mobile";
|
||||
};
|
||||
```
|
||||
|
||||
Because the bar is not scoped to a task, no task/session ids are provided. Like
|
||||
`chat-top-bar` it is a compact horizontal strip, so keep contributions to small
|
||||
badges or `h-7` buttons that match the native metric chips.
|
||||
badges or icon buttons. On a phone, `presentation` is `"mobile"`; the
|
||||
contribution joins the horizontally scrollable middle strip between the fixed
|
||||
Kandev link and menu button. Use `host.ui.Button` for documented icon actions.
|
||||
The host normalizes those buttons to a 32px box and their SVG icons to 16px.
|
||||
Do not add a second horizontal scroll container. Desktop contributions keep
|
||||
their existing sizing.
|
||||
|
||||
```js
|
||||
// inside initialize(registry, host):
|
||||
|
||||
@@ -1,48 +1,75 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-07-17
|
||||
updated: 2026-08-18
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Mobile Quick Chat Topbar
|
||||
# Mobile Workspace Topbar Actions
|
||||
|
||||
## Why
|
||||
|
||||
Mobile users cannot discover Quick Chat from the board header and must rely on a keyboard
|
||||
shortcut or menu path. The same header also repeats `Home` beside the Kandev wordmark even
|
||||
though the wordmark can provide the home navigation affordance.
|
||||
Mobile users need direct access to workspace actions from the Home and Tasks headers. The header
|
||||
must remain usable when resource metrics and plugin actions add more controls than a phone can fit.
|
||||
|
||||
## What
|
||||
|
||||
- On mobile Home and Tasks headers with an active workspace, an icon button labeled
|
||||
`Quick Chat` appears immediately before the task-search button.
|
||||
- Activating the button opens Quick Chat for the active workspace. Existing workspace chats
|
||||
- On mobile Home and Tasks headers with an active workspace, `Quick Terminal` appears before
|
||||
`Quick Chat`. `Quick Chat` appears immediately before the task-search button.
|
||||
- Activating `Quick Chat` opens Quick Chat for the active workspace. Existing workspace chats
|
||||
are available in the dialog, and its existing new-chat action remains available.
|
||||
- Activating `Quick Terminal` opens the active workspace's terminal in the shared Quick Chat
|
||||
surface.
|
||||
- The mobile Kandev wordmark is a link to the active workspace's Home board.
|
||||
- The Home header does not render a separate `Home` label. Non-Home headers retain their
|
||||
existing page-title visibility while keeping the Kandev home link available.
|
||||
- The tablet header and mobile task switcher retain the Quick Chat entry points supplied by
|
||||
the underlying mobile-access change.
|
||||
- The Kandev link is the only fixed content at the left edge. The menu button is the only fixed
|
||||
content at the right edge.
|
||||
- A non-Home title, its workspace label, plugin actions, resource metrics, `Quick Terminal`,
|
||||
`Quick Chat`, and search use one horizontal strip between the fixed controls.
|
||||
- When the strip content fits, its actions are aligned against the fixed menu on the right. The
|
||||
middle strip scrolls horizontally only when its content does not fit. It does not add horizontal
|
||||
scrolling to the page.
|
||||
- A fade appears at each strip edge only when more actions exist beyond that edge. The fade
|
||||
disappears when the strip reaches that edge.
|
||||
- Native icon buttons in this header use the same 32 by 32 CSS-pixel visible box and 16 CSS-pixel
|
||||
icon. Resource metric and host-rendered plugin icons use the same 16 CSS-pixel icon size.
|
||||
- The resource metrics region uses the same 32 CSS-pixel height as the icon buttons.
|
||||
- Quick Terminal and Quick Chat retain a 44 CSS-pixel coarse-pointer hit area around their 32
|
||||
CSS-pixel visible boxes without widening the action-strip items.
|
||||
- The tablet header and mobile task switcher retain their existing Quick Chat entry points.
|
||||
- Desktop headers do not change.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a mobile Home header with an active workspace, **WHEN** the header renders, **THEN**
|
||||
the `Quick Chat` button appears immediately to the left of `Search tasks` and no separate
|
||||
`Home` label is shown.
|
||||
the terminal, chat, search, and menu controls use the same visible size and no separate `Home`
|
||||
label is shown.
|
||||
- **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Chat`, **THEN** the active
|
||||
workspace's Quick Chat dialog opens and the user can access existing chats or start a new one.
|
||||
- **GIVEN** a mobile Home header, **WHEN** the user activates `Quick Terminal`, **THEN** the active
|
||||
workspace's terminal opens in the shared Quick Chat surface.
|
||||
- **GIVEN** resource metrics and plugin actions that exceed the middle strip width, **WHEN** the
|
||||
mobile header renders, **THEN** the Kandev link and menu remain fixed while the strip scrolls.
|
||||
- **GIVEN** a scrollable middle strip, **WHEN** hidden actions exist to the left or right, **THEN**
|
||||
only the corresponding directional fade is visible.
|
||||
- **GIVEN** a middle strip whose actions fit, **WHEN** the mobile header renders, **THEN** its
|
||||
actions align against the menu, no overflow fade is visible, and the document has no horizontal
|
||||
overflow.
|
||||
- **GIVEN** a mobile non-Home workbench page, **WHEN** the user activates the Kandev wordmark,
|
||||
**THEN** the app navigates to that workspace's Home board.
|
||||
- **GIVEN** no active workspace, **WHEN** the mobile header renders, **THEN** it does not show an
|
||||
unusable Quick Chat button.
|
||||
unusable Quick Chat or Quick Terminal button.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to Quick Chat creation, persistence, session selection, or modal layout.
|
||||
- Changes to Quick Terminal lifecycle or terminal layout.
|
||||
- Changes to desktop topbars.
|
||||
- Changes to search, menu, metrics, or floating task-creation behavior.
|
||||
- Changes to tablet header sizing.
|
||||
- Changes to task-session topbars, mobile bottom navigation, or floating task creation.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
[Mobile Quick Chat Topbar implementation](../../plans/mobile-quick-chat-topbar/plan.md)
|
||||
- [Original Quick Chat topbar implementation](../../plans/mobile-quick-chat-topbar/plan.md)
|
||||
- [Scrollable mobile topbar repair](../../plans/mobile-topbar-action-strip/plan.md)
|
||||
|
||||
Reference in New Issue
Block a user