fix(web): make mobile topbar actions scrollable (#2769)

This commit is contained in:
Carlos Florêncio
2026-08-18 17:18:32 +00:00
committed by GitHub
parent b798c6e98d
commit 6ca94f6e10
21 changed files with 1028 additions and 108 deletions
@@ -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
+8
View File
@@ -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");
});
+7 -1
View File
@@ -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.
+8 -3
View File
@@ -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
+7 -1
View File
@@ -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):
+41 -14
View File
@@ -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)