diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index de2bf211bd..cdcf2a43ea 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -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 () { diff --git a/apps/backend/internal/agentctl/server/process/probe/processtable_linux.go b/apps/backend/internal/agentctl/server/process/probe/processtable_linux.go index d9aa1e7f4d..e9159e5619 100644 --- a/apps/backend/internal/agentctl/server/process/probe/processtable_linux.go +++ b/apps/backend/internal/agentctl/server/process/probe/processtable_linux.go @@ -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) +} diff --git a/apps/backend/internal/agentctl/server/process/probe/processtable_linux_test.go b/apps/backend/internal/agentctl/server/process/probe/processtable_linux_test.go new file mode 100644 index 0000000000..7c462d9639 --- /dev/null +++ b/apps/backend/internal/agentctl/server/process/probe/processtable_linux_test.go @@ -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) + } + }) + } +} diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index e381640376..9216e3cf0c 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -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 diff --git a/apps/web/components/navigation/app-nav-sections.tsx b/apps/web/components/navigation/app-nav-sections.tsx index 8f165acf44..c88878230e 100644 --- a/apps/web/components/navigation/app-nav-sections.tsx +++ b/apps/web/components/navigation/app-nav-sections.tsx @@ -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") && ( - + {!omit.has("plugins") && ( + )} {!hasSavedSidebarLayout && !omit.has("integrations") && ( { // @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(); 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( + Session action} + />, + ); + 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; diff --git a/apps/web/components/navigation/app-nav-sheet.tsx b/apps/web/components/navigation/app-nav-sheet.tsx index 6931f0a9df..42da328bbf 100644 --- a/apps/web/components/navigation/app-nav-sheet.tsx +++ b/apps/web/components/navigation/app-nav-sheet.tsx @@ -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(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) => { - if (event.target instanceof Element && event.target.closest("a[href]")) close(); - }; return ( <> @@ -71,7 +69,7 @@ export function AppNavSheet({ pageNav, omitDestinations, onOpenTaskViews }: AppN > @@ -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, close: () => void) { + if (event.target instanceof Element && event.target.closest("a[href]")) close(); +} + function NavigationWorkspacePicker({ close }: { close: () => void }) { const { t } = useTranslation(); return ( diff --git a/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx b/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx index ff25dc2d4f..dae1ac7d80 100644 --- a/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx +++ b/apps/web/components/plugins/mobile-plugin-nav-section.test.tsx @@ -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(); +function renderSection(onNavigate = () => {}, actions?: ReactNode, includeDestinations = true) { + return render( + , + ); } -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, + , + ); + + 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(() => {}, Connected); + + 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); diff --git a/apps/web/components/plugins/mobile-plugin-nav-section.tsx b/apps/web/components/plugins/mobile-plugin-nav-section.tsx index d9ae910bb9..e9dfaf039f 100644 --- a/apps/web/components/plugins/mobile-plugin-nav-section.tsx +++ b/apps/web/components/plugins/mobile-plugin-nav-section.tsx @@ -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 (
{t("common:plugins")}
- + {actions} + {destinations.length > 0 && ( + + )}
); } diff --git a/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx b/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx index 2f446604b5..41a331bc32 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx @@ -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", () => ({ diff --git a/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx b/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx index 9b232427a1..7e4016c2c3 100644 --- a/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-top-bar.test.tsx @@ -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 }) => ( +
+ ), + useHasTaskTopBarPluginActions: () => true, })); vi.mock("@/components/navigation/app-nav-sheet", () => ({ - AppNavSheet: () => ; + }); + + render( + , + ); + + 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 {String(useHasTaskTopBarPluginActions())}; + } + + render(); + 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"); + }); +}); diff --git a/apps/web/components/task/task-top-bar-plugin-actions.tsx b/apps/web/components/task/task-top-bar-plugin-actions.tsx index 6ad428e2a1..c568d7cc80 100644 --- a/apps/web/components/task/task-top-bar-plugin-actions.tsx +++ b/apps/web/components/task/task-top-bar-plugin-actions.tsx @@ -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(() => { - 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 ; + const content = ; + if (presentation === "desktop") return content; + + return ( +
+ {content} +
+ ); +} + +/** Reactively reports whether the phone menu needs a session-plugin section. */ +export function useHasTaskTopBarPluginActions(): boolean { + return usePluginRegistry().getSlotRegistrations("chat-top-bar").length > 0; } diff --git a/apps/web/e2e/fixtures/plugins/prompt-history-plugin/bundle.js b/apps/web/e2e/fixtures/plugins/prompt-history-plugin/bundle.js index de2bf211bd..cdcf2a43ea 100644 --- a/apps/web/e2e/fixtures/plugins/prompt-history-plugin/bundle.js +++ b/apps/web/e2e/fixtures/plugins/prompt-history-plugin/bundle.js @@ -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 () { diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts index be412fe08c..6305e5d2be 100644 --- a/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts @@ -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(); + }); }); diff --git a/apps/web/lib/plugins/sdk-contract.test.ts b/apps/web/lib/plugins/sdk-contract.test.ts index 26af63b36d..9a49654293 100644 --- a/apps/web/lib/plugins/sdk-contract.test.ts +++ b/apps/web/lib/plugins/sdk-contract.test.ts @@ -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); diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 782d4fed86..30e1d11400 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -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, diff --git a/docs/plans/mobile-task-plugin-menu/plan.md b/docs/plans/mobile-task-plugin-menu/plan.md new file mode 100644 index 0000000000..a51a03f880 --- /dev/null +++ b/docs/plans/mobile-task-plugin-menu/plan.md @@ -0,0 +1,234 @@ +--- +created: 2026-09-22 +status: implemented +requirements: + - REQ-UI-MOBILE-TASK-CHROME-001 +system_design: + - ../../specs/ui/system-design/mobile-task-chrome.md +legacy_specs: [] +--- + +# Implementation Plan: Mobile Task Plugin Menu + +## Overview + +Move task-scoped `chat-top-bar` plugin contributions out of the fixed phone task +header and into the shared app menu's existing Plugins section. First add the +responsive slot contract and host composition with component regressions, then +prove the result with the packaged plugin fixture on the managed Pixel 5 project +and capture the two phone states needed for the PR. + +## Confirmed root cause + +`SessionMobileTopBar` renders `TaskTopBarPluginActions` inside +`MobileTopBarActions`, whose `shrink-0` cluster also owns the hamburger. Every +registered `chat-top-bar` component is opaque and rendered inline, while +`ChatTopBarSlotProps` has no presentation field or phone containment contract. +The supplied 390-CSS-pixel phone screenshot shows two contributions consuming +the middle of the fixed row and reducing the title/branch region to a cramped +remainder. The code path is deterministic even though the document itself does +not overflow. + +The closest shipped correction is the listing `main-top-bar` slot: its phone +presentation leaves the persistent header, renders in the shared app menu, and +receives `presentation: "mobile"`. The task slot should follow that boundary +while preserving task and session identity. + +## Scope + +### In scope + +- Remove `chat-top-bar` plugin components from the persistent phone task header. +- Render those components inside the shared phone menu's existing Plugins group. +- Add a public desktop/mobile presentation field without changing existing task, + workspace, active-session, or session-list context. +- Contain multiple and long contributions in the menu and preserve 44px phone + controls supplied through host UI primitives. +- Keep tablet and desktop task-top-bar placement unchanged. +- Add component and managed mobile E2E regressions using the real packaged + plugin fixture. +- Capture a synthetic long-title header and the opened Plugins menu for the PR. + +### Out of scope + +- Moving first-party approval, archive, port-forwarding, executor, or + change-request controls. +- Changing plugin registration lifecycle, backend APIs, persistence, or + permissions. +- Redesigning plugin navigation or the shared menu hierarchy outside the + combined Plugins section. +- Changing listing `main-top-bar` behavior. + +## Technical approach + +### Public slot contract + +- Export `ChatTopBarSlotProps` from `apps/packages/plugin-sdk/src/index.ts` and + use it as the canonical host type in + `apps/web/components/task/task-top-bar-plugin-actions.tsx`. +- Add `presentation: "desktop" | "mobile"`. Default the host component to + desktop so existing call sites and plugin behavior remain compatible. +- Update `docs/plans/plugins/PLUGIN-API.md`, + `docs/public/plugins-authoring.md`, and the SDK exact-consumer test with the + phone placement, containment, and touch contract. + +### Phone composition + +- Add a reactive registration-presence helper beside + `TaskTopBarPluginActions`; it prevents the app menu from rendering an empty + Plugins group when no `chat-top-bar` slot is registered. +- Give `TaskTopBarPluginActions` a mobile wrapper that uses the available width, + wraps contributions, constrains direct children, and normalizes host buttons + to a minimum 44px active target. Keep its desktop return path unchanged. +- Remove the plugin slot from `MobileTopBarActions`. Pass the mobile presentation + through `AppNavSheet` instead, with the same task, workspace, active-session, + and task-session IDs. +- Thread the optional content through `AppNavSections` into + `MobilePluginNavSection`. Render it above plugin destination rows under one + localized Plugins heading, and keep the section absent when it has neither + contributions nor destinations. +- Preserve the menu's existing single vertical scroll owner, safe-area padding, + link dismissal, Escape behavior, and focus return. A plugin control owns its + interaction and does not close the menu merely because it was activated. + +### Regression and PR evidence + +- Extend the packaged E2E plugin with deterministic `chat-top-bar` + contributions that expose the received presentation and include compact and + wider content. +- Extend `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts` with a task + scenario that creates a synthetic long-title task, verifies the fixed header + and menu geometry, activates a plugin control, and checks zero document + horizontal overflow. +- Use `prCapture` in that scenario to write + `mobile-task-plugin-header.png` and `mobile-task-plugin-menu.png`. The closed + screenshot shows recovered task hierarchy; the open screenshot shows where + plugin controls moved. Validate both manifest entries before PR publication. + +## ASCII UI preview + +### UI-01: Phone task header with plugin contributions + +Entry point: `/t/:taskId` below `md`, with two `chat-top-bar` registrations. + +Current behavior confirmed by the supplied screenshot and source trace: + +```text ++--------------------------------------------------+ +| < | Long task title... v | [plugin] [status] | = | +| | branch +changes | | | ++--------------------------------------------------+ + ^ task identity is squeezed by an unbounded, + non-shrinking plugin cluster +``` + +Target persistent header: + +```text ++--------------------------------------------------+ +| < | Long task title with useful context... v | = | +| | repo / branch +changes | | ++--------------------------------------------------+ + fixed row; plugin contributions are absent + first-party contextual controls may remain before the menu +``` + +Target opened shared menu: + +```text ++--------------------------------------------------+ +| Menu x | +|--------------------------------------------------| +| Workspace | +| [ Current workspace v ] | +| | +| Home [Quick Chat] [Terminal] | +| Tasks | +| ... existing task navigation ... | +| | +| Plugins | +| [ plugin action ] [ session status 23% ] | +| [ additional controls wrap inside the menu ] | +| [ plugin destination row ] | +| | +| Integrations | +| Utilities | ++--------------------------------------------------+ + one vertical scroll owner; no horizontal page scroll +``` + +Structural requirements: the phone header keeps task identity as the flexible +region; the hamburger remains fixed and touch reachable; plugin contributions +share one Plugins section, wrap within its width, and give `host.ui.Button` +controls a 44px active target. The exact spacing, labels inside plugin-owned +components, and number of first-party header controls remain governed by current +design tokens and availability. + +Tablet and desktop keep the current inline task top bar: + +```text +Task identity | metrics | plugin contributions | status/tools/actions +``` + +This preview maps to `AC-UI-MOBILE-TASK-CHROME-001.5`, `.6`, and `.7`. + +## Tests + +- `AC-UI-MOBILE-TASK-CHROME-001.7`: + `apps/web/components/task/mobile/session-mobile-top-bar.test.tsx` first + recorded the fixed-header placement failure. The task top-bar tests then + prove the desktop/mobile context, containment wrapper, and reactive presence + contract. +- `AC-UI-MOBILE-TASK-CHROME-001.5` and `.7`: + `apps/web/components/task/mobile/session-mobile-top-bar.test.tsx` proves the + plugin slot is not a child of the fixed action cluster and is supplied to the + shared menu only while registrations exist. +- `AC-UI-MOBILE-TASK-CHROME-001.7`: + `apps/web/components/plugins/mobile-plugin-nav-section.test.tsx` and + `apps/web/components/navigation/app-nav-sheet.test.tsx` prove the combined + Plugins group, empty behavior, ordering, and context. +- `AC-UI-MOBILE-TASK-CHROME-001.6` and `.7`: + `apps/web/lib/plugins/sdk-contract.test.ts` and the plugin SDK typecheck prove + one public structural contract and unchanged desktop presentation. + +## E2E tests + +- `AC-UI-MOBILE-TASK-CHROME-001.5`, `.6`, and `.7`: + `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts` installs the packaged + fixture, opens a long-title task in `mobile-chrome`, proves the closed header + retains usable non-overlapping title/menu geometry, opens the app menu, proves + both plugin contributions are contained and receive the mobile presentation, + activates one, and asserts no document horizontal overflow. The existing + listing scenario remains in the same focused run. + +## Work orders + +- [x] [Task 01: Move task plugins into the phone menu](task-01-move-task-plugins.md) (`done`) + +## Verification results + +- Installed the locked workspace dependencies. +- Focused Vitest suite: 49 tests passed across the task top bar, phone header, + shared Plugins section, navigation sheet, and public SDK contract. +- Plugin SDK and web TypeScript checks passed; targeted ESLint completed with + zero warnings. +- Managed `mobile-chrome` Playwright run: 2 tests passed against the packaged + fixture, including long-title geometry, context, 44px host-button size, + activation, focus return, and horizontal-overflow assertions. +- PR capture manifest contains the validated synthetic header and menu PNGs. +- Public-doc validation passed 70 tests and 47 published pages; specification + indexing and lint passed 299 decisions and 1,108 specifications. + +## Risks + +- Plugin components can render arbitrary markup. Host width constraints protect + the menu, while the public presentation field lets cooperative plugins choose + a clearer phone layout; the host cannot make opaque plugin copy meaningful. +- Moving the phone instance into the drawer changes when a plugin component is + visibly mounted. The contract must avoid promising background polling or local + disclosure state while the menu is closed. +- Extending the shared fixture affects every E2E test that installs it. Keep the + new registrations inert outside task detail and run the existing listing + scenario with the new task scenario. +- The user-provided screenshot contains live task data. PR captures must use the + synthetic fixture task instead of publishing that attachment. diff --git a/docs/plans/mobile-task-plugin-menu/task-01-move-task-plugins.md b/docs/plans/mobile-task-plugin-menu/task-01-move-task-plugins.md new file mode 100644 index 0000000000..f2ede95a8a --- /dev/null +++ b/docs/plans/mobile-task-plugin-menu/task-01-move-task-plugins.md @@ -0,0 +1,170 @@ +--- +id: "01-move-task-plugins" +title: "Move task plugins into the phone menu" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-MOBILE-TASK-CHROME-001 +acceptance_criteria: + - AC-UI-MOBILE-TASK-CHROME-001.5 + - AC-UI-MOBILE-TASK-CHROME-001.6 + - AC-UI-MOBILE-TASK-CHROME-001.7 +system_design: + - ../../specs/ui/system-design/mobile-task-chrome.md +--- + +# Task 01: Move Task Plugins into the Phone Menu + +## Summary + +Add an explicit responsive contract to the task session plugin slot, move its +phone presentation from the fixed task header into the shared menu's Plugins +section, and prove it with the packaged plugin fixture. Preserve the inline +tablet/desktop surface and all existing task and session context. + +## In scope + +- Add RED component and contract assertions before production changes. +- Export and consume the canonical `ChatTopBarSlotProps` SDK type with a + desktop/mobile presentation. +- Add the mobile containment wrapper and reactive registration-presence check. +- Thread phone task plugin content through `AppNavSheet`, `AppNavSections`, and + `MobilePluginNavSection`. +- Remove the inline phone plugin slot while retaining first-party controls. +- Update the plugin API reference and public authoring guide. +- Extend the packaged fixture and add the mobile Playwright regression after a + focused component assertion records the fixed-header placement failure. +- Verify long-title geometry, wrapping, interaction, menu focus/dismissal, and + zero document overflow through the managed production build. +- Capture and validate the synthetic closed-header and open-menu PR assets. + +## Out of scope + +- First-party task header action placement. +- Backend or persisted-state changes. +- Publishing screenshot binaries on the PR branch; `/pr` publishes ignored + capture output on the dedicated media ref. +- Reusing the user's attached screenshot in a public PR. + +## Acceptance + +- The fixed phone header never contains a `chat-top-bar` registration, and the + same registration appears in the shared Plugins section only when present. +- Mobile slot props preserve task/session identity, report `presentation: + "mobile"`, wrap within the menu, and give host buttons a minimum 44px active + target; desktop reports `presentation: "desktop"` and keeps existing markup. +- The combined Plugins section renders once, preserves plugin link dismissal, + and disappears when it has neither actions nor destinations. +- A real packaged plugin with multiple contributions leaves a synthetic + long-title phone header usable, stays inside the menu viewport, activates its + control, and produces both expected PR-capture manifest entries. + +## ASCII UI preview + +### UI-01: Phone task header with plugin contributions + +Full preview: [plan.md](plan.md#ui-01-phone-task-header-with-plugin-contributions). + +```text +Before: | < | task... | [plugin] [status] | = | +After: | < | task identity + branch... | = | + +Menu: Plugins + [plugin action] [session status] + [plugin destination] +``` + +The fixed header owns task identity and the menu trigger. The existing menu +owns wrapping plugin content. Maps to +`AC-UI-MOBILE-TASK-CHROME-001.5`, `.6`, and `.7`. + +## Verification + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps && pnpm --filter @kandev/web test -- --run components/task/task-top-bar-plugin-actions.test.tsx components/task/mobile/session-mobile-top-bar.test.tsx components/plugins/mobile-plugin-nav-section.test.tsx components/navigation/app-nav-sheet.test.tsx lib/plugins/sdk-contract.test.ts) +(cd apps && pnpm --filter @kandev/plugin-sdk typecheck) +(cd apps && pnpm --filter @kandev/web exec eslint components/task/task-top-bar-plugin-actions.tsx components/task/task-top-bar-plugin-actions.test.tsx components/task/mobile/session-mobile-top-bar.tsx components/task/mobile/session-mobile-top-bar.test.tsx components/plugins/mobile-plugin-nav-section.tsx components/plugins/mobile-plugin-nav-section.test.tsx components/navigation/app-nav-sheet.tsx components/navigation/app-nav-sheet.test.tsx components/navigation/app-nav-sections.tsx lib/plugins/types.ts lib/plugins/sdk-contract.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && CAPTURE_PR_ASSETS=true pnpm e2e:run --project mobile-chrome e2e/tests/plugins/mobile-plugin-topbar.spec.ts) +test -s apps/web/.pr-assets/manifest.json +node -e 'const fs=require("fs"); const m=JSON.parse(fs.readFileSync("apps/web/.pr-assets/manifest.json","utf8")); const names=m.assets?.map((a)=>a.file??a.name)??[]; for (const expected of ["mobile-task-plugin-header.png","mobile-task-plugin-menu.png"]) { if (!names.some((name)=>String(name).endsWith(expected))) throw new Error(`missing ${expected}`); }' +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.py --all +git diff --check -- apps/packages/plugin-sdk apps/web docs/public docs/plans/plugins docs/specs/ui docs/plans/mobile-task-plugin-menu +``` + +## Files likely touched + +- `apps/packages/plugin-sdk/src/index.ts` +- `apps/web/lib/plugins/types.ts` +- `apps/web/lib/plugins/sdk-contract.test.ts` +- `apps/web/components/task/task-top-bar-plugin-actions.tsx` +- `apps/web/components/task/task-top-bar-plugin-actions.test.tsx` +- `apps/web/components/task/mobile/session-mobile-top-bar.tsx` +- `apps/web/components/task/mobile/session-mobile-top-bar.test.tsx` +- `apps/web/components/task/mobile/session-mobile-top-bar-repository.test.tsx` +- `apps/web/components/navigation/app-nav-sheet.tsx` +- `apps/web/components/navigation/app-nav-sheet.test.tsx` +- `apps/web/components/navigation/app-nav-sections.tsx` +- `apps/web/components/plugins/mobile-plugin-nav-section.tsx` +- `apps/web/components/plugins/mobile-plugin-nav-section.test.tsx` +- `apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js` +- `apps/web/e2e/fixtures/plugins/prompt-history-plugin/bundle.js` +- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts` +- `docs/plans/plugins/PLUGIN-API.md` +- `docs/public/plugins-authoring.md` +- `apps/web/.pr-assets/manifest.json` (ignored capture output) +- `apps/web/.pr-assets/mobile-plugin-topbar--mobile-task-plugin-header.png` + (ignored capture output) +- `apps/web/.pr-assets/mobile-plugin-topbar--mobile-task-plugin-menu.png` + (ignored capture output) + +## Dependencies + +None. + +## Risks + +- The slot accepts opaque plugin markup; enforce host containment without + assuming a plugin's internal DOM shape beyond documented host buttons. +- Do not render an empty Plugins heading during registry install/uninstall. +- Fixture registrations are shared by tests that install the package; keep them + inert until a task detail page mounts the slot and retain the existing listing + scenario in the focused E2E run. +- Wait for finite drawer animations before geometry or screenshot reads. + +## Parallelism + +`sequential` + +## Inputs + +- `AC-UI-MOBILE-TASK-CHROME-001.5`, `.6`, and `.7`. +- `docs/specs/ui/system-design/mobile-task-chrome.md`, especially Components and + responsibilities, Plugin contribution, and Mobile design contract. +- `apps/web/components/kanban/main-top-bar-plugin-actions.tsx` and + `apps/web/components/kanban/mobile-listing-menu-actions.tsx` as the shipped + responsive slot exemplar. +- `apps/web/e2e/tests/plugins/mobile-plugin-topbar.spec.ts`, + `apps/web/e2e/helpers/layout-assertions.ts`, and + `apps/web/e2e/helpers/animations.ts` for production-build proof. +- `apps/web/e2e/fixtures/test-base.ts` `prCapture` fixture. + +## Results + +- RED: the phone-header component test failed because the plugin action was + still inside `mobile-topbar-actions`; it passed after the menu handoff. +- Focused Vitest suite: 49 tests passed. +- Plugin SDK typecheck, web typecheck, and targeted ESLint passed. +- Managed production-build mobile E2E: 2 tests passed with the packaged plugin. +- Both synthetic PR screenshots are present in the capture manifest and were + visually checked. +- Review fixup scoped the 44px guarantee to `host.ui.Button`, corrected the + documented phone placement for `main-top-bar`, and reran the managed mobile + E2E scenarios successfully after updating the fixture action. +- Public-doc and specification validation passed; final diff checks passed. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 11280000f6..64bc32c732 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -1033,9 +1033,13 @@ interface PluginRegistry { // mid-turn with an empty composer the button is replaced by Cancel, and the // decoration goes with it. // "chat-top-bar" renders status in the session top bar (beside the - // document/editor/debug controls) and forwards - // `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }`. Both - // carry the active session plus every kandev session id on the task. + // document/editor/debug controls on desktop) and forwards + // `ChatTopBarSlotProps`: `{ taskId, taskTitle, workspaceId, + // activeSessionId, sessionIds, presentation }`. On a phone, presentation is + // "mobile" and contributions live inside the shared Plugins menu section. + // The host gives `host.ui.Button` controls a 44px touch target. Arbitrary + // plugin interaction does not dismiss the menu. Both presentations 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, diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 79d27dba0c..b5cf23890c 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -630,8 +630,8 @@ to strings, but an unmounted name renders nowhere. | task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps | | new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps | | chat-submit-decoration | Layer over the composer's send button | ChatSubmitDecorationSlotProps | -| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } | -| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } | +| chat-top-bar | Session top bar or phone Plugins menu | ChatTopBarSlotProps | +| main-top-bar | Home/Kanban/Tasks top bar or phone Plugins menu | MainTopBarSlotProps | | app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | | app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | | plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed | @@ -1872,8 +1872,8 @@ plugins at once. Available slots: | `task-create-input-actions` | Task creation composer toolbar | `PluginComposerSlotProps` | | `new-session-input-actions` | New-session composer toolbar | `PluginComposerSlotProps` | | `chat-submit-decoration` | Layer over the chat composer's send button, for adornments that belong on the send affordance itself | `ChatSubmitDecorationSlotProps` | -| `chat-top-bar` | Session top bar, beside the CPU/DB metrics and the document/editor/debug controls | `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }` | -| `main-top-bar` | Default app top bar (Home / Kanban / Tasks), beside the CPU/DB metrics and the view/display controls | `{ workspaceId, workspaceLabel, currentPage }` | +| `chat-top-bar` | Session top bar on desktop; shared Plugins menu section on phones | `ChatTopBarSlotProps` | +| `main-top-bar` | Default app top bar on desktop; shared Plugins menu section on phones | `MainTopBarSlotProps` | | `app-status-bar-left` | Default-left item in the global status surface | `AppStatusBarSlotProps` | | `app-status-bar-right` | Default-right item in the global status surface | `AppStatusBarSlotProps` | | `plugin-settings` | A plugin's own settings page (**Settings > Plugins > ``**), at the top above the settings form | `{ pluginId, status }` | @@ -1981,24 +1981,32 @@ example. ### Session top bar Register a `chat-top-bar` component to surface at-a-glance status in the -session top bar, beside first-party document/editor/debug controls. The host passes the current context as -`slotProps`: +session top bar. Import its context from the public SDK: ```ts -type ChatTopBarSlotProps = { +import type { ChatTopBarSlotProps } from "@kandev/plugin-sdk"; + +// The exported type contains: +type Context = { taskId: string | null; taskTitle?: string; workspaceId: string | null; activeSessionId: string | null; // session the top bar is bound to sessionIds: string[]; // every kandev session id on the task + presentation: "desktop" | "mobile"; }; ``` Like `chat-input-actions`, both the active session and the full `sessionIds` list are provided (see the note above about resolving kandev session ids to -ACP transcript ids server-side). The top bar is a compact horizontal strip, so -keep contributions to small badges or `h-7` buttons that match the native -metric chips. +ACP transcript ids server-side). With `presentation: "desktop"`, the contribution +is inline beside first-party document/editor/debug controls, so keep it to a +small badge or `h-7` button that matches the native metric chips. With +`presentation: "mobile"`, the contribution is in the shared menu's **Plugins** +section. The host wraps multiple contributions inside the menu width and gives +`host.ui.Button` controls a minimum 44px touch target. Plugin controls retain +their own interaction and disclosure state; arbitrary interaction does not +dismiss the menu. ```js // inside initialize(registry, host): @@ -2073,14 +2081,14 @@ type MainTopBarSlotProps = { }; ``` -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 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. +Because the bar is not scoped to a task, no task/session ids are provided. On +desktop, keep contributions to small badges or icon buttons in the compact +horizontal strip. On a phone, `presentation` is `"mobile"`; the contribution +renders in the shared listing menu's **Plugins** section. The host wraps +contributions within the menu width and gives `host.ui.Button` controls a +minimum 44px active target. Use `host.ui.Button` for documented icon actions; +the host normalizes their SVG icons to 16px. Desktop contributions keep their +existing sizing. ```js // inside initialize(registry, host): diff --git a/docs/specs/ui/requirements/mobile-task-chrome.md b/docs/specs/ui/requirements/mobile-task-chrome.md index aacf442ec0..167b0f4f18 100644 --- a/docs/specs/ui/requirements/mobile-task-chrome.md +++ b/docs/specs/ui/requirements/mobile-task-chrome.md @@ -2,6 +2,7 @@ status: active system: ui created: 2026-08-24 +updated: 2026-09-22 owners: - Kandev --- @@ -13,7 +14,9 @@ owners: Phone users need a compact task header whose controls match the scope of the surface. Desktop Dockview layout management does not apply to the dedicated phone task composition, Git operations belong with Changes, and task-level -actions already have a discoverable entry through the task drawer. +actions already have a discoverable entry through the task drawer. Optional +plugin status and actions belong in the shared phone menu so they cannot consume +the task identity region. ## Terminology @@ -43,6 +46,7 @@ viewport. - **AC-UI-MOBILE-TASK-CHROME-001.4:** When a phone user selects Changes, applicable commit, push, change-request creation, pull, rebase, merge, and remote-contribution recovery actions shall remain reachable through the existing Changes controls and safety flows. Standalone recovery controls and their confirmation actions shall retain at least a 44 CSS-pixel hit target throughout the phone breakpoint below `md`. - **AC-UI-MOBILE-TASK-CHROME-001.5:** When the phone task title is long, the top bar shall keep its retained actions inside the viewport, avoid document-level horizontal overflow, and provide at least a 44-by-44 CSS-pixel hit target for the task-drawer entry. - **AC-UI-MOBILE-TASK-CHROME-001.6:** When the same task renders on tablet or desktop, existing task-top-bar and Dockview layout-profile behavior shall remain unchanged. +- **AC-UI-MOBILE-TASK-CHROME-001.7:** When one or more plugins contribute session top-bar status or actions, the phone task header shall keep those contributions out of its persistent row and expose them in the shared phone menu's Plugins section. The menu shall preserve current task, workspace, active-session, and task-session context, identify its presentation as mobile, keep multiple or long contributions inside the menu viewport, and give controls created with `host.ui.Button` at least a 44 CSS-pixel active target. Tablet and desktop shall retain the inline top-bar presentation. ## Out of scope @@ -52,7 +56,8 @@ viewport. - Removing layout profiles from desktop Dockview or from Settings. - Changing backend task movement, Git operations, APIs, persistence, or permissions. -- Removing or reorganizing other phone task-top-bar actions. +- Removing or reorganizing first-party phone task-top-bar actions other than + the plugin contribution placement defined above. ## Navigation entry points @@ -69,3 +74,7 @@ the shared phone menu, replacing the Task views action and dedicated pinned shortcuts. Kanban/Threads/List title dropdowns open display options; Threads saved-view editing remains inside that surface. See REQ-UI-MOBILE-MENU-004/005 in the unified navigation requirements for the current composition. + +## Implementation plans + +- [Mobile task plugin menu](../../../plans/mobile-task-plugin-menu/plan.md) diff --git a/docs/specs/ui/system-design/mobile-task-chrome.md b/docs/specs/ui/system-design/mobile-task-chrome.md index 5c29105c53..9203ec0d41 100644 --- a/docs/specs/ui/system-design/mobile-task-chrome.md +++ b/docs/specs/ui/system-design/mobile-task-chrome.md @@ -4,6 +4,7 @@ system: ui requirements: - REQ-UI-MOBILE-TASK-CHROME-001 created: 2026-08-24 +updated: 2026-09-22 owners: - Kandev --- @@ -15,8 +16,10 @@ owners: This design removes two phone-only top-bar entry points whose scope is owned elsewhere: saved desktop layouts and general Git operations. It keeps task movement in the existing task drawer and Git operations in the existing Changes -surface. No backend, store, persistence, permission, or responsive-routing -contract changes. +surface. It also moves plugin-provided session status and actions from the fixed +phone header into the shared app menu while retaining their inline tablet and +desktop presentation. No backend, store, persistence, permission, or +responsive-routing contract changes. ## Requirement mapping @@ -38,12 +41,39 @@ shared app menu and title-triggered task picker expose task-row actions, including **Move to**, so replacing the Git ellipsis with another task menu would create a second path to the same commands. +`SessionMobileTopBar` still renders every `chat-top-bar` plugin registration +inside a non-shrinking action cluster. The slot accepts an unbounded number of +opaque plugin components and does not tell them whether the host is the desktop +top bar or the phone surface. Multiple compact contributions can therefore +consume most of a phone header even without causing document overflow, leaving +the title and branch summary technically present but no longer useful. The +listing header already avoids this failure by putting `main-top-bar` +contributions in the shared phone menu and passing an explicit presentation. + ## Components and responsibilities - `apps/web/components/task/mobile/session-mobile-top-bar.tsx` keeps task title, - repository/branch summary, applicable status/plugin controls, approval, and - the shared app-navigation trigger and title-triggered task picker. It does not - mount layout or Git action surfaces. Phone triggers retain 44px touch targets. + repository/branch summary, applicable first-party status controls, approval, + and the shared app-navigation trigger and title-triggered task picker. It does + not mount layout, Git action, or plugin contribution surfaces inline. It passes + the current task plugin contribution into the app menu instead. Phone triggers + retain 44px touch targets. +- `apps/web/components/task/task-top-bar-plugin-actions.tsx` uses the public + `ChatTopBarSlotProps` contract. It keeps desktop rendering unchanged and adds a + mobile presentation that wraps contributions, constrains direct children to + the available menu width, and applies the phone minimum target to host buttons. + A reactive registration-presence hook prevents an empty Plugins section. +- `apps/web/components/navigation/app-nav-sheet.tsx` and + `apps/web/components/navigation/app-nav-sections.tsx` accept optional + page-scoped plugin content. `MobilePluginNavSection` renders that content and + plugin navigation destinations under one localized **Plugins** heading after + workspace actions and Automations and before Integrations. Arbitrary plugin + interaction does not dismiss the menu; plugin links retain normal dismissal. +- `apps/packages/plugin-sdk/src/index.ts` exports `ChatTopBarSlotProps` with a + `presentation: "desktop" | "mobile"` field. The mobile value means the + contribution is mounted in the shared phone menu; the desktop value means the + contribution remains inline in the task top bar. Existing task, workspace, + active-session, and session-list fields remain unchanged. - `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` remains the shared task-list and row-action owner for embedded Tasks and the title picker. - `apps/web/components/task/mobile/mobile-changes-panel.tsx` continues to @@ -77,6 +107,21 @@ not retain action ownership. Both entries reuse the same task-row actions and mutation path. +### Plugin contribution + +1. On tablet or desktop, `TaskTopBarPluginActions` passes + `presentation: "desktop"` and renders each `chat-top-bar` registration in the + existing inline cluster. +2. On a phone, the persistent header renders only its first-party controls and + shared app-navigation trigger. The reactive slot-presence check supplies the + plugin contribution to `AppNavSheet` only while registrations exist. +3. The user opens the hamburger menu and reaches the contribution in the shared + **Plugins** section. `TaskTopBarPluginActions` passes + `presentation: "mobile"` with the same task and session identity. +4. The menu's single vertical scroll owner contains wrapping contributions and + plugin navigation rows. Plugin-owned controls keep their own interaction and + disclosure state; selecting a plugin navigation link closes the menu. + ### Git action 1. User selects **Changes** from existing phone bottom navigation. @@ -92,39 +137,49 @@ state or operation semantics. ## Mobile design contract - **Desktop outcome and mobile entry point:** Desktop and tablet top bars remain - unchanged. Phone task actions enter through the hamburger task drawer; Git - actions enter through bottom-navigation **Changes**. + unchanged. Phone task and plugin contributions enter through the hamburger + menu; Git actions enter through bottom-navigation **Changes**. - **Nearest shipped exemplars:** `SessionTaskSwitcherSheet` contributes the - inset task drawer and visible task-row actions. `MobileChangesPanel` + inset task drawer and visible task-row actions. The listing + `MainTopBarPluginActions` mobile presentation contributes menu placement, + explicit presentation context, wrapping, and touch geometry. `MobileChangesPanel` contributes the focused, one-dimensional Git surface and its single internal scroll owner. - **Information hierarchy and primary action:** Task identity remains first, - followed by contextual status/actions and one task-navigation control. - Neither layout management nor Git operations compete with the active panel. -- **Presentation choice and rationale:** No replacement surface is added. - Task choices remain in the existing drawer because they are short contextual - navigation/actions. Dense Git content remains in Changes because it needs - repository, file, commit, and remote-state context. + followed by essential first-party status/actions and one app-navigation + control. Optional plugin status/actions move into the existing Plugins group. + Layout management and Git operations do not compete with the active panel. +- **Presentation choice and rationale:** No replacement surface is added. Task + choices and optional plugin contributions share the existing drawer because + they are temporary contextual navigation, status, and actions. Dense Git + content remains in Changes because it needs repository, file, commit, and + remote-state context. - **Scroll, viewport, safe area, and touch:** Existing fixed top bar, `h-dvh` workbench, panel scroll owner, bottom navigation, drawer safe area, - and menu containment remain. Removing two controls reduces crowding. The - retained task-drawer trigger has a 44-by-44 CSS-pixel hit target. Standalone - Changes recovery controls and their dialog actions retain 44 CSS-pixel touch - targets through the full phone range below `md`. -- **Shared state and logic:** Phone and desktop continue to share task and Git - state, mutations, eligibility, and feedback. Only phone composition loses - duplicate entry points. -- **Mobile Playwright proof:** Tests assert both removed triggers are absent, - the retained task drawer remains contained and can move the active task, Git - operations complete through Changes, long titles do not overlap actions, and - document horizontal overflow stays absent. Remote recovery geometry is also - verified at 700 CSS pixels, between the `sm` and `md` breakpoints. + and menu containment remain. Removing fixed plugin content protects the task + identity width. The retained app-menu trigger has a 44-by-44 CSS-pixel hit + target; plugin controls created with `host.ui.Button` have at least a 44 + CSS-pixel active target in the menu. + Standalone Changes recovery controls and their dialog actions retain 44 + CSS-pixel touch targets through the full phone range below `md`. +- **Shared state and logic:** Phone and desktop share task, session, plugin, and + Git state, mutations, eligibility, and feedback. Only the phone plugin + presentation and placement differ. +- **Mobile Playwright proof:** Tests install a real fixture plugin, create a + long-title task, and assert that plugin contributions are absent from the + persistent header, present in the menu with mobile context and touch geometry, + contained with multiple contributions, and leave no document horizontal + overflow. Existing tests continue to prove the removed layout/Git triggers, + task movement, Changes actions, and remote recovery at 700 CSS pixels. ## Failure and recovery - Missing or loading session Git data leaves Changes controls in their existing loading/disabled state; it does not reintroduce a top-bar action. - Archived tasks keep existing read-only presentation and task navigation. +- A plugin render failure remains isolated by `PluginErrorBoundary`; removing or + disabling the last `chat-top-bar` registration removes the menu contribution + and does not leave an empty Plugins section unless plugin navigation rows exist. - Remote-contribution drift continues to fail closed through shared Changes action policy and exact confirmation/lease behavior. - If optional task-list data is still hydrating, the existing task drawer owns @@ -132,9 +187,11 @@ state or operation semantics. ## Persistence and compatibility -No persisted layout, task, session, or user-setting value changes. Removing the -phone selector does not delete saved layouts or alter desktop defaults. Existing -phone panel preference and desktop/tablet layout state remain unchanged. +No persisted layout, task, session, or user-setting value changes. Adding the +`presentation` field is backward compatible for existing structural slot +consumers. Removing the phone selector does not delete saved layouts or alter +desktop defaults. Existing phone panel preference and desktop/tablet layout +state remain unchanged. ## Related specifications and decisions