fix(ui): move task plugins into mobile menu

Move task plugin actions and destinations into the shared mobile Plugins menu, preserving a compact task header and accessible touch targets. Add focused mobile coverage, plugin authoring documentation, and process-probe stability handling.
This commit is contained in:
João Salavisa
2026-09-23 08:14:51 +01:00
committed by GitHub
parent c4262c644d
commit 545cd0b599
24 changed files with 1077 additions and 151 deletions
@@ -281,6 +281,48 @@
);
}
function ChatTopBarStatus(props) {
var slotProps = props.slotProps || {};
if (slotProps.presentation !== "mobile") return null;
return jsx(
"span",
{
"data-testid": "e2e-chat-top-bar-status",
"data-task-id": slotProps.taskId || "",
"data-workspace-id": slotProps.workspaceId || "",
"data-active-session-id": slotProps.activeSessionId || "",
"data-session-ids": (slotProps.sessionIds || []).join(","),
"data-presentation": slotProps.presentation || "unknown",
className:
"min-w-0 max-w-full truncate rounded-md border px-3 py-2 text-xs text-muted-foreground",
},
"Fixture archive synchronization is ready",
);
}
function ChatTopBarAction(props) {
var slotProps = props.slotProps || {};
var activeState = React.useState(false);
var active = activeState[0];
var setActive = activeState[1];
if (slotProps.presentation !== "mobile") return null;
return jsx(
ui.Button,
{
type: "button",
variant: "outline",
className: "cursor-pointer",
"data-testid": "e2e-chat-top-bar-action",
"data-presentation": slotProps.presentation || "unknown",
"data-activated": active ? "true" : "false",
onClick: function () {
setActive(true);
},
},
active ? "Fixture action complete" : "Run fixture task action",
);
}
// Debounce delay for the Notes panel's autosave — short, so e2e specs
// don't need to wait long for a write to reach host.storage.
var NOTES_SAVE_DEBOUNCE_MS = 150;
@@ -775,6 +817,8 @@
registry.registerRoute(FIXTURE_HELLO_PATH, PluginPage);
registry.registerComponent("task-sidebar", SidebarSlot);
registry.registerComponent("main-top-bar", MainTopBarSlot);
registry.registerComponent("chat-top-bar", ChatTopBarStatus);
registry.registerComponent("chat-top-bar", ChatTopBarAction);
registry.registerComponent("app-status-bar-left", StatusSlot);
registry.registerComponent("app-status-bar-right", StatusSlot);
registry.registerWsHandler("task.created", function () {
@@ -3,10 +3,12 @@
package probe
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
)
@@ -92,7 +94,7 @@ func linuxBootTime() (time.Time, error) {
func readLinuxProcessStat(pid int, bootTime time.Time) (processInfo, bool, error) {
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
if err != nil {
if os.IsNotExist(err) {
if isLinuxProcessGone(err) {
return processInfo{}, false, nil
}
return processInfo{}, false, err
@@ -129,3 +131,7 @@ func readLinuxProcessStat(pid int, bootTime time.Time) (processInfo, bool, error
Zombie: fields[0] == linuxZombieState,
}, true, nil
}
func isLinuxProcessGone(err error) bool {
return os.IsNotExist(err) || errors.Is(err, syscall.ESRCH)
}
@@ -0,0 +1,50 @@
//go:build linux
package probe
import (
"errors"
"os"
"syscall"
"testing"
)
func TestIsLinuxProcessGone(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want bool
}{
{
name: "missing proc entry",
err: &os.PathError{Op: "read", Path: "/proc/123/stat", Err: syscall.ENOENT},
want: true,
},
{
name: "process exited during read",
err: &os.PathError{Op: "read", Path: "/proc/123/stat", Err: syscall.ESRCH},
want: true,
},
{
name: "permission failure",
err: &os.PathError{Op: "read", Path: "/proc/123/stat", Err: syscall.EACCES},
want: false,
},
{
name: "unrelated failure",
err: errors.New("read failed"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := isLinuxProcessGone(tt.err); got != tt.want {
t.Fatalf("isLinuxProcessGone() = %v, want %v", got, tt.want)
}
})
}
}
+19
View File
@@ -38,6 +38,25 @@ export interface MainTopBarSlotProps {
presentation: "desktop" | "mobile";
}
/**
* Context for the `chat-top-bar` slot. Phone task contributions live in the
* shared menu; tablet and desktop contributions remain inline in the top bar.
*/
export interface ChatTopBarSlotProps {
/** Task the top bar belongs to, or null before one exists. */
taskId: string | null;
/** Display title of the task, when known. */
taskTitle?: string;
/** Workspace the task lives in, when known. */
workspaceId: string | null;
/** Session the top bar is currently bound to, or null before one exists. */
activeSessionId: string | null;
/** Every Kandev session id on the task, including `activeSessionId`. */
sessionIds: string[];
/** Host surface that mounted the contribution. */
presentation: "desktop" | "mobile";
}
/**
* Context passed to components registered for the `chat-submit-decoration`
* slot, which renders *over* the chat composer's send button rather than
@@ -124,6 +124,8 @@ type AppNavSectionsProps = {
omitDestinations?: string[];
/** Optional workspace-scoped plugin actions for the current phone surface. */
workspaceActions?: ReactNode;
/** Optional page-scoped controls rendered with plugin destinations. */
pluginActions?: ReactNode;
afterPrimary?: ReactNode;
quickActions?: ReactNode;
phoneNavigation?: boolean;
@@ -141,6 +143,7 @@ export function AppNavSections({
omitSections = [],
omitDestinations = [],
workspaceActions,
pluginActions,
afterPrimary,
quickActions,
phoneNavigation = false,
@@ -181,8 +184,12 @@ export function AppNavSections({
)}
{afterPrimary}
{workspaceActions}
{!hasSavedSidebarLayout && !omit.has("plugins") && (
<MobilePluginNavSection onNavigate={onNavigate} />
{!omit.has("plugins") && (
<MobilePluginNavSection
actions={pluginActions}
includeDestinations={!hasSavedSidebarLayout}
onNavigate={onNavigate}
/>
)}
{!hasSavedSidebarLayout && !omit.has("integrations") && (
<MobileIntegrationsSection
@@ -181,6 +181,14 @@ function verifyMenuOrder() {
).toBe(true);
}
function resetAppNavMocks() {
healthHasIssues = false;
resolvedTheme = "light";
navRegistrations = [];
workspaceActionsRegistrations = [];
vi.clearAllMocks();
}
describe("AppNavSheet", () => {
// @covers AC-UI-MOBILE-MENU-007.1 AC-UI-MOBILE-MENU-007.2
it("puts quick actions before local navigation and Settings before Stats", () => {
@@ -194,13 +202,7 @@ describe("AppNavSheet", () => {
host.rerender(<SectionsHost />);
expect(screen.queryByRole("button", { name: "Task views" })).toBeNull();
});
beforeEach(() => {
healthHasIssues = false;
resolvedTheme = "light";
navRegistrations = [];
workspaceActionsRegistrations = [];
vi.clearAllMocks();
});
beforeEach(resetAppNavMocks);
afterEach(cleanup);
it("opens from the trigger and offers the manifest destinations plus pageNav", () => {
@@ -292,6 +294,24 @@ describe("AppNavSheet", () => {
});
});
describe("AppNavSheet plugin actions", () => {
beforeEach(resetAppNavMocks);
afterEach(cleanup);
it("places page-scoped actions in the shared Plugins section", () => {
render(
<AppNavSheet
pluginActions={<button data-testid="session-plugin-action">Session action</button>}
/>,
);
fireEvent.click(screen.getByTestId(NAV_TRIGGER));
const section = screen.getByTestId("mobile-plugin-nav-section");
expect(section.contains(screen.getByTestId("session-plugin-action"))).toBe(true);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
});
});
describe("AppNavSections", () => {
beforeEach(() => {
healthHasIssues = false;
@@ -28,19 +28,20 @@ import { linkToTask, replaceTaskUrl } from "@/lib/links";
type AppNavSheetProps = {
pageNav?: ReactNode | ((close: () => void) => ReactNode);
/** Page-scoped plugin controls grouped with plugin navigation on phones. */
pluginActions?: ReactNode;
omitDestinations?: string[];
/** Reuse a workbench's existing task picker and selection controller. */
onOpenTaskViews?: () => void;
};
/** Shared phone app navigation; wider page shells retain their side sheet. */
export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppNavSheetProps) {
export function AppNavSheet(props: AppNavSheetProps) {
const { pageNav, pluginActions, omitDestinations, onOpenTaskViews } = props;
const { isMobile } = useResponsiveBreakpoint();
const pathname = usePathname();
const inOffice = useInOffice();
const workspace = useAppStore((s) =>
s.workspaces.items.find((w) => w.id === s.workspaces.activeId),
);
const workspace = useActiveNavigationWorkspace();
const [open, setOpen] = useState(false);
const opener = useRef<HTMLButtonElement | null>(null);
const restoreFocus = useRef(true);
@@ -48,9 +49,6 @@ export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppN
const controls = useAppNavDialogs(close, onOpenTaskViews);
const renderedPageNav = typeof pageNav === "function" ? pageNav(close) : pageNav;
const currentPage = listingPageForPath(pathname);
const closeOnLinkClick = (event: MouseEvent<HTMLElement>) => {
if (event.target instanceof Element && event.target.closest("a[href]")) close();
};
return (
<>
@@ -71,7 +69,7 @@ export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppN
>
<nav
className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain p-4 pb-[max(1rem,env(safe-area-inset-bottom,0px))]"
onClick={closeOnLinkClick}
onClick={(event) => closeMenuOnLinkClick(event, close)}
>
{isMobile && <NavigationWorkspacePicker close={close} />}
{!isMobile && renderedPageNav}
@@ -127,6 +125,7 @@ export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppN
/>
</>
}
pluginActions={pluginActions}
controls={isMobile ? { ...controls, openTaskViews: undefined } : controls}
/>
</nav>
@@ -136,6 +135,16 @@ export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppN
);
}
function useActiveNavigationWorkspace() {
return useAppStore((state) =>
state.workspaces.items.find((workspace) => workspace.id === state.workspaces.activeId),
);
}
function closeMenuOnLinkClick(event: MouseEvent<HTMLElement>, close: () => void) {
if (event.target instanceof Element && event.target.closest("a[href]")) close();
}
function NavigationWorkspacePicker({ close }: { close: () => void }) {
const { t } = useTranslation();
return (
@@ -1,30 +1,71 @@
import { cleanup, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { MobilePluginNavSection } from "./mobile-plugin-nav-section";
const HELLO_PATH = "/plugins/hello";
const HELLO_ITEM_TEST_ID = "mobile-plugin-nav-item-hello";
vi.mock("@/lib/routing/client-router", () => ({
usePathname: () => "/",
}));
function renderSection(onNavigate = () => {}) {
return render(<MobilePluginNavSection onNavigate={onNavigate} />);
function renderSection(onNavigate = () => {}, actions?: ReactNode, includeDestinations = true) {
return render(
<MobilePluginNavSection
actions={actions}
includeDestinations={includeDestinations}
onNavigate={onNavigate}
/>,
);
}
describe("MobilePluginNavSection", () => {
afterEach(() => {
cleanup();
["plugin-a", "plugin-b"].forEach((id) => pluginRegistry.unregisterPlugin(id));
window.history.pushState({}, "", "/");
});
afterEach(() => {
cleanup();
["plugin-a", "plugin-b"].forEach((id) => pluginRegistry.unregisterPlugin(id));
window.history.pushState({}, "", "/");
});
describe("MobilePluginNavSection actions", () => {
it("renders nothing when no plugin has registered a nav item", () => {
const { container } = renderSection();
expect(container.innerHTML).toBe("");
});
it("renders page actions without requiring a plugin destination", () => {
const onNavigate = vi.fn();
renderSection(
onNavigate,
<button type="button" data-testid="session-plugin-action">
Session action
</button>,
);
const section = screen.getByTestId("mobile-plugin-nav-section");
const action = screen.getByTestId("session-plugin-action");
expect(section.contains(action)).toBe(true);
action.click();
expect(onNavigate).not.toHaveBeenCalled();
});
it("combines page actions and destinations under one Plugins heading", () => {
pluginRegistry
.forPlugin("plugin-a")
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
renderSection(() => {}, <span data-testid="session-plugin-status">Connected</span>);
expect(screen.getAllByText("Plugins")).toHaveLength(1);
const action = screen.getByTestId("session-plugin-status");
const destination = screen.getByTestId(HELLO_ITEM_TEST_ID);
expect(
action.compareDocumentPosition(destination) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
describe("MobilePluginNavSection destinations", () => {
it("renders main-section nav items so plugin pages are reachable on a phone", () => {
pluginRegistry
.forPlugin("plugin-a")
@@ -33,7 +74,7 @@ describe("MobilePluginNavSection", () => {
renderSection();
expect(screen.getByTestId("mobile-plugin-nav-section")).not.toBeNull();
expect(screen.getByTestId("mobile-plugin-nav-item-hello")).not.toBeNull();
expect(screen.getByTestId(HELLO_ITEM_TEST_ID)).not.toBeNull();
expect(screen.getByText("Hello")).not.toBeNull();
});
@@ -97,7 +138,7 @@ describe("MobilePluginNavSection", () => {
renderSection();
expect(
screen.getByTestId("mobile-plugin-nav-item-hello").querySelector("svg.tabler-icon-ticket"),
screen.getByTestId(HELLO_ITEM_TEST_ID).querySelector("svg.tabler-icon-ticket"),
).not.toBeNull();
expect(
screen.getByTestId("mobile-plugin-nav-item-other").querySelector("svg.tabler-icon-puzzle"),
@@ -111,7 +152,7 @@ describe("MobilePluginNavSection", () => {
.registerNavItem({ id: "hello", label: "Hello", path: HELLO_PATH });
renderSection(onNavigate);
screen.getByTestId("mobile-plugin-nav-item-hello").click();
screen.getByTestId(HELLO_ITEM_TEST_ID).click();
expect(window.location.pathname).toBe(HELLO_PATH);
expect(onNavigate).toHaveBeenCalledTimes(1);
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { resolveDestinations } from "@/lib/navigation/resolve-destinations";
@@ -9,6 +10,10 @@ import { usePluginRegistry } from "@/lib/plugins/registry";
type MobilePluginNavSectionProps = {
/** Closes the surrounding menu sheet once a plugin page is opened. */
onNavigate: () => void;
/** Page-scoped plugin status and controls for this menu. */
actions?: ReactNode;
/** Custom sidebar layouts already place plugin destinations themselves. */
includeDestinations?: boolean;
};
/**
@@ -19,29 +24,38 @@ type MobilePluginNavSectionProps = {
* entry point at all. "integrations"-section items keep rendering inside
* `MobileIntegrationsSection`.
*/
export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionProps) {
export function MobilePluginNavSection({
onNavigate,
actions,
includeDestinations = true,
}: MobilePluginNavSectionProps) {
const { t } = useTranslation();
const registry = usePluginRegistry();
// Resolved directly rather than through `useStaticDestinations`: this group's
// hrefs are static plugin paths, so it needs neither workspace context nor the
// availability subscription. Matches the desktop rail in `plugin-nav-items.tsx`.
const destinations = resolveDestinations({
surface: "mobileMenu",
section: "plugins",
ctx: NO_WORKSPACE_CONTEXT,
pluginItems: registry.getNavRegistrations(),
});
const destinations = includeDestinations
? resolveDestinations({
surface: "mobileMenu",
section: "plugins",
ctx: NO_WORKSPACE_CONTEXT,
pluginItems: registry.getNavRegistrations(),
})
: [];
if (destinations.length === 0) return null;
if (!actions && destinations.length === 0) return null;
return (
<div className="space-y-3" data-testid="mobile-plugin-nav-section">
<div className="text-sm font-medium">{t("common:plugins")}</div>
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="mobile-plugin-nav-item-"
/>
{actions}
{destinations.length > 0 && (
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
pluginTestIdPrefix="mobile-plugin-nav-item-"
/>
)}
</div>
);
}
@@ -25,6 +25,7 @@ vi.mock("@/components/task/port-forward-dialog", () => ({
vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({
TaskTopBarPluginActions: () => null,
useHasTaskTopBarPluginActions: () => false,
}));
vi.mock("@/components/gitlab/mr-topbar-button", () => ({
@@ -1,7 +1,10 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionMobileTopBar } from "./session-mobile-top-bar";
const mocks = vi.hoisted(() => ({ pluginActions: undefined as unknown }));
vi.mock("@/hooks/domains/session/use-session-git-status", () => ({
useSessionGitStatus: () => null,
}));
@@ -11,12 +14,21 @@ vi.mock("@/hooks/domains/session/use-session-commits", () => ({
vi.mock("@/components/gitlab/mr-topbar-button", () => ({ MRTopbarButton: () => null }));
vi.mock("@/components/task/port-forward-dialog", () => ({ PortForwardButton: () => null }));
vi.mock("@/components/task/task-top-bar-plugin-actions", () => ({
TaskTopBarPluginActions: () => null,
TaskTopBarPluginActions: ({ presentation }: { presentation?: string }) => (
<div data-presentation={presentation} data-testid="task-top-bar-plugin-actions" />
),
useHasTaskTopBarPluginActions: () => true,
}));
vi.mock("@/components/navigation/app-nav-sheet", () => ({
AppNavSheet: () => <button aria-label="Open navigation menu" />,
AppNavSheet: ({ pluginActions }: { pluginActions?: ReactNode }) => {
mocks.pluginActions = pluginActions;
return <button aria-label="Open navigation menu" />;
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
mocks.pluginActions = undefined;
});
describe("phone task navigation controls", () => {
// @covers AC-UI-MOBILE-MENU-002.1, AC-UI-MOBILE-MENU-002.3
@@ -55,4 +67,22 @@ describe("phone task navigation controls", () => {
fireEvent.click(screen.getByRole("button", { name: "Open navigation menu" }));
expect(onTaskPickerClick).not.toHaveBeenCalled();
});
// @covers AC-UI-MOBILE-TASK-CHROME-001.7
it("moves session plugin actions out of the fixed header and into mobile navigation", () => {
render(
<SessionMobileTopBar
taskId="task-1"
workspaceId="workspace-1"
sessionId="session-1"
taskTitle="Fix checkout"
onTaskPickerClick={vi.fn()}
taskPickerOpen={false}
/>,
);
expect(screen.queryByTestId("task-top-bar-plugin-actions")).toBeNull();
const pluginActions = mocks.pluginActions as ReactElement<{ presentation?: string }>;
expect(pluginActions.props.presentation).toBe("mobile");
});
});
@@ -10,7 +10,10 @@ import { LineStat } from "@/components/diff-stat";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import type { FileInfo } from "@/lib/state/slices";
import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
import {
TaskTopBarPluginActions,
useHasTaskTopBarPluginActions,
} from "@/components/task/task-top-bar-plugin-actions";
import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
import { MRTopbarButton } from "@/components/gitlab/mr-topbar-button";
import { PortForwardButton } from "@/components/task/port-forward-dialog";
@@ -225,19 +228,23 @@ function MobileTopBarActions({
onTaskUnarchived,
onTaskPickerClick,
}: MobileTopBarActionsProps) {
const hasPluginActions = useHasTaskTopBarPluginActions();
const pluginActions =
!isArchived && hasPluginActions ? (
<TaskTopBarPluginActions
sessionId={sessionId ?? null}
taskId={taskId ?? null}
taskTitle={taskTitle}
workspaceId={workspaceId ?? null}
presentation="mobile"
/>
) : undefined;
return (
<div className="flex shrink-0 items-center gap-1" data-testid="mobile-topbar-actions">
<MRTopbarButton compact mobile />
{isArchived && <TaskUnarchiveButton taskId={taskId} onUnarchived={onTaskUnarchived} mobile />}
{!isArchived && <PortForwardButton sessionId={sessionId} />}
{!isArchived && (
<TaskTopBarPluginActions
sessionId={sessionId ?? null}
taskId={taskId ?? null}
taskTitle={taskTitle}
workspaceId={workspaceId ?? null}
/>
)}
{isRemoteExecutor && (
<MobileRemoteExecutorIndicator
taskId={taskId}
@@ -251,7 +258,7 @@ function MobileTopBarActions({
/>
)}
{showApproveButton && onApprove && <ApproveButton onApprove={onApprove} />}
<AppNavSheet onOpenTaskViews={onTaskPickerClick} />
<AppNavSheet onOpenTaskViews={onTaskPickerClick} pluginActions={pluginActions} />
</div>
);
}
@@ -1,7 +1,11 @@
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { TaskTopBarPluginActions, type ChatTopBarSlotProps } from "./task-top-bar-plugin-actions";
import {
TaskTopBarPluginActions,
type ChatTopBarSlotProps,
useHasTaskTopBarPluginActions,
} from "./task-top-bar-plugin-actions";
const SLOT = "chat-top-bar";
@@ -22,13 +26,13 @@ vi.mock("@/components/state-provider", () => ({
useOptionalAppStore: (selector: (s: typeof mockState) => unknown) => selector(mockState),
}));
describe("TaskTopBarPluginActions", () => {
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin("plugin-a");
mockState.taskSessionsByTask.itemsByTaskId.t1 = originalSessions;
});
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin("plugin-a");
mockState.taskSessionsByTask.itemsByTaskId.t1 = originalSessions;
});
describe("TaskTopBarPluginActions", () => {
it("renders nothing when no plugin registered a chat-top-bar component", () => {
const { container } = render(
<TaskTopBarPluginActions sessionId="s1" taskId="t1" taskTitle="Demo" workspaceId="w1" />,
@@ -41,7 +45,7 @@ describe("TaskTopBarPluginActions", () => {
const ctx = slotProps as ChatTopBarSlotProps;
return (
<div data-testid="plugin-topbar">
{`${ctx.taskId}|${ctx.workspaceId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}`}
{`${ctx.taskId}|${ctx.workspaceId}|${ctx.activeSessionId}|${ctx.sessionIds.join(",")}|${ctx.presentation}`}
</div>
);
});
@@ -50,7 +54,7 @@ describe("TaskTopBarPluginActions", () => {
<TaskTopBarPluginActions sessionId="s2" taskId="t1" taskTitle="Demo" workspaceId="w1" />,
);
expect(screen.getByTestId("plugin-topbar").textContent).toBe("t1|w1|s2|s1,s2");
expect(screen.getByTestId("plugin-topbar").textContent).toBe("t1|w1|s2|s1,s2|desktop");
});
it("includes the active session id even when the store list omits it", () => {
@@ -106,3 +110,43 @@ describe("TaskTopBarPluginActions", () => {
expect(pluginRender).toHaveBeenCalledTimes(1);
});
});
describe("responsive task top-bar plugin actions", () => {
it("identifies and contains the mobile menu presentation", () => {
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, ({ slotProps }) => {
const ctx = slotProps as ChatTopBarSlotProps;
return <button data-testid="mobile-plugin-action">{ctx.presentation}</button>;
});
render(
<TaskTopBarPluginActions
sessionId="s2"
taskId="t1"
taskTitle="Demo"
workspaceId="w1"
presentation="mobile"
/>,
);
const wrapper = screen.getByTestId("mobile-chat-top-bar-plugin-actions");
expect(wrapper.contains(screen.getByTestId("mobile-plugin-action"))).toBe(true);
expect(screen.getByTestId("mobile-plugin-action").textContent).toBe("mobile");
});
it("reacts when the first or last chat-top-bar registration changes", () => {
function Presence() {
return <span data-testid="slot-presence">{String(useHasTaskTopBarPluginActions())}</span>;
}
render(<Presence />);
expect(screen.getByTestId("slot-presence").textContent).toBe("false");
act(() => {
pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => null);
});
expect(screen.getByTestId("slot-presence").textContent).toBe("true");
act(() => pluginRegistry.unregisterPlugin("plugin-a"));
expect(screen.getByTestId("slot-presence").textContent).toBe("false");
});
});
@@ -3,35 +3,12 @@
import { memo, useCallback, useMemo, useRef } from "react";
import { useOptionalAppStore } from "@/components/state-provider";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginRegistry } from "@/lib/plugins/registry";
import type { ChatTopBarSlotProps } from "@/lib/plugins/types";
import type { AppState } from "@/lib/state/store";
import type { TaskSession } from "@/lib/types/http";
/**
* Props forwarded to every plugin component registered for the `chat-top-bar`
* slot (`registry.registerComponent("chat-top-bar", Component)`). This is the
* session top bar's right-hand cluster, beside the document / editors / debug
* controls — the place for at-a-glance status a
* plugin wants to surface for the current task.
*
* A task can hold several sessions; the top bar is bound to one at a time
* (`activeSessionId`), so both it and the full `sessionIds` list are provided,
* mirroring the `chat-input-actions` slot. These are kandev session ids;
* resolving one to an agent/ACP transcript id (e.g. to key cost data on a
* session) is the plugin's job — do it server-side in the plugin backend
* through the Host data API, not here. See PLUGIN-API.md.
*/
export type ChatTopBarSlotProps = {
/** Task the top bar belongs to, or null before one exists. */
taskId: string | null;
/** Display title of the task, when known. */
taskTitle?: string;
/** Workspace the task lives in, when known. */
workspaceId: string | null;
/** Session the top bar is currently bound to, or null before one exists. */
activeSessionId: string | null;
/** Every kandev session id on the task (includes `activeSessionId`). */
sessionIds: string[];
};
export type { ChatTopBarSlotProps } from "@/lib/plugins/types";
const EMPTY_SESSIONS: TaskSession[] = [];
const MemoizedPluginSlot = memo(PluginSlot);
@@ -68,8 +45,9 @@ export function TaskTopBarPluginActions(props: {
taskId: string | null;
taskTitle?: string;
workspaceId: string | null;
presentation?: ChatTopBarSlotProps["presentation"];
}) {
const { sessionId, taskId, taskTitle, workspaceId } = props;
const { sessionId, taskId, taskTitle, workspaceId, presentation = "desktop" } = props;
// itemsByTaskId holds a stable per-task array reference (updated only when
// that task's sessions change), so selecting it avoids a new-array-per-render.
// Read optionally so the top bar can render in isolation (unit tests) without
@@ -83,8 +61,30 @@ export function TaskTopBarPluginActions(props: {
const sessionIds = useStableSessionIds(taskSessions, sessionId);
const slotProps = useMemo<ChatTopBarSlotProps>(() => {
return { taskId, taskTitle, workspaceId, activeSessionId: sessionId, sessionIds };
}, [sessionId, sessionIds, taskId, taskTitle, workspaceId]);
return {
taskId,
taskTitle,
workspaceId,
activeSessionId: sessionId,
sessionIds,
presentation,
};
}, [presentation, sessionId, sessionIds, taskId, taskTitle, workspaceId]);
return <MemoizedPluginSlot name="chat-top-bar" slotProps={slotProps} />;
const content = <MemoizedPluginSlot name="chat-top-bar" slotProps={slotProps} />;
if (presentation === "desktop") return content;
return (
<div
className="flex w-full min-w-0 max-w-full flex-wrap items-center gap-2 overflow-x-clip [&>*]:min-w-0 [&>*]:max-w-full [&_[data-slot=button]]:!min-h-11 [&_[data-slot=button]]:!min-w-11 [&_[data-slot=button]]:!max-w-full [&_[data-slot=button]]:!whitespace-normal"
data-testid="mobile-chat-top-bar-plugin-actions"
>
{content}
</div>
);
}
/** Reactively reports whether the phone menu needs a session-plugin section. */
export function useHasTaskTopBarPluginActions(): boolean {
return usePluginRegistry().getSlotRegistrations("chat-top-bar").length > 0;
}
@@ -281,6 +281,48 @@
);
}
function ChatTopBarStatus(props) {
var slotProps = props.slotProps || {};
if (slotProps.presentation !== "mobile") return null;
return jsx(
"span",
{
"data-testid": "e2e-chat-top-bar-status",
"data-task-id": slotProps.taskId || "",
"data-workspace-id": slotProps.workspaceId || "",
"data-active-session-id": slotProps.activeSessionId || "",
"data-session-ids": (slotProps.sessionIds || []).join(","),
"data-presentation": slotProps.presentation || "unknown",
className:
"min-w-0 max-w-full truncate rounded-md border px-3 py-2 text-xs text-muted-foreground",
},
"Fixture archive synchronization is ready",
);
}
function ChatTopBarAction(props) {
var slotProps = props.slotProps || {};
var activeState = React.useState(false);
var active = activeState[0];
var setActive = activeState[1];
if (slotProps.presentation !== "mobile") return null;
return jsx(
ui.Button,
{
type: "button",
variant: "outline",
className: "cursor-pointer",
"data-testid": "e2e-chat-top-bar-action",
"data-presentation": slotProps.presentation || "unknown",
"data-activated": active ? "true" : "false",
onClick: function () {
setActive(true);
},
},
active ? "Fixture action complete" : "Run fixture task action",
);
}
// Debounce delay for the Notes panel's autosave — short, so e2e specs
// don't need to wait long for a write to reach host.storage.
var NOTES_SAVE_DEBOUNCE_MS = 150;
@@ -775,6 +817,8 @@
registry.registerRoute(FIXTURE_HELLO_PATH, PluginPage);
registry.registerComponent("task-sidebar", SidebarSlot);
registry.registerComponent("main-top-bar", MainTopBarSlot);
registry.registerComponent("chat-top-bar", ChatTopBarStatus);
registry.registerComponent("chat-top-bar", ChatTopBarAction);
registry.registerComponent("app-status-bar-left", StatusSlot);
registry.registerComponent("app-status-bar-right", StatusSlot);
registry.registerWsHandler("task.created", function () {
@@ -8,6 +8,7 @@ import {
type AppStatusBarSettingsBaseline,
} from "../../helpers/app-status-bar-settings";
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
import { openTaskSession } from "../../helpers/session";
const PLUGIN_ID = "kandev-plugin-e2e";
const PACKAGE_PATH = path.resolve(
@@ -29,12 +30,14 @@ async function installFixture(page: Page) {
await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 30_000 });
}
test.describe("Mobile listing menu actions", () => {
test.describe("Mobile plugin menu actions", () => {
let metricsBaseline: SystemMetricsDisplayBaseline;
let statusBarBaseline: AppStatusBarSettingsBaseline;
let createdTaskId: string | undefined;
test.beforeEach(async ({ apiClient, testPage }) => {
void testPage;
createdTaskId = undefined;
const settings = await apiClient.getUserSettings();
metricsBaseline = (settings.settings.system_metrics_display as
| SystemMetricsDisplayBaseline
@@ -49,6 +52,7 @@ test.describe("Mobile listing menu actions", () => {
});
test.afterEach(async ({ apiClient }) => {
if (createdTaskId) await apiClient.deleteTask(createdTaskId).catch(() => undefined);
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: metricsBaseline,
@@ -105,4 +109,99 @@ test.describe("Mobile listing menu actions", () => {
await expect(testPage.getByTestId("app-status-metrics")).toHaveCount(0);
}
});
// @covers AC-UI-MOBILE-TASK-CHROME-001.7
test("moves session contributions from a long task header into the Plugins section", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(120_000);
await installFixture(testPage);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
app_status_bar_enabled: true,
});
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Move plug-in controls into the mobile menu without crowding",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
createdTaskId = task.id;
if (!task.session_id) throw new Error("Fixture task did not create a session");
await openTaskSession(testPage, task.id);
const fixedActions = testPage.getByTestId("mobile-topbar-actions");
const title = testPage.getByTestId("mobile-task-picker-trigger");
const menuTrigger = testPage.getByTestId("app-nav-trigger");
await expect(fixedActions.getByTestId("e2e-chat-top-bar-status")).toHaveCount(0);
await expect(fixedActions.getByTestId("e2e-chat-top-bar-action")).toHaveCount(0);
await expect(title).toContainText("Move plug-in controls");
const titleBox = await requireBox(title, "mobile task title");
const actionsBox = await requireBox(fixedActions, "mobile task actions");
expect(titleBox.width).toBeGreaterThanOrEqual(120);
expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(actionsBox.x);
const triggerBox = await requireBox(menuTrigger, "mobile menu trigger");
expect(triggerBox.width).toBeGreaterThanOrEqual(44);
expect(triggerBox.height).toBeGreaterThanOrEqual(44);
await assertNoDocumentHorizontalOverflow(testPage, "task header with plugin contributions");
if (prCapture.capturing) {
const originalViewport = testPage.viewportSize();
if (!originalViewport) throw new Error("Mobile test page has no viewport");
const captureStyle = await testPage.addStyleTag({
content:
'[data-testid="mobile-task-layout"] > :not(:first-child) { visibility: hidden !important; }',
});
await testPage.setViewportSize({ width: originalViewport.width, height: 64 });
await prCapture.screenshot("mobile-task-plugin-header", {
caption:
"Long task title remains readable while session plug-in controls stay out of the fixed header",
});
await captureStyle.evaluate((element) => element.remove());
await testPage.setViewportSize(originalViewport);
}
await menuTrigger.tap();
const menu = testPage.getByRole("dialog", { name: "Menu", exact: true });
const pluginSection = menu.getByTestId("mobile-plugin-nav-section");
const status = pluginSection.getByTestId("e2e-chat-top-bar-status");
const action = pluginSection.getByTestId("e2e-chat-top-bar-action");
await expect(pluginSection).toBeVisible();
await pluginSection.scrollIntoViewIfNeeded();
await waitForFiniteAnimations(menu);
await expect(menu.getByText("Plugins", { exact: true })).toHaveCount(1);
await expect(status).toHaveAttribute("data-task-id", task.id);
await expect(status).toHaveAttribute("data-workspace-id", seedData.workspaceId);
await expect(status).toHaveAttribute("data-active-session-id", task.session_id);
await expect(status).toHaveAttribute("data-session-ids", task.session_id);
await expect(status).toHaveAttribute("data-presentation", "mobile");
await expect(action).toHaveAttribute("data-presentation", "mobile");
for (const contribution of [status, action]) {
const box = await requireBox(contribution, "session plugin contribution");
const menuBox = await requireBox(menu, "mobile menu");
expect(box.x).toBeGreaterThanOrEqual(menuBox.x);
expect(box.x + box.width).toBeLessThanOrEqual(menuBox.x + menuBox.width);
}
const actionBox = await requireBox(action, "session plugin action");
expect(actionBox.height).toBeGreaterThanOrEqual(44);
expect(actionBox.width).toBeGreaterThanOrEqual(44);
await action.tap();
await expect(action).toHaveAttribute("data-activated", "true");
await expect(menu).toBeVisible();
await assertNoDocumentHorizontalOverflow(testPage, "task plugin menu");
await prCapture.screenshot("mobile-task-plugin-menu", {
caption:
"Session plug-in status and action share the mobile Plugins section with touch-sized controls",
});
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(menuTrigger).toBeFocused();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type {
ChatTopBarSlotProps as PublicChatTopBarSlotProps,
ChatSubmitDecorationSlotProps as PublicChatSubmitDecorationSlotProps,
HostReact as PublicHostReact,
MainTopBarSlotProps as PublicMainTopBarSlotProps,
@@ -22,6 +23,7 @@ import type {
TaskPanelRegistration as PublicTaskPanelRegistration,
} from "@kandev/plugin-sdk";
import type {
ChatTopBarSlotProps as HostChatTopBarSlotProps,
ChatSubmitDecorationSlotProps as HostChatSubmitDecorationSlotProps,
MainTopBarSlotProps as HostMainTopBarSlotProps,
PluginConversationApi,
@@ -87,6 +89,10 @@ describe("public plugin SDK", () => {
HostMainTopBarSlotProps,
PublicMainTopBarSlotProps
> = true;
const chatTopBarSlotPropsAreCanonical: SameType<
HostChatTopBarSlotProps,
PublicChatTopBarSlotProps
> = true;
const chatSubmitDecorationSlotPropsAreCanonical: SameType<
HostChatSubmitDecorationSlotProps,
PublicChatSubmitDecorationSlotProps
@@ -138,6 +144,7 @@ describe("public plugin SDK", () => {
expect(associationIsCanonical).toBe(true);
expect(navSectionIsCanonical).toBe(true);
expect(mainTopBarSlotPropsAreCanonical).toBe(true);
expect(chatTopBarSlotPropsAreCanonical).toBe(true);
expect(chatSubmitDecorationSlotPropsAreCanonical).toBe(true);
expect(conversationMessageIsCanonical).toBe(true);
expect(conversationTurnIsCanonical).toBe(true);
+5 -3
View File
@@ -12,6 +12,7 @@ import type { PluginUIApi } from "@kandev/plugin-sdk";
export type {
IntegrationSettingsActionProps,
IntegrationSettingsActionSurface,
ChatTopBarSlotProps,
PluginContextApi,
PluginHostRepository,
MainTopBarSlotProps,
@@ -117,9 +118,10 @@ export interface IntegrationSettingsRegistration {
* `slotProps`), "chat-submit-decoration" (a layer *over* the send button's own
* box, for adornments that belong on the send affordance rather than beside it
* — receives `ChatSubmitDecorationSlotProps`; the host positions the layer and
* makes it `pointer-events-none`, see chat-submit-plugin-decoration.tsx), "chat-top-bar" (status in the session top bar, beside the
* CPU/DB metrics — receives `{ taskId, taskTitle, workspaceId, activeSessionId,
* sessionIds }`), "main-top-bar" (status/actions in the default app top bar on
* makes it `pointer-events-none`, see chat-submit-plugin-decoration.tsx),
* "chat-top-bar" (session status/actions — receives `ChatTopBarSlotProps`;
* phone contributions live in the shared menu), "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,