feat(plugins): add task-row tags and sidebar actions (#2609)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-08-21 13:12:30 -04:00
committed by GitHub
parent b0ca65971c
commit 64bc820c45
23 changed files with 518 additions and 75 deletions
@@ -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() {
+3 -3
View File
@@ -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 `<PluginSlot/>`; `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`).
+13
View File
@@ -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({
<PrimaryTaskLine task={task} pendingInput={pendingInput} showContributions />
</div>
<RichMetadataBadges details={details} />
<TaskRowMetadata
taskId={task.id}
workflowStepId={task.workflow_step_id}
surface="task-list"
className="mt-1 pl-6"
/>
{details.description && (
<p className="mt-1 line-clamp-2 pl-6 text-xs leading-tight text-muted-foreground">
{details.description}
@@ -180,6 +187,12 @@ export function TaskListRowPrimaryContent({
style={{ paddingLeft: `${level * 28}px` }}
>
<PrimaryTaskLine task={task} pendingInput={pendingInput} showContributions={false} />
<TaskRowMetadata
taskId={task.id}
workflowStepId={task.workflow_step_id}
surface="task-list"
className="flex min-w-0 shrink items-center gap-1 overflow-hidden"
/>
</div>
);
}
@@ -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 (
<span data-testid={METADATA_TEST_ID}>
{p.taskId}|{p.workspaceId}|{p.workflowStepId}|{p.surface}
</span>
);
}
afterEach(() => {
pluginRegistry.unregisterPlugin(PLUGIN_ID);
});
function renderWithWorkspace(showTaskDetails: boolean) {
return render(
<StateProvider initialState={{ workspaces: { items: [], activeId: "ws-1" } }}>
<TooltipProvider>
<TasksListView {...props([makeTask({})])} showTaskDetails={showTaskDetails} />
</TooltipProvider>
</StateProvider>,
);
}
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();
});
});
@@ -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";
/**
@@ -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";
@@ -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<typeof pluginRegistry.getTaskMenuActions>[number];
/**
@@ -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 && <TaskAutopilotIcon />}
{isPinned && (
<IconPinFilled
data-testid="task-pinned-icon"
className="h-3 w-3 shrink-0 text-muted-foreground/60"
/>
)}
<TaskContributionIcons taskId={taskId} prInfo={prInfo} />
{taskId ? <RegisteredChangeRequestTaskIcon taskId={taskId} /> : null}
{issueInfo && <IssueTaskIcon issueInfo={issueInfo} />}
{agentErrorMessage && <TaskAgentErrorIcon message={agentErrorMessage} />}
</>
);
}
function TaskAgentErrorIcon({ message }: { message: string }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="task-agent-error-icon"
className="inline-flex shrink-0 cursor-help text-destructive"
aria-label={t("task:taskHasAnAgentError")}
>
<IconAlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[320px] whitespace-pre-wrap break-words">
{message}
</TooltipContent>
</Tooltip>
);
}
+23 -37
View File
@@ -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({
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1 min-w-0 text-[13px] font-medium text-foreground leading-tight">
<TaskItemTitle taskId={taskId} title={title} />
{autopilot && <TaskAutopilotIcon />}
{isPinned && (
<IconPinFilled
data-testid="task-pinned-icon"
className="h-3 w-3 shrink-0 text-muted-foreground/60"
/>
)}
<TaskContributionIcons taskId={taskId} prInfo={prInfo} />
{taskId ? <RegisteredChangeRequestTaskIcon taskId={taskId} /> : null}
{issueInfo && <IssueTaskIcon issueInfo={issueInfo} />}
{agentErrorMessage && <TaskAgentErrorIcon message={agentErrorMessage} />}
<TaskItemLeadingBadges
autopilot={autopilot}
isPinned={isPinned}
taskId={taskId}
prInfo={prInfo}
issueInfo={issueInfo}
agentErrorMessage={agentErrorMessage}
/>
{isRemoteExecutor && (
<RemoteCloudTooltip
taskId={taskId ?? ""}
@@ -405,6 +402,13 @@ function TaskItemContent({
{repositories.join(" · ")}
</span>
)}
{taskId && (
<TaskRowMetadata
taskId={taskId}
workflowStepId={workflowStepId ?? null}
surface="sidebar"
/>
)}
<TaskItemStatsRow
updatedAt={showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt}
prInfo={prInfo}
@@ -416,26 +420,6 @@ function TaskItemContent({
);
}
function TaskAgentErrorIcon({ message }: { message: string }) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span
data-testid="task-agent-error-icon"
className="inline-flex shrink-0 cursor-help text-destructive"
aria-label={t("task:taskHasAnAgentError")}
>
<IconAlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[320px] whitespace-pre-wrap break-words">
{message}
</TooltipContent>
</Tooltip>
);
}
// 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}
@@ -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 (
<div data-testid="task-row-metadata" className={cn("min-w-0", className)}>
<PluginSlot name="task-row-metadata" slotProps={slotProps} />
</div>
);
}
@@ -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> = {}): 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(
<StateProvider initialState={{ workspaces: { items: [], activeId: "workspace-1" } }}>
<ToastProvider>
<TaskItemWithContextMenu task={task({ workflowStepId: "step-1" })}>
<div data-testid="plugin-task-row">Task 1</div>
</TaskItemWithContextMenu>
</ToastProvider>
</StateProvider>,
);
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() {
@@ -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 && <TaskColorMenu taskId={task.id} disabled={isDeleting} />}
<TaskNestContextMenuItems task={task} disabled={isDeleting} />
<TaskPluginPrimaryMenuItems task={task} disabled={isDeleting} />
<TaskPluginLinkMenu
task={task}
disabled={isDeleting}
@@ -0,0 +1,31 @@
"use client";
import { KanbanCardContextMenuItems } from "@/components/kanban-card-menu-items";
import { buildPrimaryPluginEntries } from "@/components/plugins/task-menu-actions";
import { useAppStore } from "@/components/state-provider";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { usePluginRegistry } from "@/lib/plugins/registry";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import type { TaskSwitcherItem } from "./task-switcher-types";
export function TaskPluginPrimaryMenuItems({
task,
disabled,
}: {
task: TaskSwitcherItem;
disabled?: boolean;
}) {
usePluginRegistry();
const workspaceId = useAppStore((state) => 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 <KanbanCardContextMenuItems entries={buildPrimaryPluginEntries({ disabled, context })} />;
}
@@ -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}
@@ -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");
});
});
@@ -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");
});
});
+11 -1
View File
@@ -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. */
@@ -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.
+1
View File
@@ -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 |
+15 -6
View File
@@ -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
+1
View File
@@ -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",
+18 -14
View File
@@ -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
+18 -3
View File
@@ -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.