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