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.