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 909782cd05..ea4551f3aa 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -20,9 +20,9 @@ * end to end: a "Notes" task panel (registerTaskPanel, mobile-enabled) that * round-trips a single per-user document through host.storage * (get on mount, debounced set, subscribe to pick up a write from another - * tab/surface), a task-card-indicators slot component, a task-card-tags slot - * component, and a registerTaskMenuAction under the kanban card's "edit" - * group. It also registers one composer action on all three composer slots + * tab/surface), task-card indicator/tag components, generic task-row metadata, + * and task-menu actions under the "edit" and "primary" groups. It also + * registers one composer action on all three composer slots * (chat-input-actions, task-create-input-actions, new-session-input-actions), * which is how the e2e suite exercises PluginComposerCapability against real * native composers rather than a mock. @@ -377,6 +377,19 @@ ); } + function RowMetadata(props) { + var slotProps = props.slotProps || {}; + return jsx( + "span", + { + "data-testid": "e2e-row-metadata", + "data-task-id": slotProps.taskId, + "data-surface": slotProps.surface, + }, + "fixture metadata", + ); + } + function WorkspaceActionsSlot(props) { var slotProps = props.slotProps || {}; return jsx( @@ -681,6 +694,7 @@ registry.registerComponent("new-session-input-actions", ComposerAction); registry.registerComponent("task-card-indicators", CardIndicator); registry.registerComponent("task-card-tags", CardTags); + registry.registerComponent("task-row-metadata", RowMetadata); registry.registerComponent("sidebar-workspace-actions", WorkspaceActionsSlot); registry.registerTaskMenuAction({ id: "enhance-notes", @@ -701,6 +715,19 @@ }); }, }); + registry.registerTaskMenuAction({ + id: "inspect-task-metadata", + label: "Inspect task metadata", + group: "primary", + run: function (context) { + return host.storage.set( + "task", + context.taskId, + "primary-menu-presentation", + context.presentation, + ); + }, + }); registry.registerKeybinding("open-demo", function () { function DemoModalContent() { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 928fb38cd5..66df22f120 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -283,9 +283,9 @@ plugin leaks a stale registration. switches). `PluginTaskPanel` (`components/task/`) resolves the registration behind a `PluginErrorBoundary`; `mobileEnabled: true` also renders it via the phone bottom nav (`session-mobile-bottom-nav.tsx`) with `presentation: "mobile"`. -- **Kanban card contributions:** `registerTaskMenuAction({ group: "edit", ... })` turns the flat - `Edit` item into an `Edit` submenu (`kanban-card-edit-submenu.tsx`); - `registerComponent("task-card-indicators", ...)` renders beside `PRTaskIcon` via ``; `task-card-tags` renders in its own row below the badges (for contributions too wide for the title-row indicators spot, e.g. tag chips). +- **Task contributions:** `registerTaskMenuAction({ group: "edit", ... })` adds card-only actions to + the `Edit` submenu. Group `"primary"` adds flat actions to card and desktop/mobile task-row menus. + Card indicator/tag slots stay card-specific; `task-row-metadata` is generic for sidebar and `/tasks` rows. - **Sidebar workspace actions:** `registerComponent("sidebar-workspace-actions", ...)` renders after Quick Terminal/Quick Chat in the desktop sidebar's New Task row and in the shared phone navigation sheet, forwarding `SidebarWorkspaceActionsSlotProps` with `presentation: "desktop" | "mobile"`; mobile plugin controls own a 44px touch target and accessible name. - **`host.storage`:** authenticated per-user key/value storage (`lib/plugins/host-api.ts`), backed by `/api/plugins/{id}/user-state/...` (`docs/decisions/2026-08-01-per-user-plugin-storage.md`). diff --git a/apps/web/app/tasks/rich-task-list-row.tsx b/apps/web/app/tasks/rich-task-list-row.tsx index 15139cc72c..c680ca4d83 100644 --- a/apps/web/app/tasks/rich-task-list-row.tsx +++ b/apps/web/app/tasks/rich-task-list-row.tsx @@ -4,6 +4,7 @@ import { IconAlertCircle, IconSubtask } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; +import { TaskRowMetadata } from "@/components/task/task-row-plugin-slots"; import { MRTaskIcon } from "@/components/gitlab/mr-task-icon"; import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card"; import { useTaskPendingInput, type PendingInput } from "@/hooks/use-task-pending-input"; @@ -137,6 +138,12 @@ function RichTaskContent({ + {details.description && (

{details.description} @@ -180,6 +187,12 @@ export function TaskListRowPrimaryContent({ style={{ paddingLeft: `${level * 28}px` }} > + ); } diff --git a/apps/web/app/tasks/tasks-list-view.test.tsx b/apps/web/app/tasks/tasks-list-view.test.tsx index e36a24769f..c31b0222ec 100644 --- a/apps/web/app/tasks/tasks-list-view.test.tsx +++ b/apps/web/app/tasks/tasks-list-view.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; +import { pluginRegistry } from "@/lib/plugins/registry"; import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; import { sessionId as toSessionId, @@ -128,3 +129,64 @@ describe("TasksListView row — destructive-action guard", () => { expect(screen.queryByTestId("still-working-warning")).not.toBeNull(); }); }); + +describe("TasksListView row — task-row-metadata slot", () => { + const PLUGIN_ID = "example-metadata-plugin"; + const METADATA_TEST_ID = "plugin-row-metadata"; + const SLOT = "task-row-metadata"; + + function SlotPropsProbe({ slotProps }: { slotProps?: unknown }) { + const p = slotProps as { + taskId: string; + workspaceId: string | null; + workflowStepId: string | null; + surface: string; + }; + return ( + + {p.taskId}|{p.workspaceId}|{p.workflowStepId}|{p.surface} + + ); + } + + afterEach(() => { + pluginRegistry.unregisterPlugin(PLUGIN_ID); + }); + + function renderWithWorkspace(showTaskDetails: boolean) { + return render( + + + + + , + ); + } + + it("renders the slot with surface: 'task-list' in the rich row variant", () => { + pluginRegistry.forPlugin(PLUGIN_ID).registerComponent(SLOT, SlotPropsProbe); + + renderWithWorkspace(true); + + expect(screen.getByTestId(METADATA_TEST_ID).textContent).toBe("task-1|ws-1|step-1|task-list"); + }); + + it("renders the slot with surface: 'task-list' in the compact row variant", () => { + pluginRegistry.forPlugin(PLUGIN_ID).registerComponent(SLOT, SlotPropsProbe); + + renderWithWorkspace(false); + + expect(screen.getByTestId(METADATA_TEST_ID).textContent).toBe("task-1|ws-1|step-1|task-list"); + }); + + it("renders nothing in either variant when no plugin is registered", () => { + renderWithWorkspace(true); + expect(screen.queryByTestId(METADATA_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("task-row-metadata")).toBeNull(); + cleanup(); + + renderWithWorkspace(false); + expect(screen.queryByTestId(METADATA_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("task-row-metadata")).toBeNull(); + }); +}); diff --git a/apps/web/components/kanban-card-edit-submenu.tsx b/apps/web/components/kanban-card-edit-submenu.tsx index 423aa4206d..2a3c998b9e 100644 --- a/apps/web/components/kanban-card-edit-submenu.tsx +++ b/apps/web/components/kanban-card-edit-submenu.tsx @@ -3,10 +3,7 @@ import { IconPencil } from "@tabler/icons-react"; import { t } from "@/lib/i18n"; import type { PluginTaskMenuContext } from "@/lib/plugins/types"; -import { - runnablePluginMenuEntry, - visiblePluginMenuActions, -} from "./kanban-card-plugin-menu-actions"; +import { runnablePluginMenuEntry, visiblePluginMenuActions } from "./plugins/task-menu-actions"; import type { KanbanCardMenuEntry } from "./kanban-card-menu-items"; /** diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 19aaeaf763..578a6ab497 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -35,7 +35,7 @@ import { cn } from "@/lib/utils"; import { buildLinkSubmenu } from "./kanban-card-link-submenu"; import type { PluginIcon, PluginTaskMenuContext } from "@/lib/plugins/types"; import { buildEditMenuEntry } from "./kanban-card-edit-submenu"; -import { buildPrimaryPluginEntries } from "./kanban-card-plugin-menu-actions"; +import { buildPrimaryPluginEntries } from "./plugins/task-menu-actions"; import { useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; diff --git a/apps/web/components/kanban-card-plugin-menu-actions.ts b/apps/web/components/plugins/task-menu-actions.ts similarity index 97% rename from apps/web/components/kanban-card-plugin-menu-actions.ts rename to apps/web/components/plugins/task-menu-actions.ts index cba1fbffaa..7e6db8dd19 100644 --- a/apps/web/components/kanban-card-plugin-menu-actions.ts +++ b/apps/web/components/plugins/task-menu-actions.ts @@ -2,7 +2,7 @@ import { createElement } from "react"; import { pluginRegistry } from "@/lib/plugins/registry"; import { resolvePluginIcon } from "@/lib/plugins/icons"; import type { PluginTaskMenuContext } from "@/lib/plugins/types"; -import type { KanbanCardMenuEntry } from "./kanban-card-menu-items"; +import type { KanbanCardMenuEntry } from "../kanban-card-menu-items"; type PluginTaskMenuAction = ReturnType[number]; /** diff --git a/apps/web/components/task/task-item-leading-badges.tsx b/apps/web/components/task/task-item-leading-badges.tsx new file mode 100644 index 0000000000..0689c06365 --- /dev/null +++ b/apps/web/components/task/task-item-leading-badges.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { IconAlertCircle, IconPinFilled } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTranslation } from "react-i18next"; +import { IssueTaskIcon } from "@/components/github/issue-task-icon"; +import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; +import { TaskAutopilotIcon } from "@/components/task/task-autopilot-icon"; +import { TaskContributionIcons } from "./task-contribution-icons"; + +export function TaskItemLeadingBadges({ + autopilot, + isPinned, + taskId, + prInfo, + issueInfo, + agentErrorMessage, +}: { + autopilot?: boolean; + isPinned?: boolean; + taskId?: string; + prInfo?: { number: number; state: string; aggregateState?: string }; + issueInfo?: { url: string; number: number }; + agentErrorMessage?: string | null; +}) { + return ( + <> + {autopilot && } + {isPinned && ( + + )} + + {taskId ? : null} + {issueInfo && } + {agentErrorMessage && } + + ); +} + +function TaskAgentErrorIcon({ message }: { message: string }) { + const { t } = useTranslation(); + return ( + + + + + + + {message} + + + ); +} diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index e5a442d424..6cdab37429 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -2,18 +2,14 @@ import { memo } from "react"; import { - IconAlertCircle, IconChevronDown, IconCircleCheck, IconCircleDashed, IconDots, IconMessageQuestion, IconProgressCheck, - IconPinFilled, IconShieldQuestion, } from "@tabler/icons-react"; -import { IssueTaskIcon } from "@/components/github/issue-task-icon"; -import { TaskContributionIcons } from "./task-contribution-icons"; import { cn } from "@/lib/utils"; import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent"; import { TaskItemStatsRow } from "./task-item-stats-row"; @@ -28,13 +24,13 @@ import { } from "@/lib/ui/state-icons"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; +import { TaskRowMetadata } from "./task-row-plugin-slots"; import { classifyTask } from "./task-classify"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { useTranslation } from "react-i18next"; -import { TaskAutopilotIcon } from "@/components/task/task-autopilot-icon"; import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; -import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; +import { TaskItemLeadingBadges } from "./task-item-leading-badges"; type DiffStats = { additions: number; @@ -74,6 +70,8 @@ type TaskItemProps = { menuOpen?: boolean; isDeleting?: boolean; taskId?: string; + /** Drives the `task-row-metadata` plugin slot's `workflowStepId`. */ + workflowStepId?: string | null; primarySessionId?: string | null; hasPendingClarification?: boolean; hasPendingPermission?: boolean; @@ -334,6 +332,7 @@ function TaskItemContent({ title, autopilot, taskId, + workflowStepId, isRemoteExecutor, remoteExecutorType, remoteExecutorName, @@ -353,6 +352,7 @@ function TaskItemContent({ title: string; autopilot?: boolean; taskId?: string; + workflowStepId?: string | null; isRemoteExecutor?: boolean; remoteExecutorType?: string; remoteExecutorName?: string; @@ -374,17 +374,14 @@ function TaskItemContent({

- {autopilot && } - {isPinned && ( - - )} - - {taskId ? : null} - {issueInfo && } - {agentErrorMessage && } + {isRemoteExecutor && ( )} + {taskId && ( + + )} - - - - - - {message} - - - ); -} - // The row keeps its state and accessibility affordances together for keyboard // selection, so this small exception avoids splitting that interaction across // multiple components. @@ -461,6 +445,7 @@ export const TaskItem = memo(function TaskItem({ menuOpen = false, isDeleting, taskId, + workflowStepId, primarySessionId, hasPendingClarification, hasPendingPermission, @@ -512,6 +497,7 @@ export const TaskItem = memo(function TaskItem({ title={title} autopilot={autopilot} taskId={taskId} + workflowStepId={workflowStepId} isRemoteExecutor={isRemoteExecutor} remoteExecutorType={remoteExecutorType} remoteExecutorName={remoteExecutorName} diff --git a/apps/web/components/task/task-row-plugin-slots.tsx b/apps/web/components/task/task-row-plugin-slots.tsx new file mode 100644 index 0000000000..7c41d55c4c --- /dev/null +++ b/apps/web/components/task/task-row-plugin-slots.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { useAppStore } from "@/components/state-provider"; +import { usePluginRegistry } from "@/lib/plugins/registry"; +import type { TaskRowMetadataSlotProps } from "@/lib/plugins/types"; +import { cn } from "@/lib/utils"; + +/** + * Plugin-agnostic, compact secondary metadata for dense task-listing rows. + * The component owns its layout wrapper so an empty registry contributes no + * DOM or spacing. + */ +export function TaskRowMetadata({ + taskId, + workflowStepId, + surface, + className, +}: { + taskId: string; + workflowStepId: string | null; + surface: TaskRowMetadataSlotProps["surface"]; + className?: string; +}) { + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const registry = usePluginRegistry(); + if (registry.getSlotRegistrations("task-row-metadata").length === 0) return null; + + const slotProps: TaskRowMetadataSlotProps = { taskId, workspaceId, workflowStepId, surface }; + return ( +
+ +
+ ); +} diff --git a/apps/web/components/task/task-switcher-context-menu.test.tsx b/apps/web/components/task/task-switcher-context-menu.test.tsx index 67fef1987c..fa4d52678f 100644 --- a/apps/web/components/task/task-switcher-context-menu.test.tsx +++ b/apps/web/components/task/task-switcher-context-menu.test.tsx @@ -1,11 +1,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import type { PluginTaskMenuContext } from "@/lib/plugins/types"; import { TaskItemWithContextMenu } from "./task-switcher-context-menu"; import type { TaskSwitcherItem } from "./task-switcher-types"; -afterEach(() => cleanup()); +const PLUGIN_ID = "example-task-actions"; +const PLUGIN_ACTION_LABEL = "Inspect task"; + +afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin(PLUGIN_ID); +}); function task(overrides: Partial = {}): TaskSwitcherItem { return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides }; @@ -49,6 +57,54 @@ async function openContextMenu() { await screen.findByRole("menuitem", { name: /color/i }); } +function renderPluginMenu(run: (context: PluginTaskMenuContext) => void = vi.fn()) { + pluginRegistry.forPlugin(PLUGIN_ID).registerTaskMenuAction({ + id: "inspect-task", + label: PLUGIN_ACTION_LABEL, + group: "primary", + run, + }); + render( + + + +
Task 1
+
+
+
, + ); + fireEvent.contextMenu(screen.getByTestId("plugin-task-row")); +} + +describe("TaskItemWithContextMenu — plugin primary actions", () => { + it("runs a generic primary action with the sidebar task context", async () => { + const run = vi.fn(); + renderPluginMenu(run); + + fireEvent.click(screen.getByRole("menuitem", { name: PLUGIN_ACTION_LABEL })); + await Promise.resolve(); + + expect(run).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + taskId: "task-1", + taskTitle: "Task 1", + workflowStepId: "step-1", + presentation: "desktop", + }); + }); + + it("removes an action from an already-open menu when its plugin unregisters", async () => { + renderPluginMenu(); + expect(screen.getByRole("menuitem", { name: PLUGIN_ACTION_LABEL })).not.toBeNull(); + + pluginRegistry.unregisterPlugin(PLUGIN_ID); + + await waitFor(() => { + expect(screen.queryByRole("menuitem", { name: PLUGIN_ACTION_LABEL })).toBeNull(); + }); + }); +}); + // happy-dom's TouchEvent drops the touches/changedTouches init, so the touch // events in the cancellation tests stub it faithfully. function stubTouchEvent() { diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index ba88e5e17a..aecdba80f2 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -36,6 +36,7 @@ import { TaskDetachItem, } from "./task-switcher-action-items"; import type { StepDef, TaskSwitcherItem } from "./task-switcher-types"; +import { TaskPluginPrimaryMenuItems } from "./task-switcher-plugin-menu-items"; export type { StepDef } from "./task-switcher-types"; export { createTaskLinkSelectAction } from "./task-switcher-link-menu"; @@ -334,6 +335,7 @@ function SingleSelectionMenuItems({ /> {!task.isArchived && } + state.workspaces.activeId); + const { isMobile } = useResponsiveBreakpoint(); + if (!workspaceId) return null; + + const context: PluginTaskMenuContext = { + workspaceId, + taskId: task.id, + taskTitle: task.title, + workflowStepId: task.workflowStepId ?? null, + presentation: isMobile ? "mobile" : "desktop", + }; + return ; +} diff --git a/apps/web/components/task/task-switcher-row.tsx b/apps/web/components/task/task-switcher-row.tsx index 30be5c254d..7104a5535b 100644 --- a/apps/web/components/task/task-switcher-row.tsx +++ b/apps/web/components/task/task-switcher-row.tsx @@ -153,6 +153,7 @@ function TaskRowItem({ remoteExecutorType={task.remoteExecutorType} remoteExecutorName={task.remoteExecutorName} taskId={task.id} + workflowStepId={task.workflowStepId} primarySessionId={task.primarySessionId ?? null} hasPendingClarification={task.hasPendingClarification} hasPendingPermission={task.hasPendingPermission} diff --git a/apps/web/e2e/tests/plugins/mobile-plugin-task-row-metadata.spec.ts b/apps/web/e2e/tests/plugins/mobile-plugin-task-row-metadata.spec.ts new file mode 100644 index 0000000000..e1d4b4bea2 --- /dev/null +++ b/apps/web/e2e/tests/plugins/mobile-plugin-task-row-metadata.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; +import { SessionPage } from "../../pages/session-page"; + +async function menuPresentation(apiClient: ApiClient, taskId: string) { + const response = await apiClient.rawRequest( + "GET", + `/api/plugins/${PLUGIN_ID}/user-state/task/${taskId}/primary-menu-presentation`, + ); + if (response.status !== 200) return null; + return ((await response.json()) as { value: string }).value; +} + +test.describe("Mobile plugin task row metadata", () => { + test.afterEach(async ({ apiClient }) => { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + }); + + test("uses the same metadata and primary action contract in the phone task sheet", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await installFixturePlugin(testPage); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile plugin row metadata", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.mobileSessionMenu.tap(); + const sheet = testPage.getByRole("dialog", { name: "Tasks" }); + const row = sheet.getByTestId("sidebar-task-item").filter({ hasText: task.title }); + await expect(row.getByTestId("e2e-row-metadata")).toHaveAttribute("data-task-id", task.id); + await row.getByRole("button", { name: "Task actions" }).tap(); + await testPage.getByRole("menuitem", { name: "Inspect task metadata" }).tap(); + + await expect.poll(() => menuPresentation(apiClient, task.id)).toBe("mobile"); + }); +}); diff --git a/apps/web/e2e/tests/plugins/plugin-task-row-metadata.spec.ts b/apps/web/e2e/tests/plugins/plugin-task-row-metadata.spec.ts new file mode 100644 index 0000000000..4dfadd3bb2 --- /dev/null +++ b/apps/web/e2e/tests/plugins/plugin-task-row-metadata.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "../../fixtures/test-base"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; +import type { ApiClient } from "../../helpers/api-client"; +import { SessionPage } from "../../pages/session-page"; + +async function menuPresentation(apiClient: ApiClient, taskId: string) { + const response = await apiClient.rawRequest( + "GET", + `/api/plugins/${PLUGIN_ID}/user-state/task/${taskId}/primary-menu-presentation`, + ); + if (response.status !== 200) return null; + return ((await response.json()) as { value: string }).value; +} + +test.describe("Plugins — task row metadata and primary actions", () => { + test.afterEach(async ({ apiClient }) => { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + }); + + test("renders generic metadata and invokes the primary action from the desktop sidebar", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + await installFixturePlugin(testPage); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Desktop plugin row metadata", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + const row = session.sidebar.getByTestId("sidebar-task-item").filter({ hasText: task.title }); + await expect(row.getByTestId("e2e-row-metadata")).toHaveAttribute("data-task-id", task.id); + await row.hover(); + await row.getByRole("button", { name: "Task actions" }).click(); + await testPage.getByRole("menuitem", { name: "Inspect task metadata" }).click(); + + await expect.poll(() => menuPresentation(apiClient, task.id)).toBe("desktop"); + }); +}); diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index aee5f25899..52684c7300 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -138,7 +138,9 @@ export interface IntegrationSettingsRegistration { * as `slotProps`), "task-card-tags" (its own row on every kanban card, * rendered below the badges row — for contributions too wide for the cramped * title-row `task-card-indicators` spot, e.g. a row of tag chips — receives - * `TaskCardTagsSlotProps` as `slotProps`), and "sidebar-workspace-actions" + * `TaskCardTagsSlotProps` as `slotProps`), "task-row-metadata" (compact, + * read-only secondary metadata on sidebar and `/tasks` rows — receives + * `TaskRowMetadataSlotProps` as `slotProps`), and "sidebar-workspace-actions" * (icon-button cluster in the sidebar's New Task row, after the built-in * Quick Terminal and Quick Chat actions, with the same action group exposed * through mobile navigation — receives `SidebarWorkspaceActionsSlotProps` @@ -168,6 +170,14 @@ export type TaskCardTagsSlotProps = { workflowStepId: string | null; }; +/** Context passed to compact, plugin-agnostic task-row metadata contributions. */ +export type TaskRowMetadataSlotProps = { + taskId: string; + workspaceId: string | null; + workflowStepId: string | null; + surface: "sidebar" | "task-list"; +}; + /** Context the host forwards to every `sidebar-workspace-actions` component. */ export type SidebarWorkspaceActionsSlotProps = { /** Active workspace for the current navigation surface. */ diff --git a/docs/decisions/2026-08-18-plugin-task-row-metadata.md b/docs/decisions/2026-08-18-plugin-task-row-metadata.md new file mode 100644 index 0000000000..5815b7ef52 --- /dev/null +++ b/docs/decisions/2026-08-18-plugin-task-row-metadata.md @@ -0,0 +1,33 @@ +# ADR-2026-08-18-plugin-task-row-metadata: Keep Task Row Plugin Metadata Generic + +**Status:** accepted +**Date:** 2026-08-18 +**Area:** frontend + +## Context + +Plugins need compact, read-only content on sidebar and `/tasks` rows. The first +consumer was a tags plugin, but naming the public slot after tags would make one +plugin's data model part of the host contract. + +## Decision + +The host exposes `task-row-metadata` with `TaskRowMetadataSlotProps`. The slot +accepts contributions from any plugin and describes only the host surface: +task, workspace, workflow step, and `sidebar` or `task-list` presentation. The +host owns the conditional wrapper and emits no DOM when the slot is empty. + +Primary task-menu actions use the same registration on kanban, desktop sidebar, +and phone task-sheet menus. Edit-group actions remain card-only. + +## Consequences + +Plugins can show tags, ownership, provider state, or other compact metadata +without new host hooks. Host layouts stay stable when no plugin contributes. +The generic contract cannot promise tags-specific styling or behavior. + +## Alternatives Considered + +`task-row-tags` and `TaskRowTagsSlotProps` were rejected because they couple the +host API to one plugin. Reusing `task-card-tags` was rejected because dense rows +need a surface discriminator and different layout ownership. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 4f618cbe17..aa50eb7086 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -179,6 +179,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-17-separate-task-activity-from-summary-freshness | [Separate Task Activity From Summary Freshness](2026-08-17-separate-task-activity-from-summary-freshness.md) | accepted | backend, frontend, protocol | 2026-08-17 | | 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 | | 2026-08-18-never-started-agent-stall-terminal | [Treat Never-Started Agent Stalls as Terminal](2026-08-18-never-started-agent-stall-terminal.md) | accepted | backend, frontend, protocol | 2026-08-18 | +| 2026-08-18-plugin-task-row-metadata | [Keep Task Row Plugin Metadata Generic](2026-08-18-plugin-task-row-metadata.md) | accepted | frontend | 2026-08-18 | | 2026-08-20-settings-prompt-editor-provider-ownership | [Settings Prompt Editors Own Monaco Completion Providers](2026-08-20-settings-prompt-editor-provider-ownership.md) | accepted | frontend | 2026-08-20 | | 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 | | 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 | diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index 764cd1383b..2f5599c2b9 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -698,7 +698,8 @@ interface PluginRegistry { // "settings-nav", "chat-input-actions", "task-create-input-actions", // "new-session-input-actions", "chat-top-bar", // "main-top-bar", "app-status-bar-left", "app-status-bar-right", - // "plugin-settings", "task-card-indicators", "task-card-tags", and + // "plugin-settings", "task-card-indicators", "task-card-tags", + // "task-row-metadata", and // "sidebar-workspace-actions". // "task-card-indicators" renders a small icon/badge beside the PR status // icon on every kanban card and forwards @@ -710,6 +711,10 @@ interface PluginRegistry { // same `{ taskId: string, workspaceId: string | null, workflowStepId: string | null }` // shape as `slotProps` (`workspaceId` is null with no active workspace, and // `workflowStepId` is null when the task has no workflow step assigned). + // "task-row-metadata" renders compact, read-only metadata on the sidebar + // task tree and `/tasks` list. It forwards + // `{ taskId, workspaceId, workflowStepId, surface }`, where `surface` is + // "sidebar" or "task-list". The slot is plugin-agnostic. // "chat-input-actions", "task-create-input-actions", and // "new-session-input-actions" render composer actions for task/Quick Chat, // task creation, and new-session creation. Each forwards the typed @@ -1197,11 +1202,10 @@ console, and the menu still closes either way (Radix's own close-on-select, independent of the async result). Group `"primary"` renders each visible action as its own flat, top-level menu -item instead of nesting it under `Edit` — positioned between the "Move -to"/"Send to workflow" submenus and the "Link" submenu. Visibility filtering, -registration order, and `run()`/error handling are identical to the `"edit"` -group; the two groups are independent lists (an action only ever belongs to -one). +item instead of nesting it under `Edit`. It appears on cards and on the shared +desktop/mobile task-row menu. Group `"edit"` remains card-only. Visibility +filtering, registration order, and `run()`/error handling are identical; the +two groups are independent lists (an action only ever belongs to one). `"task-card-indicators"` (documented above with the other slots) is the matching read-only surface: a small icon/badge rendered beside the PR status @@ -1213,6 +1217,11 @@ in its own row on the card instead of the cramped title row `"task-card-indicators"` shares with `PRTaskIcon`. Use it for a contribution that needs its own width, e.g. a row of tag chips. +`"task-row-metadata"` is a separate, plugin-agnostic slot for compact, +read-only metadata in the sidebar task tree and `/tasks` list. Its shape is +`{ taskId, workspaceId, workflowStepId, surface }`, with `surface` equal to +`"sidebar"` or `"task-list"`. The host emits no wrapper when the slot is empty. + ### Task filters `registerTaskFilter` adds one section to the kanban board's display dropdown diff --git a/docs/public/coverage.json b/docs/public/coverage.json index f464434561..d06eab7ed2 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -120,6 +120,7 @@ "delete_task_kandev", "list_tasks_kandev", "move_task_kandev", + "record_step_decision_kandev", "remove_task_dependency_kandev", "set_task_title_kandev", "step_complete_kandev", diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index e78e682741..947a23eb6e 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -262,7 +262,7 @@ slice shapes in a released plugin. | registerTaskAction | Child action inside the task menu's native Link section | Active ui.bundle | Action is revoked on unload; host supplies current task/workspace and desktop/mobile presentation | registry.registerTaskAction({ id: "link-pr", placement: "link", ... }) | | registerReviewProvider | Normalized task reviews, workspace associations, unlink, and shared Review panel | ui.bundle and matching `repository_providers[]` id | Snapshots/subscriptions are owner-scoped and revoked on unload; host owns status chrome, indicators, unlink UI, and responsive Review placement | registry.registerReviewProvider({ id: "acme", ...reviews }) | | registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) | -| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) | +| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" is card-only inside Edit, while "primary" is a flat item on cards and desktop/mobile task-row menus | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "primary", run: doEnhance }) | | registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) | | host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx | | host.context | Versioned provider-neutral reads/subscriptions for active workspace, all workspace ids, native task creation, and exact provider repository identity | Active ui.bundle | Subscriptions are generation-owned and revoked on unload; records are stable SDK shapes, not private app state | const ids = host.context.getWorkspaceIds() | @@ -451,6 +451,7 @@ to strings, but an unmounted name renders nowhere. | plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed | | task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } | | task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } | +| task-row-metadata | Sidebar task tree and `/tasks` rows | TaskRowMetadataSlotProps | | sidebar-workspace-actions | Desktop New Task row or phone navigation action group, after Quick Terminal and Quick Chat | SidebarWorkspaceActionsSlotProps | AppStatusBarSlotProps is { placement, presentation, density, pathname, @@ -1950,21 +1951,24 @@ func (p *plugin) OnEvent(ctx context.Context, event *pluginsdk.Event) error { For critical workflows, periodically reconcile from host.Tasks() or another Host reader because event queues are bounded and delivery is best-effort. -### 6. Kanban-aware contribution +### 6. Task contributions -`registerTaskMenuAction` adds an item to the kanban card's context/dropdown -menu; group `"edit"` nests it inside the `Edit` submenu, group `"primary"` -renders it as a flat, top-level item positioned between the "Move -to"/"Send to workflow" submenus and the "Link" submenu. +`registerTaskMenuAction` adds an item to native task menus. Group `"edit"` is +card-only and nests inside the kanban card's `Edit` submenu. Group `"primary"` +renders as a flat top-level item on cards and desktop/mobile task-row menus. `task-card-indicators` (see the named slots table) is the matching read-only surface, rendered beside the PR status icon on every card. `task-card-tags` is a sibling read-only surface with the same `slotProps` shape, mounted in its own row instead of that cramped title-row spot; reach for it when a -contribution (e.g. a row of tag chips) needs its own width. Both -`visible(context)` and `run(context)` receive the card's actual `presentation`: -`"desktop"` on the desktop kanban and `"mobile"` on the phone kanban. Use it -when an action needs to choose responsive behavior; the host supplies it -through the card composition. +contribution (for example, a row of tag chips) needs its own width. +`visible(context)` and `run(context)` receive the actual `presentation`. +The value is `"desktop"` or `"mobile"` for the current native task menu. + +`task-row-metadata` is the plugin-agnostic compact counterpart for the sidebar +task tree and `/tasks` list. It receives `{ taskId, workspaceId, +workflowStepId, surface: "sidebar" | "task-list" }`. Use it for read-only +metadata such as labels, ownership, provider state, or tags. An empty slot +adds no wrapper or spacing. ```js registry.registerTaskMenuAction({ @@ -1982,9 +1986,9 @@ With no plugin action registered, the card's `Edit` item stays flat; once any plugin registers one for group `"edit"`, it becomes `Edit > Edit task` followed by each visible action. A `run` that throws or rejects is caught and logged, not left to crash the card; the menu closes either way. Group -`"primary"` actions render and behave the same way, just as their own -top-level menu row instead of nested under `Edit`. Do not patch first-party -Kanban components directly. +`"primary"` actions render and behave the same way as their own top-level row, +including in desktop and phone task-row menus. Do not patch first-party task +components directly. `registerTaskFilter` adds a client-side, multi-select filter section to the kanban board's display dropdown, next to the built-in Workflow and Repository diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 9dd51155e1..52123a0d77 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -749,18 +749,23 @@ presentation }`. Every plugin panel shares one generic `"plugin-panel"` dockview throws renders a per-panel error-boundary fallback without affecting the rest of the layout. Decision: ADR-2026-08-04-plugin-contribution-lifecycle-authority. -- **Kanban card contributions:** `registerTaskMenuAction({ id, label, icon?, -group: "edit", visible?, run })` adds an item to the kanban card's `Edit` +- **Task-menu and row contributions:** `registerTaskMenuAction({ id, label, icon?, +group: "edit" | "primary", visible?, run })` adds `"edit"` actions to the kanban card's `Edit` submenu (the flat `Edit` item becomes `Edit > Edit task` once any plugin registers one); `run(context)` receives `{ workspaceId, taskId, taskTitle, workflowStepId, presentation }`, and a rejected `run` is caught and logged - without blocking the menu from closing. `registerComponent("task-card-indicators", + without blocking the menu from closing. `"primary"` actions render on cards + and desktop/mobile task-row menus. `registerComponent("task-card-indicators", C)` renders `C` beside the PR status icon on every kanban card, receiving `{ taskId, workspaceId, workflowStepId }` as `slotProps`. `registerComponent("task-card-tags", C)` renders `C` in its own row on every kanban card (below the badges row), receiving the same `{ taskId, workspaceId, workflowStepId }` shape — for a contribution too wide for the cramped title-row `task-card-indicators` spot, e.g. a row of tag chips. + `registerComponent("task-row-metadata", C)` renders plugin-agnostic, + read-only metadata on sidebar and `/tasks` rows. It receives + `{ taskId, workspaceId, workflowStepId, surface }`. An empty slot adds no + wrapper or spacing. Decision: ADR-2026-08-18-plugin-task-row-metadata. - **Sidebar workspace actions:** `registerComponent("sidebar-workspace-actions", C)` renders `C` after Quick Terminal/Quick Chat in the desktop sidebar's New Task row and in the shared phone navigation sheet, forwarding @@ -1090,6 +1095,16 @@ complete. `context.presentation` is `"mobile"`; the same action invoked from desktop receives `"desktop"`. +- **GIVEN** a plugin registers a `"primary"` task-menu action, **WHEN** a user + opens a desktop sidebar or phone task-sheet menu, **THEN** the same action is + present with the correct `presentation`. **GIVEN** the plugin unregisters + while the menu is open, **THEN** its action disappears without a reload. + +- **GIVEN** any plugin registers `"task-row-metadata"`, **WHEN** sidebar or + `/tasks` rows render, **THEN** it receives `{ taskId, workspaceId, + workflowStepId, surface }`. **GIVEN** the slot is empty, **THEN** the host + renders no metadata wrapper or extra spacing. + - **GIVEN** a plugin has per-user state and deleting those rows returns an error, **WHEN** an operator uninstalls it, **THEN** the request fails, the package and plugin record remain installed for retry, and no successful-uninstall response is emitted.