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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user