feat(plugins): plugin task list facets for sort and group (#2932)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alassane
2026-08-25 06:59:14 +00:00
committed by GitHub
parent 8f21873d45
commit 0700911e05
30 changed files with 1170 additions and 204 deletions
@@ -21,7 +21,10 @@
* 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), task-card indicator/tag components, generic task-row metadata,
* and task-menu actions under the "edit" and "primary" groups. It also
* and task-menu actions under the "edit" and "primary" groups. It registers a
* task-list facet (registerTaskListFacet) whose values a spec drives through
* `window.__e2eFacetValues`, so /tasks facet sort and grouping can be exercised
* against real plugin registrations. 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
@@ -34,6 +37,12 @@
(function () {
var moduleCount = 0;
var listeners = new Set();
// Task-list facet plumbing. Values come from `window.__e2eFacetValues`
// (taskId -> [{value,label,color}]) so a spec can drive multi-value
// membership, colors, and the untagged fallback against task ids the
// fixture cannot know ahead of time. `window.__e2eFacetNotify()` fires
// the subscription so the reactive re-read path is exercised for real.
var facetListeners = new Set();
function emit() {
listeners.forEach(function (fn) {
@@ -689,6 +698,26 @@
Component: NotesPanel,
mobileEnabled: true,
});
registry.registerTaskListFacet({
id: "fixture-tags",
label: "Fixture tag",
getValues: function (context) {
var byTask = window.__e2eFacetValues || {};
if (byTask.__throwFor === context.taskId) throw new Error("fixture facet boom");
return byTask[context.taskId] || [];
},
subscribe: function (listener) {
facetListeners.add(listener);
window.__e2eFacetNotify = function () {
facetListeners.forEach(function (fn) {
fn();
});
};
return function () {
facetListeners.delete(listener);
};
},
});
registry.registerComponent("chat-input-actions", ComposerAction);
registry.registerComponent("task-create-input-actions", ComposerAction);
registry.registerComponent("new-session-input-actions", ComposerAction);
+15
View File
@@ -304,6 +304,20 @@ export interface TaskFilterRegistration {
matches(context: { taskId: string }, selected: string[]): boolean;
}
export interface TaskListFacetValue {
value: string;
label: string;
color?: string;
}
/** A synchronous, page-local facet contribution for the host task list. */
export interface TaskListFacetRegistration {
id: string;
label: string;
getValues(context: { taskId: string; workspaceId?: string }): readonly TaskListFacetValue[];
subscribe?(listener: () => void): () => void;
}
export type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";
export interface PluginStorageEntry {
@@ -692,6 +706,7 @@ export interface PluginRegistry {
registerTaskPanel(registration: TaskPanelRegistration): void;
registerTaskMenuAction(registration: TaskMenuActionRegistration): void;
registerTaskFilter(registration: TaskFilterRegistration): void;
registerTaskListFacet(registration: TaskListFacetRegistration): void;
}
export type PluginHost = PluginHostApi;
@@ -0,0 +1,32 @@
import { MobileFab } from "@/components/kanban/mobile-fab";
import { MobileTasksCreateDialog, type MobileTaskStep } from "./mobile-tasks-create-dialog";
export function MobileTasksActions({
workspaceId,
workflowId,
steps,
open,
onOpenChange,
onCreated,
}: {
workspaceId: string;
workflowId: string | null;
steps: MobileTaskStep[];
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => Promise<void>;
}) {
return (
<>
<MobileFab onClick={() => onOpenChange(true)} />
<MobileTasksCreateDialog
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
workflowId={workflowId}
steps={steps}
onCreated={onCreated}
/>
</>
);
}
@@ -0,0 +1,42 @@
import { TaskCreateDialog } from "@/components/task-create-dialog";
export type MobileTaskStep = {
id: string;
title: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
};
export function MobileTasksCreateDialog({
open,
onOpenChange,
workspaceId,
workflowId,
steps,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
workflowId: string | null;
steps: MobileTaskStep[];
onCreated: () => Promise<void>;
}) {
return (
<TaskCreateDialog
open={open}
onOpenChange={onOpenChange}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={() => {
onOpenChange(false);
void onCreated();
}}
/>
);
}
+22 -16
View File
@@ -9,8 +9,6 @@ import {
SORT_OPTION_LABEL_KEYS,
TASKS_LIST_GROUP_OPTIONS,
TASKS_LIST_SORT_OPTIONS,
type TasksListGroup,
type TasksListSort,
} from "@/lib/tasks/tasks-list-options";
export function TasksListControls({
@@ -20,37 +18,45 @@ export function TasksListControls({
onTasksListSortChange,
tasksListGroup,
onTasksListGroupChange,
facetOptions,
}: {
showArchived: boolean;
onShowArchivedChange: (show: boolean) => void;
tasksListSort: TasksListSort;
onTasksListSortChange: (sort: TasksListSort) => void;
tasksListGroup: TasksListGroup;
onTasksListGroupChange: (group: TasksListGroup) => void;
tasksListSort: string;
onTasksListSortChange: (sort: string) => void;
tasksListGroup: string;
onTasksListGroupChange: (group: string) => void;
facetOptions?: ReadonlyArray<{ value: string; label: string }>;
}) {
const { t } = useTranslation();
const sortOptions = TASKS_LIST_SORT_OPTIONS.map((option) => ({
value: option.value,
label: t(SORT_OPTION_LABEL_KEYS[option.value]),
}));
const groupOptions = TASKS_LIST_GROUP_OPTIONS.map((option) => ({
value: option.value,
label: t(GROUP_OPTION_LABEL_KEYS[option.value]),
}));
const sortOptions = [
...TASKS_LIST_SORT_OPTIONS.map((option) => ({
value: option.value,
label: t(SORT_OPTION_LABEL_KEYS[option.value]),
})),
...(facetOptions ?? []),
];
const groupOptions = [
...TASKS_LIST_GROUP_OPTIONS.map((option) => ({
value: option.value,
label: t(GROUP_OPTION_LABEL_KEYS[option.value]),
})),
...(facetOptions ?? []),
];
return (
<div className="hidden min-h-9 flex-wrap items-center justify-end gap-3 sm:flex">
<ListOptionSelect
label={t("tasks:sort")}
value={tasksListSort}
options={sortOptions}
onChange={(value) => onTasksListSortChange(value as TasksListSort)}
onChange={onTasksListSortChange}
testId="tasks-list-sort"
/>
<ListOptionSelect
label={t("tasks:group")}
value={tasksListGroup}
options={groupOptions}
onChange={(value) => onTasksListGroupChange(value as TasksListGroup)}
onChange={onTasksListGroupChange}
testId="tasks-list-group"
/>
<Label className="flex h-11 items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none lg:h-9">
+110
View File
@@ -14,6 +14,9 @@ import { TasksListView, type TasksListViewProps } from "./tasks-list-view";
afterEach(cleanup);
const PLUGIN_TAGS_FACET = "facet:plugin:tags";
const TASKS_LIST_SECTION = "tasks-list-section";
function message(overrides: Partial<Message>): Message {
return {
id: "msg-1",
@@ -130,6 +133,113 @@ describe("TasksListView row — destructive-action guard", () => {
});
});
describe("TasksListView facet grouping", () => {
it("keeps a plugin value named untagged separate from the generated ungrouped section", () => {
const tagged = makeTask({ id: toTaskId("tagged") });
const untagged = makeTask({ id: toTaskId("untagged") });
render(
<StateProvider initialState={{ messages: { bySession: {}, metaBySession: {} } }}>
<TooltipProvider>
<TasksListView
{...props([tagged, untagged])}
tasksListGroup={PLUGIN_TAGS_FACET}
facetValues={{
"facet:plugin:tags:tagged": [{ value: "untagged", label: "Custom untagged" }],
}}
/>
</TooltipProvider>
</StateProvider>,
);
expect(screen.getAllByTestId(TASKS_LIST_SECTION)).toHaveLength(2);
});
it("keeps the host fallback separate from any plugin value", () => {
const tagged = makeTask({ id: toTaskId("tagged") });
const untagged = makeTask({ id: toTaskId("untagged") });
render(
<StateProvider initialState={{ messages: { bySession: {}, metaBySession: {} } }}>
<TooltipProvider>
<TasksListView
{...props([tagged, untagged])}
tasksListGroup={PLUGIN_TAGS_FACET}
facetValues={{
"facet:plugin:tags:tagged": [
{ value: "__host_ungrouped__", label: "Custom host value" },
],
}}
/>
</TooltipProvider>
</StateProvider>,
);
expect(screen.getAllByTestId(TASKS_LIST_SECTION)).toHaveLength(2);
});
// A wrapping section label used to squeeze the flex sibling swatch down to
// ~1px wide, which erased the plugin's colour coding exactly when the label
// was long enough to need it.
it("keeps the colour swatch from shrinking when a long facet label wraps", () => {
const tagged = makeTask({ id: toTaskId("tagged") });
render(
<StateProvider initialState={{ messages: { bySession: {}, metaBySession: {} } }}>
<TooltipProvider>
<TasksListView
{...props([tagged])}
tasksListGroup={PLUGIN_TAGS_FACET}
facetValues={{
"facet:plugin:tags:tagged": [
{ value: "long", label: "A very long facet label".repeat(6), color: "#0ea5e9" },
],
}}
/>
</TooltipProvider>
</StateProvider>,
);
const swatch = screen
.getByTestId(TASKS_LIST_SECTION)
.querySelector('span[style*="background-color"]');
expect(swatch).not.toBeNull();
expect(swatch?.classList.contains("shrink-0")).toBe(true);
});
it("groups a task under every facet value it carries", () => {
const multi = makeTask({ id: toTaskId("multi") });
const single = makeTask({ id: toTaskId("single") });
render(
<StateProvider initialState={{ messages: { bySession: {}, metaBySession: {} } }}>
<TooltipProvider>
<TasksListView
{...props([multi, single])}
tasksListGroup={PLUGIN_TAGS_FACET}
facetValues={{
"facet:plugin:tags:multi": [
{ value: "alpha", label: "Alpha" },
{ value: "beta", label: "Beta" },
],
"facet:plugin:tags:single": [{ value: "beta", label: "Beta" }],
}}
/>
</TooltipProvider>
</StateProvider>,
);
const titles = screen
.getAllByTestId(TASKS_LIST_SECTION)
.map((section) => section.textContent ?? "");
expect(titles).toHaveLength(2);
expect(titles[0]).toContain("Alpha");
expect(titles[0]).toContain("1");
expect(titles[1]).toContain("Beta");
expect(titles[1]).toContain("2");
});
});
describe("TasksListView row — task-row-metadata slot", () => {
const PLUGIN_ID = "example-metadata-plugin";
const METADATA_TEST_ID = "plugin-row-metadata";
+58 -15
View File
@@ -15,23 +15,22 @@ import { TasksPagination } from "./tasks-pagination";
import { TaskListRowPrimaryContent } from "./rich-task-list-row";
import { PullToRefresh } from "@/components/mobile/pull-to-refresh";
import { TasksListControls } from "./tasks-list-controls";
import {
TASK_STATE_ORDER,
type TasksListGroup,
type TasksListSort,
} from "@/lib/tasks/tasks-list-options";
import { TASK_STATE_ORDER } from "@/lib/tasks/tasks-list-options";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { TaskListFacetValue } from "@/lib/plugins/types";
export type TasksListViewProps = {
total: number;
showArchived: boolean;
setShowArchived: (show: boolean) => void;
tasksListSort: TasksListSort;
onTasksListSortChange: (sort: TasksListSort) => void;
tasksListGroup: TasksListGroup;
onTasksListGroupChange: (group: TasksListGroup) => void;
tasksListSort: string;
onTasksListSortChange: (sort: string) => void;
tasksListGroup: string;
onTasksListGroupChange: (group: string) => void;
facetOptions?: ReadonlyArray<{ value: string; label: string }>;
facetValues?: Record<string, readonly TaskListFacetValue[]>;
tasks: Task[];
workflows: Workflow[];
repositories: Repository[];
@@ -70,6 +69,8 @@ export function TasksListView({
handleUnarchive,
handleDelete,
onRefresh,
facetOptions,
facetValues,
}: TasksListViewProps) {
// Not a <main>: AppShell owns that landmark, one per page.
const content = (
@@ -82,6 +83,7 @@ export function TasksListView({
onTasksListSortChange={onTasksListSortChange}
tasksListGroup={tasksListGroup}
onTasksListGroupChange={onTasksListGroupChange}
facetOptions={facetOptions}
/>
<TaskRows
tasks={tasks}
@@ -95,6 +97,7 @@ export function TasksListView({
onUnarchive={handleUnarchive}
onDelete={handleDelete}
onRowClick={handleRowClick}
facetValues={facetValues}
/>
<TasksPagination
total={total}
@@ -117,9 +120,16 @@ type TaskTreeNode = {
type TaskListSection = {
key: string;
title: string | null;
color?: string;
nodes: TaskTreeNode[];
};
const UNGROUPED_FACET_SECTION_KEY = "facet:host:ungrouped";
function facetValueSectionKey(value: string): string {
return `facet:value:${value}`;
}
function TaskRows({
tasks,
workflows,
@@ -132,18 +142,20 @@ function TaskRows({
onUnarchive,
onDelete,
onRowClick,
facetValues = {},
}: {
tasks: Task[];
workflows: Workflow[];
repositories: Repository[];
showTaskDetails: boolean;
tasksListGroup: TasksListGroup;
tasksListGroup: string;
isLoading: boolean;
deletingTaskId: string | null;
onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
onUnarchive: (taskId: string) => Promise<void>;
onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise<void>;
onRowClick: (task: Task) => void;
facetValues?: Record<string, readonly TaskListFacetValue[]>;
}) {
const { t, i18n } = useTranslation();
const workflowMap = useMemo(() => new Map(workflows.map((w) => [w.id, w.name])), [workflows]);
@@ -153,8 +165,8 @@ function TaskRows({
// language in the deps a section header keeps the previous locale until the
// task list itself changes.
const sections = useMemo(
() => buildTaskSections(tasks, { groupBy: tasksListGroup, workflowMap, repoMap }),
[repoMap, tasks, tasksListGroup, workflowMap, i18n.language],
() => buildTaskSections(tasks, { groupBy: tasksListGroup, workflowMap, repoMap, facetValues }),
[facetValues, repoMap, tasks, tasksListGroup, workflowMap, i18n.language],
);
if (isLoading) {
@@ -198,13 +210,38 @@ function buildTaskSections(
groupBy,
workflowMap,
repoMap,
facetValues,
}: {
groupBy: TasksListGroup;
groupBy: string;
workflowMap: Map<string, string>;
repoMap: Map<string, string>;
facetValues: Record<string, readonly TaskListFacetValue[]>;
},
): TaskListSection[] {
const roots = buildTaskTree(tasks);
if (groupBy.startsWith("facet:")) {
const grouped = new Map<string, { title: string; color?: string; tasks: Task[] }>();
for (const task of tasks) {
const values = facetValues[`${groupBy}:${task.id}`] ?? [];
const entries = values.length ? values : [{ value: "untagged", label: t("tasks:ungrouped") }];
for (const value of entries) {
const key = values.length ? facetValueSectionKey(value.value) : UNGROUPED_FACET_SECTION_KEY;
const section = grouped.get(key) ?? { title: value.label, color: value.color, tasks: [] };
section.tasks.push(task);
grouped.set(key, section);
}
}
return Array.from(grouped.entries())
.map(([key, section]) => ({
key,
title: section.title,
color: section.color,
nodes: buildTaskTree(section.tasks),
}))
.sort((a, b) =>
(a.title ?? "").localeCompare(b.title ?? "", undefined, { sensitivity: "base" }),
);
}
if (groupBy === "none") {
return [{ key: "all", title: null, nodes: roots }];
}
@@ -262,7 +299,7 @@ function buildTaskTree(tasks: Task[]): TaskTreeNode[] {
function groupForTask(
task: Task,
groupBy: TasksListGroup,
groupBy: string,
workflowMap: Map<string, string>,
repoMap: Map<string, string>,
) {
@@ -283,7 +320,7 @@ function groupForTask(
return { key: `state:${task.state}`, title };
}
function compareSection(a: TaskListSection, b: TaskListSection, groupBy: TasksListGroup): number {
function compareSection(a: TaskListSection, b: TaskListSection, groupBy: string): number {
if (groupBy === "state") {
const aIndex = TASK_STATE_ORDER.indexOf(a.key.replace("state:", "") as Task["state"]);
const bIndex = TASK_STATE_ORDER.indexOf(b.key.replace("state:", "") as Task["state"]);
@@ -397,6 +434,12 @@ function TaskListSectionView({
<section className="space-y-2" data-testid="tasks-list-section">
{section.title && (
<div className="flex items-center gap-2 px-1 text-xs font-semibold uppercase tracking-normal text-muted-foreground">
{section.color && (
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: section.color }}
/>
)}
<span>{section.title}</span>
<span className="text-muted-foreground/70">{rows.length}</span>
</div>
+98 -101
View File
@@ -10,10 +10,6 @@ import {
unarchiveTask,
updateUserSettings,
} from "@/lib/api";
import { KanbanHeader } from "@/components/kanban/kanban-header";
import { MobileFab } from "@/components/kanban/mobile-fab";
import { MobileSearchBar } from "@/components/kanban/mobile-search-bar";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { Task, Workspace, Workflow, Repository } from "@/lib/types/http";
import { useToast } from "@/components/toast-provider";
// Module-level `t`: every use below is inside a callback or a plain helper
@@ -29,10 +25,14 @@ import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
import { useWorkflowSnapshot } from "@/hooks/use-workflow-snapshot";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
import { useTaskListFacets } from "@/hooks/use-task-list-facets";
import { useTaskListFacetSelection } from "@/hooks/use-task-list-facet-selection";
import { linkToTask } from "@/lib/links";
import { unarchiveToastPayload } from "@/lib/tasks/unarchive-feedback";
import { shouldSkipInitialTasksFetch } from "./tasks-page-fetch-policy";
import { TasksListView } from "./tasks-list-view";
import { TasksPageContent } from "./tasks-page-content";
import { type MobileTaskStep } from "./mobile-tasks-create-dialog";
import { MobileTasksActions } from "./mobile-tasks-actions";
import {
parseTasksListGroup,
parseTasksListSort,
@@ -65,16 +65,7 @@ type UseTaskOperationsParams = {
setTotal: (total: number) => void;
};
const EMPTY_WORKFLOW_STEPS: KanbanStep[] = [];
type KanbanStep = {
id: string;
title: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
};
const EMPTY_WORKFLOW_STEPS: MobileTaskStep[] = [];
function useLatestWorkspaceRequest(activeWorkspaceId: string | null) {
const latestFetchRef = useRef({ seq: 0, workspaceId: activeWorkspaceId });
@@ -523,6 +514,11 @@ export function TasksPageClient(props: TasksPageClientProps) {
const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen);
const { isMobile } = useResponsiveBreakpoint();
const showTaskDetails = useAppStore((state) => state.userSettings.tasksListShowDetails ?? false);
const { facets, values: facetValues } = useTaskListFacets(s.tasks, s.activeWorkspaceId);
const facetOptions = useMemo(
() => facets.map((facet) => ({ value: facet.key, label: facet.label })),
[facets],
);
const activeSteps = useAppStore((state) =>
state.kanban.workflowId === s.activeWorkflowId ? state.kanban.steps : EMPTY_WORKFLOW_STEPS,
);
@@ -540,6 +536,15 @@ export function TasksPageClient(props: TasksPageClientProps) {
setTasks: s.setTasks,
setPagination: s.setPagination,
});
const { displayedTasks, sort, group, selectSort, selectGroup } = useTaskListFacetSelection({
facetKeys: facetOptions.map((facet) => facet.value),
coreSort: s.tasksListSort,
coreGroup: s.tasksListGroup,
tasks: s.tasks,
facetValues,
onCoreSortChange: handleSortChange,
onCoreGroupChange: handleGroupChange,
});
useEffect(() => {
setMobileSearchOpen(false);
@@ -551,95 +556,87 @@ export function TasksPageClient(props: TasksPageClientProps) {
}, [setView]);
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<KanbanHeader
workspaceId={s.activeWorkspaceId ?? undefined}
currentPage="tasks"
searchQuery={s.searchQuery}
onSearchChange={s.setSearchQuery}
isSearchLoading={s.isLoading && !!s.debouncedQuery}
tasksListOptions={{
<TasksPageContent
header={{
workspaceId: s.activeWorkspaceId ?? undefined,
currentPage: "tasks",
searchQuery: s.searchQuery,
onSearchChange: s.setSearchQuery,
isSearchLoading: s.isLoading && !!s.debouncedQuery,
tasksListOptions: {
showArchived: s.showArchived,
onShowArchivedChange: s.setShowArchived,
sort: s.tasksListSort,
onSortChange: handleSortChange,
group: s.tasksListGroup,
onGroupChange: handleGroupChange,
}}
/>
{isMobile && isMobileSearchOpen && (
<MobileSearchBar searchQuery={s.searchQuery} onSearchChange={s.setSearchQuery} />
)}
<TasksListView
showArchived={s.showArchived}
setShowArchived={s.setShowArchived}
tasksListSort={s.tasksListSort}
onTasksListSortChange={handleSortChange}
tasksListGroup={s.tasksListGroup}
onTasksListGroupChange={handleGroupChange}
tasks={s.tasks}
workflows={s.workflows}
repositories={s.repositories}
showTaskDetails={showTaskDetails}
total={s.total}
pageCount={s.pageCount}
pagination={s.pagination}
setPagination={s.setPagination}
isLoading={s.isLoading}
handleRowClick={s.handleRowClick}
deletingTaskId={s.deletingTaskId}
handleArchive={s.handleArchive}
handleUnarchive={s.handleUnarchive}
handleDelete={s.handleDelete}
onRefresh={isMobile ? () => s.fetchTasks() : undefined}
/>
{isMobile && s.activeWorkspaceId && (
<>
<MobileFab onClick={() => setIsCreateOpen(true)} />
<MobileTasksCreateDialog
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
workspaceId={s.activeWorkspaceId}
workflowId={s.activeWorkflowId}
steps={activeSteps}
onCreated={() => s.fetchTasks(true)}
/>
</>
)}
</div>
);
}
type MobileTasksCreateDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string;
workflowId: string | null;
steps: KanbanStep[];
onCreated: () => Promise<void>;
};
function MobileTasksCreateDialog({
open,
onOpenChange,
workspaceId,
workflowId,
steps,
onCreated,
}: MobileTasksCreateDialogProps) {
return (
<TaskCreateDialog
open={open}
onOpenChange={onOpenChange}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={() => {
onOpenChange(false);
void onCreated();
sort,
onSortChange: selectSort,
group,
onGroupChange: selectGroup,
facetOptions,
},
}}
isMobile={isMobile}
isMobileSearchOpen={isMobileSearchOpen}
tasks={displayedTasks}
workflows={s.workflows}
repositories={s.repositories}
facetOptions={facetOptions}
facetValues={facetValues}
total={s.total}
pageCount={s.pageCount}
pagination={s.pagination}
setPagination={s.setPagination}
isLoading={s.isLoading}
showArchived={s.showArchived}
showTaskDetails={showTaskDetails}
sort={sort}
group={group}
onSortChange={selectSort}
onGroupChange={selectGroup}
onShowArchivedChange={s.setShowArchived}
onRowClick={s.handleRowClick}
deletingTaskId={s.deletingTaskId}
onArchive={s.handleArchive}
onUnarchive={s.handleUnarchive}
onDelete={s.handleDelete}
onRefresh={() => s.fetchTasks()}
mobileActions={mobileTaskActions({
isMobile,
workspaceId: s.activeWorkspaceId,
workflowId: s.activeWorkflowId,
steps: activeSteps,
open: isCreateOpen,
onOpenChange: setIsCreateOpen,
onCreated: () => s.fetchTasks(true),
})}
/>
);
}
function mobileTaskActions({
isMobile,
workspaceId,
workflowId,
steps,
open,
onOpenChange,
onCreated,
}: {
isMobile: boolean;
workspaceId: string | null;
workflowId: string | null;
steps: MobileTaskStep[];
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => Promise<void>;
}) {
if (!isMobile || !workspaceId) return null;
return (
<MobileTasksActions
workspaceId={workspaceId}
workflowId={workflowId}
steps={steps}
open={open}
onOpenChange={onOpenChange}
onCreated={onCreated}
/>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { PaginationState } from "@tanstack/react-table";
import type { ReactNode } from "react";
import type { Repository, Task, Workflow } from "@/lib/types/http";
import type { TaskListFacetValue } from "@/lib/plugins/types";
import { KanbanHeader } from "@/components/kanban/kanban-header";
import { MobileSearchBar } from "@/components/kanban/mobile-search-bar";
import { TasksListView } from "./tasks-list-view";
type Props = {
header: React.ComponentProps<typeof KanbanHeader>;
isMobile: boolean;
isMobileSearchOpen: boolean;
tasks: Task[];
workflows: Workflow[];
repositories: Repository[];
facetOptions: ReadonlyArray<{ value: string; label: string }>;
facetValues: Record<string, readonly TaskListFacetValue[]>;
total: number;
pageCount: number;
pagination: PaginationState;
setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void;
isLoading: boolean;
showArchived: boolean;
showTaskDetails: boolean;
sort: string;
group: string;
onSortChange: (value: string) => void;
onGroupChange: (value: string) => void;
onShowArchivedChange: (value: boolean) => void;
onRowClick: (task: Task) => void;
deletingTaskId: string | null;
onArchive: (id: string, opts?: { cascade?: boolean }) => Promise<void>;
onUnarchive: (id: string) => Promise<void>;
onDelete: (id: string, opts?: { cascade?: boolean }) => Promise<void>;
onRefresh: () => Promise<void>;
mobileActions: ReactNode;
};
export function TasksPageContent(props: Props) {
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<KanbanHeader {...props.header} />
{props.isMobile && props.isMobileSearchOpen && (
<MobileSearchBar
searchQuery={props.header.searchQuery ?? ""}
onSearchChange={props.header.onSearchChange!}
/>
)}
<TasksListView
showArchived={props.showArchived}
setShowArchived={props.onShowArchivedChange}
tasksListSort={props.sort}
onTasksListSortChange={props.onSortChange}
tasksListGroup={props.group}
onTasksListGroupChange={props.onGroupChange}
facetOptions={props.facetOptions}
facetValues={props.facetValues}
tasks={props.tasks}
workflows={props.workflows}
repositories={props.repositories}
showTaskDetails={props.showTaskDetails}
total={props.total}
pageCount={props.pageCount}
pagination={props.pagination}
setPagination={props.setPagination}
isLoading={props.isLoading}
handleRowClick={props.onRowClick}
deletingTaskId={props.deletingTaskId}
handleArchive={props.onArchive}
handleUnarchive={props.onUnarchive}
handleDelete={props.onDelete}
onRefresh={props.isMobile ? props.onRefresh : undefined}
/>
{props.mobileActions}
</div>
);
}
@@ -7,18 +7,17 @@ import {
SORT_OPTION_LABEL_KEYS,
TASKS_LIST_GROUP_OPTIONS,
TASKS_LIST_SORT_OPTIONS,
type TasksListGroup,
type TasksListSort,
} from "@/lib/tasks/tasks-list-options";
import { useTranslation } from "react-i18next";
export type TasksListDisplayOptions = {
showArchived: boolean;
onShowArchivedChange: (show: boolean) => void;
sort: TasksListSort;
onSortChange: (sort: TasksListSort) => void;
group: TasksListGroup;
onGroupChange: (group: TasksListGroup) => void;
sort: string;
onSortChange: (sort: string) => void;
group: string;
onGroupChange: (group: string) => void;
facetOptions?: ReadonlyArray<{ value: string; label: string }>;
};
const fieldClass = "space-y-1.5";
@@ -35,10 +34,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
<label id="mobile-tasks-list-sort-label" className={fieldLabelClass}>
{t("kanban:sort")}
</label>
<Select
value={options.sort}
onValueChange={(value) => options.onSortChange(value as TasksListSort)}
>
<Select value={options.sort} onValueChange={options.onSortChange}>
<SelectTrigger
id="mobile-tasks-list-sort-select"
aria-labelledby="mobile-tasks-list-sort-label"
@@ -48,9 +44,15 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASKS_LIST_SORT_OPTIONS.map((option) => (
{[
...TASKS_LIST_SORT_OPTIONS.map((option) => ({
value: option.value,
label: t(SORT_OPTION_LABEL_KEYS[option.value]),
})),
...(options.facetOptions ?? []),
].map((option) => (
<SelectItem key={option.value} value={option.value} className="cursor-pointer">
{t(SORT_OPTION_LABEL_KEYS[option.value])}
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -61,10 +63,7 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
<label id="mobile-tasks-list-group-label" className={fieldLabelClass}>
{t("kanban:group")}
</label>
<Select
value={options.group}
onValueChange={(value) => options.onGroupChange(value as TasksListGroup)}
>
<Select value={options.group} onValueChange={options.onGroupChange}>
<SelectTrigger
id="mobile-tasks-list-group-select"
aria-labelledby="mobile-tasks-list-group-label"
@@ -74,9 +73,15 @@ export function MobileTasksListOptions({ options }: { options: TasksListDisplayO
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASKS_LIST_GROUP_OPTIONS.map((option) => (
{[
...TASKS_LIST_GROUP_OPTIONS.map((option) => ({
value: option.value,
label: t(GROUP_OPTION_LABEL_KEYS[option.value]),
})),
...(options.facetOptions ?? []),
].map((option) => (
<SelectItem key={option.value} value={option.value} className="cursor-pointer">
{t(GROUP_OPTION_LABEL_KEYS[option.value])}
{option.label}
</SelectItem>
))}
</SelectContent>
@@ -0,0 +1,46 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { expect, test } from "../../fixtures/test-base";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
test.describe("Mobile plugin task-list facet", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("adds a facet to the mobile Group picker", async ({ testPage, apiClient, seedData }) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
const taskId = (
await apiClient.createTask(seedData.workspaceId, "Mobile facet task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
})
).id;
await testPage.goto("/tasks?group=none");
await testPage.waitForLoadState("networkidle");
await testPage.evaluate(
({ taskId }: { taskId: string }) => {
const win = window as Record<string, unknown>;
win.__e2eFacetValues = { [taskId]: [{ value: "alpha", label: "Alpha" }] };
(win.__e2eFacetNotify as () => void)();
},
{ taskId },
);
await testPage.getByRole("button", { name: "Open menu" }).tap();
const menu = testPage.getByRole("dialog", { name: "Menu" });
await menu.getByTestId("mobile-tasks-list-group").tap();
await testPage.getByRole("listbox").getByRole("option", { name: "Fixture tag" }).tap();
const section = testPage.getByTestId("tasks-list-section");
await expect(section).toHaveCount(1);
await expect(section).toContainText("Alpha");
await expect(section).toContainText("Mobile facet task");
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true);
});
});
@@ -0,0 +1,50 @@
import { expect, test } from "../../fixtures/test-base";
import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture";
test.describe("Plugin task-list facet", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("adds a facet to desktop Sort and Group controls", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixturePlugin(testPage);
const taskId = (
await apiClient.createTask(seedData.workspaceId, "Desktop facet task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
})
).id;
await testPage.goto("/tasks?group=none");
await testPage.waitForLoadState("networkidle");
await testPage.evaluate(
({ taskId }: { taskId: string }) => {
const win = window as Record<string, unknown>;
win.__e2eFacetValues = { [taskId]: [{ value: "alpha", label: "Alpha" }] };
(win.__e2eFacetNotify as () => void)();
},
{ taskId },
);
await testPage.getByTestId("tasks-list-sort").click();
await expect(
testPage.getByRole("listbox").getByRole("option", { name: "Fixture tag" }),
).toBeVisible();
await testPage.getByRole("listbox").getByRole("option", { name: "Fixture tag" }).click();
await testPage.getByTestId("tasks-list-group").click();
await testPage.getByRole("listbox").getByRole("option", { name: "Fixture tag" }).click();
const section = testPage.getByTestId("tasks-list-section");
await expect(section).toHaveCount(1);
await expect(section).toContainText("Alpha");
await expect(section).toContainText("Desktop facet task");
});
});
@@ -0,0 +1,33 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useTaskListFacetSelection } from "./use-task-list-facet-selection";
const FACET_KEY = "facet:plugin:tags";
const baseProps = {
facetKeys: [FACET_KEY],
coreSort: "updated" as never,
coreGroup: "none" as never,
tasks: [{ id: "task-1" }] as never[],
facetValues: {},
onCoreSortChange: vi.fn(),
onCoreGroupChange: vi.fn(),
};
describe("useTaskListFacetSelection", () => {
it("returns to core controls when the selected facet unregisters", () => {
const { result, rerender } = renderHook((props) => useTaskListFacetSelection(props), {
initialProps: baseProps,
});
act(() => result.current.selectSort(FACET_KEY));
act(() => result.current.selectGroup(FACET_KEY));
expect(result.current.sort).toBe(FACET_KEY);
expect(result.current.group).toBe(FACET_KEY);
rerender({ ...baseProps, facetKeys: [] });
expect(result.current.sort).toBe("updated");
expect(result.current.group).toBe("none");
});
});
@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from "react";
import type { Task } from "@/lib/types/http";
import {
isTaskListFacetOption,
sortTasksByFacet,
type TasksListGroup,
type TasksListSort,
} from "@/lib/tasks/tasks-list-options";
import type { TaskFacetValues } from "./use-task-list-facets";
type TaskListFacetSelectionParams = {
facetKeys: readonly string[];
coreSort: TasksListSort;
coreGroup: TasksListGroup;
tasks: Task[];
facetValues: TaskFacetValues;
onCoreSortChange: (sort: TasksListSort) => void;
onCoreGroupChange: (group: TasksListGroup) => void;
};
export function useTaskListFacetSelection({
facetKeys,
coreSort,
coreGroup,
tasks,
facetValues,
onCoreSortChange,
onCoreGroupChange,
}: TaskListFacetSelectionParams) {
const [facetSort, setFacetSort] = useState<string | null>(null);
const [facetGroup, setFacetGroup] = useState<string | null>(null);
useEffect(() => {
if (facetSort && !facetKeys.includes(facetSort)) setFacetSort(null);
if (facetGroup && !facetKeys.includes(facetGroup)) setFacetGroup(null);
}, [facetGroup, facetKeys, facetSort]);
const displayedTasks = useMemo(
() => (facetSort ? sortTasksByFacet(tasks, facetSort, facetValues) : tasks),
[facetSort, facetValues, tasks],
);
const selectSort = (value: string) => {
if (isTaskListFacetOption(value)) {
setFacetSort(value);
return;
}
setFacetSort(null);
onCoreSortChange(value as TasksListSort);
};
const selectGroup = (value: string) => {
if (isTaskListFacetOption(value)) {
setFacetGroup(value);
return;
}
setFacetGroup(null);
onCoreGroupChange(value as TasksListGroup);
};
return {
displayedTasks,
sort: facetSort ?? coreSort,
group: facetGroup ?? coreGroup,
selectSort,
selectGroup,
};
}
@@ -0,0 +1,144 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedTaskListFacet } from "./use-task-list-facets";
import { resolveTaskFacetValues, useTaskListFacets } from "./use-task-list-facets";
import { pluginRegistry } from "@/lib/plugins/registry";
const TASKS = [{ id: "task-1" }] as never[];
const PLUGIN_ID = "facet-hook-test";
const WORKSPACE_ID = "workspace-1";
afterEach(() => {
cleanup();
pluginRegistry.unregisterPlugin(PLUGIN_ID);
vi.restoreAllMocks();
});
function facet(overrides: Partial<ResolvedTaskListFacet> = {}): ResolvedTaskListFacet {
return {
pluginId: PLUGIN_ID,
id: "tags",
label: "Tag",
key: `facet:${PLUGIN_ID}:tags`,
getValues: () => [],
...overrides,
};
}
describe("resolveTaskFacetValues", () => {
it("returns no values without a workspace", () => {
expect(resolveTaskFacetValues([facet()], TASKS, null)).toEqual({});
});
it("filters malformed values and contains callback failures", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const values = resolveTaskFacetValues(
[
facet({
getValues: () => [
{ value: "valid", label: "Valid" },
{ value: "missing-label" } as never,
null as never,
],
}),
facet({
id: "broken",
key: "facet:facet-hook-test:broken",
getValues: () => {
throw new Error("nope");
},
}),
],
TASKS,
WORKSPACE_ID,
);
expect(values).toEqual({
[`facet:${PLUGIN_ID}:tags:task-1`]: [{ value: "valid", label: "Valid" }],
[`facet:${PLUGIN_ID}:broken:task-1`]: [],
});
});
});
describe("useTaskListFacets", () => {
it("namespaces each facet key with its plugin and registration id", () => {
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskListFacet({
id: "tags",
label: "Tag",
getValues: () => [],
});
const { result } = renderHook(() => useTaskListFacets(TASKS, WORKSPACE_ID));
expect(result.current.facets.map((entry) => entry.key)).toEqual([`facet:${PLUGIN_ID}:tags`]);
});
it("keeps healthy subscriptions active when another facet throws and cleans them up", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const unsubscribe = vi.fn();
const subscribe = vi.fn(() => unsubscribe);
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskListFacet({
id: "healthy",
label: "Healthy",
getValues: () => [],
subscribe,
});
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskListFacet({
id: "broken",
label: "Broken",
getValues: () => [],
subscribe: () => {
throw new Error("nope");
},
});
const { unmount } = renderHook(() => useTaskListFacets(TASKS, WORKSPACE_ID));
expect(subscribe).toHaveBeenCalledOnce();
act(unmount);
expect(unsubscribe).toHaveBeenCalledOnce();
});
// The facet is a live projection, not a load-time snapshot: a plugin whose
// catalog changes after mount notifies through subscribe(), and every consumer
// of `values` (facet sort order, facet group sections) has to see the new
// labels without the task list itself refetching.
it("reprojects values when a facet notifies its subscriber", () => {
let labels = ["Alpha"];
let notify = () => undefined as void;
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskListFacet({
id: "tags",
label: "Tag",
getValues: () => labels.map((label) => ({ value: label.toLowerCase(), label })),
subscribe: (listener) => {
notify = listener;
return () => undefined;
},
});
const { result } = renderHook(() => useTaskListFacets(TASKS, WORKSPACE_ID));
const key = `facet:${PLUGIN_ID}:tags:task-1`;
expect(result.current.values[key]).toEqual([{ value: "alpha", label: "Alpha" }]);
labels = ["Beta", "Gamma"];
act(() => notify());
expect(result.current.values[key]).toEqual([
{ value: "beta", label: "Beta" },
{ value: "gamma", label: "Gamma" },
]);
});
it("passes the active workspace id to getValues", () => {
const getValues = vi.fn(() => []);
pluginRegistry.forPlugin(PLUGIN_ID).registerTaskListFacet({
id: "tags",
label: "Tag",
getValues,
});
renderHook(() => useTaskListFacets(TASKS, WORKSPACE_ID));
expect(getValues).toHaveBeenCalledWith({ taskId: "task-1", workspaceId: WORKSPACE_ID });
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useMemo, useState } from "react";
import { pluginTaskListFacetRegistrationKey, usePluginRegistry } from "@/lib/plugins/registry";
import type { PluginTaskListFacetRegistration } from "@/lib/plugins/registry";
import type { TaskListFacetValue } from "@/lib/plugins/types";
import type { Task } from "@/lib/types/http";
import { TASK_LIST_FACET_PREFIX } from "@/lib/tasks/tasks-list-options";
export type ResolvedTaskListFacet = PluginTaskListFacetRegistration & { key: string };
export type TaskFacetValues = Record<string, readonly TaskListFacetValue[]>;
export function useTaskListFacets(tasks: Task[], workspaceId: string | null) {
const registry = usePluginRegistry();
const registrations = registry.getTaskListFacets();
const registryVersion = registry.getVersion();
const facets = useMemo(
() =>
registrations.map((facet) => ({
...facet,
key: `${TASK_LIST_FACET_PREFIX}${pluginTaskListFacetRegistrationKey(facet)}`,
})),
[registryVersion],
);
const [revision, setRevision] = useState(0);
useEffect(() => {
const unsubscribers = facets.flatMap((facet) => {
if (!facet.subscribe) return [];
try {
return [facet.subscribe(() => setRevision((value) => value + 1))];
} catch (error) {
console.error(
`[plugins] task-list facet "${facet.pluginId}:${facet.id}" subscribe() threw`,
error,
);
return [];
}
});
return () =>
unsubscribers.forEach((unsubscribe) => {
try {
unsubscribe();
} catch (error) {
console.error("[plugins] task-list facet unsubscribe() threw", error);
}
});
}, [facets]);
const values = useMemo(
() => resolveTaskFacetValues(facets, tasks, workspaceId),
[facets, tasks, workspaceId, revision],
);
return { facets, values };
}
export function resolveTaskFacetValues(
facets: ResolvedTaskListFacet[],
tasks: Task[],
workspaceId: string | null,
): TaskFacetValues {
if (!workspaceId) return {};
const resolved: TaskFacetValues = {};
for (const facet of facets) {
for (const task of tasks) {
try {
const values = facet.getValues({ taskId: task.id, workspaceId });
resolved[`${facet.key}:${task.id}`] = Array.isArray(values)
? values.filter(
(value) =>
value && typeof value.value === "string" && typeof value.label === "string",
)
: [];
} catch (error) {
console.error(
`[plugins] task-list facet "${facet.pluginId}:${facet.id}" getValues() threw`,
error,
);
resolved[`${facet.key}:${task.id}`] = [];
}
}
}
return resolved;
}
@@ -9,6 +9,7 @@ import type {
SlotComponent,
TaskActionRegistration,
TaskFilterRegistration,
TaskListFacetRegistration,
TaskMenuActionRegistration,
TaskPanelRegistration,
} from "./types";
@@ -79,6 +80,17 @@ export interface PluginTaskFilterRegistration extends TaskFilterRegistration {
pluginId: string;
}
/** Task-list facet registration paired with the plugin that owns it. */
export interface PluginTaskListFacetRegistration extends TaskListFacetRegistration {
pluginId: string;
}
export function pluginTaskListFacetRegistrationKey(
registration: Pick<PluginTaskListFacetRegistration, "pluginId" | "id">,
): string {
return `${registration.pluginId}:${registration.id}`;
}
/** Stable UI/state identity for plugin-local task filter ids. */
export function pluginTaskFilterRegistrationKey(
registration: Pick<PluginTaskFilterRegistration, "pluginId" | "id">,
+27
View File
@@ -427,6 +427,33 @@ describe("pluginRegistry — task filters", () => {
});
});
describe("pluginRegistry — task-list facets", () => {
afterEach(() => cleanup("plugin-a", "plugin-b"));
it("namespaces facet identities and revokes them with their plugin", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
const getValues = () => [{ value: "bug", label: "Bug", color: "#f00" }];
scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues });
expect(pluginRegistry.getTaskListFacets()).toEqual([
{ pluginId: "plugin-a", id: "tags", label: "Tag", getValues },
]);
pluginRegistry.unregisterPlugin("plugin-a");
expect(pluginRegistry.getTaskListFacets()).toEqual([]);
});
it("rejects unsafe and duplicate plugin-local facet ids", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
expect(() =>
scoped.registerTaskListFacet({ id: "Not safe", label: "Tag", getValues: () => [] }),
).toThrow("URL-safe");
scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues: () => [] });
expect(() =>
scoped.registerTaskListFacet({ id: "tags", label: "Tag", getValues: () => [] }),
).toThrow("already registered");
});
});
describe("pluginRegistry — route options and plugin names", () => {
afterEach(() => {
cleanup("plugin-a");
+28 -1
View File
@@ -23,6 +23,7 @@ import type {
SlotComponent,
TaskActionRegistration,
TaskFilterRegistration,
TaskListFacetRegistration,
TaskMenuActionRegistration,
TaskPanelRegistration,
WsHandler,
@@ -49,6 +50,7 @@ import type {
PluginTaskActionRegistration,
PluginTaskFilterRegistration,
PluginTaskMenuActionRegistration,
PluginTaskListFacetRegistration,
PluginTaskPanelRegistration,
RouteRegistration,
} from "./registry-registration-types";
@@ -111,6 +113,7 @@ class PluginRegistryStore {
private taskPanels: Owned<TaskPanelRegistration>[] = [];
private taskMenuActions: Owned<TaskMenuActionRegistration>[] = [];
private taskFilters: Owned<TaskFilterRegistration>[] = [];
private taskListFacets: Owned<TaskListFacetRegistration>[] = [];
private nextSlotRegistrationId = 0;
/** Display names from the boot payload, used for derived page-chrome titles. */
private pluginNames = new Map<string, string>();
@@ -305,6 +308,23 @@ class PluginRegistryStore {
this.notify();
}
registerTaskListFacet(pluginId: string, registration: TaskListFacetRegistration): void {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(registration.id)) {
throw new Error(`[plugins] task-list facet id "${registration.id}" must be URL-safe`);
}
if (
this.taskListFacets.some(
(entry) => entry.pluginId === pluginId && entry.value.id === registration.id,
)
) {
throw new Error(
`[plugins] task-list facet "${registration.id}" is already registered by "${pluginId}"`,
);
}
this.taskListFacets.push({ pluginId, value: registration });
this.notify();
}
/**
* Records the keybinding ids declared in `pluginId`'s `ui.keybindings`
* manifest, so `registerKeybinding` can warn on an undeclared id. Safe to
@@ -395,6 +415,7 @@ class PluginRegistryStore {
this.taskPanels = removeByPlugin(this.taskPanels, pluginId);
this.taskMenuActions = removeByPlugin(this.taskMenuActions, pluginId);
this.taskFilters = removeByPlugin(this.taskFilters, pluginId);
this.taskListFacets = removeByPlugin(this.taskListFacets, pluginId);
this.pluginNames.delete(pluginId);
this.declaredKeybindingIds.delete(pluginId);
const removedEnabledState = removedIntegrationIds.reduce(
@@ -555,6 +576,10 @@ class PluginRegistryStore {
return this.taskFilters.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
}
getTaskListFacets(): PluginTaskListFacetRegistration[] {
return this.taskListFacets.map((entry) => ({ ...entry.value, pluginId: entry.pluginId }));
}
/** Registry view scoped to one plugin — matches the frozen `PluginRegistry` contract. */
forPlugin(pluginId: string, pluginName?: string): PluginRegistry {
if (pluginName) this.pluginNames.set(pluginId, pluginName);
@@ -576,6 +601,7 @@ class PluginRegistryStore {
registerTaskPanel: (registration) => this.registerTaskPanel(pluginId, registration),
registerTaskMenuAction: (registration) => this.registerTaskMenuAction(pluginId, registration),
registerTaskFilter: (registration) => this.registerTaskFilter(pluginId, registration),
registerTaskListFacet: (registration) => this.registerTaskListFacet(pluginId, registration),
};
}
@@ -617,7 +643,8 @@ class PluginRegistryStore {
this.reviewProviders.size +
this.taskPanels.length +
this.taskMenuActions.length +
this.taskFilters.length
this.taskFilters.length +
this.taskListFacets.length
);
}
+4
View File
@@ -333,6 +333,10 @@ export type PluginTaskFilterRegistrationKey = `${string}:${string}`;
*/
export type TaskFilterRegistration = PluginSDK.TaskFilterRegistration;
/** A plugin-provided, client-side facet for sorting and grouping `/tasks`. */
export type TaskListFacetRegistration = PluginSDK.TaskListFacetRegistration;
export type TaskListFacetValue = PluginSDK.TaskListFacetValue;
/** One entry returned by `host.storage.list`. */
export type PluginStorageEntry = PluginSDK.PluginStorageEntry;
@@ -4,6 +4,7 @@ import {
SORT_OPTION_LABEL_KEYS,
TASKS_LIST_GROUP_OPTIONS,
TASKS_LIST_SORT_OPTIONS,
sortTasksByFacet,
} from "./tasks-list-options";
describe("SORT_OPTION_LABEL_KEYS", () => {
@@ -21,6 +22,25 @@ describe("SORT_OPTION_LABEL_KEYS", () => {
});
});
describe("sortTasksByFacet", () => {
it("sorts by the first label, keeps ties stable, and leaves untagged tasks last", () => {
const tasks = [
{ id: "one", title: "one" },
{ id: "two", title: "two" },
{ id: "three", title: "three" },
] as never[];
expect(
sortTasksByFacet(tasks, "facet:plugin:tags", {
"facet:plugin:tags:one": [{ value: "z", label: "Zulu" }],
"facet:plugin:tags:two": [
{ value: "a", label: "alpha" },
{ value: "z", label: "Zulu" },
],
}).map((task) => task.id),
).toEqual(["two", "one", "three"]);
});
});
describe("GROUP_OPTION_LABEL_KEYS", () => {
it("maps every group option to a tasks: translation key", () => {
for (const option of TASKS_LIST_GROUP_OPTIONS) {
+38
View File
@@ -1,4 +1,5 @@
import type { Task, TaskState } from "@/lib/types/http";
import type { TaskListFacetValue } from "@/lib/plugins/types";
// Values only. These carried a parallel `label: "Updated newest"` alongside the
// key maps below, described as a "fallback/debug value" — but the mobile menu
@@ -28,6 +29,13 @@ export type TasksListGroup = (typeof TASKS_LIST_GROUP_OPTIONS)[number]["value"];
export const DEFAULT_TASKS_LIST_SORT: TasksListSort = "updated_desc";
export const DEFAULT_TASKS_LIST_GROUP: TasksListGroup = "state";
// i18n-exempt: internal client-only option namespace, never rendered as copy.
export const TASK_LIST_FACET_PREFIX = "facet:";
export function isTaskListFacetOption(value: string): boolean {
return value.startsWith(TASK_LIST_FACET_PREFIX);
}
// The only source of display copy for these options; resolved at render against
// the `tasks:` catalog.
export const SORT_OPTION_LABEL_KEYS: Record<TasksListSort, string> = {
@@ -97,3 +105,33 @@ export function compareTasksForList(a: Task, b: Task, sort: TasksListSort): numb
function compareDate(a: string, b: string): number {
return Date.parse(a) - Date.parse(b);
}
export function sortTasksByFacet(
tasks: Task[],
facetKey: string,
values: Record<string, readonly TaskListFacetValue[]>,
): Task[] {
return tasks
.map((task, index) => ({
task,
index,
label: firstFacetLabel(values[`${facetKey}:${task.id}`] ?? []),
}))
.sort((a, b) => {
if (!a.label && !b.label) return a.index - b.index;
if (!a.label) return 1;
if (!b.label) return -1;
return (
a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) || a.index - b.index
);
})
.map(({ task }) => task);
}
export function firstFacetLabel(values: readonly TaskListFacetValue[]): string | null {
return (
values
.map((value) => value.label)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))[0] ?? null
);
}
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "Repository",
"groupByState": "State",
"groupByWorkflow": "Workflow",
"ungrouped": "Ungrouped",
"loading": "Loading...",
"loadingTasks": "Loading tasks...",
"noRepository": "No repository",
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "Ŕēƥōśĩţōŕŷ",
"groupByState": "Śţàţē",
"groupByWorkflow": "Ŵōŕķƒĺōŵ",
"ungrouped": "Ŭńĝŕōũƥēđ",
"loading": "Ĺōàďĩńĝ...",
"loadingTasks": "Ĺōàďĩńĝ ţàśķś...",
"noRepository": "Ńō ŕēƥōśĩţōŕŷ",
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "Repositório",
"groupByState": "Estado",
"groupByWorkflow": "Fluxo de trabalho",
"ungrouped": "Sem grupo",
"loading": "A carregar...",
"loadingTasks": "A carregar as tarefas...",
"noRepository": "Sem repositório",
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "仓库",
"groupByState": "状态",
"groupByWorkflow": "工作流",
"ungrouped": "未分组",
"loading": "加载中…",
"loadingTasks": "正在加载任务…",
"noRepository": "无仓库",
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "儲存庫",
"groupByState": "狀態",
"groupByWorkflow": "工作流程",
"ungrouped": "未分組",
"loading": "載入中…",
"loadingTasks": "正在載入任務…",
"noRepository": "無儲存庫",
+1
View File
@@ -23,6 +23,7 @@
"groupByRepository": "儲存庫",
"groupByState": "狀態",
"groupByWorkflow": "工作流程",
"ungrouped": "未分組",
"loading": "載入中…",
"loadingTasks": "正在載入任務…",
"noRepository": "無儲存庫",
+35 -5
View File
@@ -47,7 +47,7 @@ repositoryProviderIds?: string[] }`. `repositoryProviderIds` is JSON
from an expired generation cannot mutate the replacement generation. Failure or
timeout does **not** unregister `registry` contributions (nav items, routes,
etc.) already made before the failure — those persist, and only the plugin's
lifecycle status becomes failed, until the plugin's *next* load revokes them.
lifecycle status becomes failed, until the plugin's _next_ load revokes them.
## Global entry point
@@ -164,7 +164,11 @@ interface PluginHostApi {
// Publishes one integration registration's live enabled state for one
// workspace. The value is memory-only; persist it with host.storage and
// republish it for every workspace after plugin load.
setIntegrationEnabled(integrationId: string, workspaceId: string, enabled: boolean): void;
setIntegrationEnabled(
integrationId: string,
workspaceId: string,
enabled: boolean,
): void;
// Authenticated, per-user key/value storage backed by
// /api/plugins/{id}/user-state/... — see the "host.storage" section below.
// Requires the plugin manifest to declare capabilities.user_state: true.
@@ -525,9 +529,17 @@ cleared when the plugin unloads. Persist the source of truth with
`host.storage`, then republish it after load:
```ts
function publishAll(host: PluginHostApi, integrationId: string, enabledByWorkspace: Map<string, boolean>) {
function publishAll(
host: PluginHostApi,
integrationId: string,
enabledByWorkspace: Map<string, boolean>,
) {
for (const workspaceId of host.context.getWorkspaceIds()) {
host.setIntegrationEnabled(integrationId, workspaceId, enabledByWorkspace.get(workspaceId) === true);
host.setIntegrationEnabled(
integrationId,
workspaceId,
enabledByWorkspace.get(workspaceId) === true,
);
}
}
@@ -631,7 +643,11 @@ own write).
// unrecognised one, simply degrade to "main"'s placement — nothing is ever
// silently dropped.
type PluginIcon = string | React.ComponentType<{ className?: string }>;
export type PluginNavSection = "main" | "settings" | "integrations" | "sidebar-footer";
export type PluginNavSection =
| "main"
| "settings"
| "integrations"
| "sidebar-footer";
interface NavItem {
id: string;
@@ -811,6 +827,7 @@ interface PluginRegistry {
// dropdown, alongside the built-in Workflow/Repository sections. See
// "Task filters" below.
registerTaskFilter(registration: TaskFilterRegistration): void;
registerTaskListFacet(registration: TaskListFacetRegistration): void;
}
interface IntegrationSettingsRegistration {
@@ -1243,6 +1260,19 @@ mirroring how Workflow/Repository combine with the search query today. If
`matches()` throws, the task is treated as non-matching and the error is
logged (mirroring `TaskMenuActionRegistration.visible`'s error handling).
### Task-list facets
`registerTaskListFacet({ id, label, getValues, subscribe? })` adds a choice to `/tasks` Sort and
Group controls. `getValues({ taskId, workspaceId })` synchronously returns `{ value, label,
color? }[]`; `subscribe` invalidates the loaded page. Return each value at most once for a task,
and keep one label and color for a value across all tasks. Facet sorting uses the first value label
after a case-insensitive alphabetical comparison. Facets are page-local: no facet selection is
persisted or sent to the backend. The host catches callback errors and revokes registrations and
active subscriptions when the owning plugin unloads. A task with multiple values appears in each
matching group; a task without a value appears in the host's `Ungrouped` section. Parent/child
indentation is preserved only within a group both tasks match, so a matching child without its
parent is rendered at that group's root.
## Registry internals (host side)
`apps/web/lib/plugins/registry.ts` holds a singleton `PluginRegistry` whose data
+72 -47
View File
@@ -257,35 +257,36 @@ slice shapes in a released plugin.
### Frontend hook/API matrix
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, sidebar-footer (sidebar footer icon row, subject to an inline budget past which the item moves to the footer's overflow menu; and phone menu Utilities group, uncapped), or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; core shortcuts win, and editable targets are skipped unless that entry set ui.keybindings[].allow_in_editor | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
| registerIntegrationSettings | One provider-owned settings component with an optional action mounted in the detail header and the integrations index card | Active ui.bundle | Registration is exclusive by id and revoked on unload; host owns workspace selection and settings navigation; component and action receive the routed `workspaceId`, and action receives its `surface` | registry.registerIntegrationSettings({ id: "acme", Component, action: Toggle }) |
| registerTranslations | Flat English fallback plus optional Kandev locale catalogs, isolated to this plugin's namespace | Active ui.bundle | Catalogs are replaced atomically, removed on unload, and registry consumers invalidate when the host locale changes | registry.registerTranslations({ en: { settings: "Settings" }, "pt-pt": { settings: "Definições" } }) |
| registerRepositoryProvider | Provider-owned paged/searchable repository list, URL match/inspect, branches, and optional native `createChangeRequest` transport | ui.bundle and matching `repository_providers[]` id | Registration and in-flight callbacks are result-fenced on unload; host owns native task and Create PR UI | registry.registerRepositoryProvider({ id: "acme", ...provider }) |
| 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" 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() |
| host.useSettingsSaveContributor | Registers one plugin-owned contributor for the native save bar; the host prefixes ids with `plugin:<pluginId>:` before shared coordination | Active ui.bundle | Contributor lifecycle follows the rendering component; save and discard remain plugin-owned | host.useSettingsSaveContributor({ id: "credentials", revision, isDirty, save, discard }) |
| host.setIntegrationEnabled | Publishes one registration's live enabled value for one workspace; the value is reactive but not durable | Active ui.bundle | The host validates that `integrationId` belongs to this plugin; republish after load using `host.context.getWorkspaceIds()` and `subscribeWorkspaces()` | host.setIntegrationEnabled("acme", workspaceId, enabled) |
| host.i18n | Plugin-scoped `locale`, imperative `t(key, options?)`, and reactive `useTranslation()` | A registered English catalog | Locale changes re-render reactive consumers; missing active-locale messages fall back to the plugin's English catalog | const { t } = host.i18n.useTranslation(); t("pullRequests") |
| host.store (legacy) | Compatibility-only access to Kandev's private Zustand store; intentionally absent from `@kandev/plugin-sdk` | Older ui.bundle | Do not use in new or official plugins; private slices may change without plugin-API compatibility | Migrate reads to host.context |
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; requests are generation-aborted on unload | host.api.fetch("webhooks/inbound", { method: "POST" }) |
| host.api.invokeAction | Authenticated call to a declared action with host-verified workspace/task/session/repository selectors and bounded untrusted body | Matching manifest `actions[]` key/scope | Browser abort cancels the bounded plugin RPC; safe domain statuses and `Retry-After` may be returned | host.api.invokeAction("reviews.get", { taskId }, { signal }) |
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key, options?)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key, options?)/list(scope, scopeId, options?) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | Reads and writes accept an AbortSignal; set/delete also accept writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId, signal }) |
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, sidebar-footer (sidebar footer icon row, subject to an inline budget past which the item moves to the footer's overflow menu; and phone menu Utilities group, uncapped), or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; core shortcuts win, and editable targets are skipped unless that entry set ui.keybindings[].allow_in_editor | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
| registerIntegrationSettings | One provider-owned settings component with an optional action mounted in the detail header and the integrations index card | Active ui.bundle | Registration is exclusive by id and revoked on unload; host owns workspace selection and settings navigation; component and action receive the routed `workspaceId`, and action receives its `surface` | registry.registerIntegrationSettings({ id: "acme", Component, action: Toggle }) |
| registerTranslations | Flat English fallback plus optional Kandev locale catalogs, isolated to this plugin's namespace | Active ui.bundle | Catalogs are replaced atomically, removed on unload, and registry consumers invalidate when the host locale changes | registry.registerTranslations({ en: { settings: "Settings" }, "pt-pt": { settings: "Definições" } }) |
| registerRepositoryProvider | Provider-owned paged/searchable repository list, URL match/inspect, branches, and optional native `createChangeRequest` transport | ui.bundle and matching `repository_providers[]` id | Registration and in-flight callbacks are result-fenced on unload; host owns native task and Create PR UI | registry.registerRepositoryProvider({ id: "acme", ...provider }) |
| 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" 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 }) |
| registerTaskListFacet | { id, label, getValues({ taskId, workspaceId }), subscribe? }; adds page-local Sort and Group choices on `/tasks` | Active ui.bundle | Values apply only to the loaded page, callbacks are isolated, and registrations are revoked on disable/unload | registry.registerTaskListFacet({ id: "tags", label: "Tag", getValues: taskTags }) |
| 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() |
| host.useSettingsSaveContributor | Registers one plugin-owned contributor for the native save bar; the host prefixes ids with `plugin:<pluginId>:` before shared coordination | Active ui.bundle | Contributor lifecycle follows the rendering component; save and discard remain plugin-owned | host.useSettingsSaveContributor({ id: "credentials", revision, isDirty, save, discard }) |
| host.setIntegrationEnabled | Publishes one registration's live enabled value for one workspace; the value is reactive but not durable | Active ui.bundle | The host validates that `integrationId` belongs to this plugin; republish after load using `host.context.getWorkspaceIds()` and `subscribeWorkspaces()` | host.setIntegrationEnabled("acme", workspaceId, enabled) |
| host.i18n | Plugin-scoped `locale`, imperative `t(key, options?)`, and reactive `useTranslation()` | A registered English catalog | Locale changes re-render reactive consumers; missing active-locale messages fall back to the plugin's English catalog | const { t } = host.i18n.useTranslation(); t("pullRequests") |
| host.store (legacy) | Compatibility-only access to Kandev's private Zustand store; intentionally absent from `@kandev/plugin-sdk` | Older ui.bundle | Do not use in new or official plugins; private slices may change without plugin-API compatibility | Migrate reads to host.context |
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; requests are generation-aborted on unload | host.api.fetch("webhooks/inbound", { method: "POST" }) |
| host.api.invokeAction | Authenticated call to a declared action with host-verified workspace/task/session/repository selectors and bounded untrusted body | Matching manifest `actions[]` key/scope | Browser abort cancels the bounded plugin RPC; safe domain statuses and `Retry-After` may be returned | host.api.invokeAction("reviews.get", { taskId }, { signal }) |
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key, options?)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key, options?)/list(scope, scopeId, options?) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | Reads and writes accept an AbortSignal; set/delete also accept writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId, signal }) |
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
### Code-host integration surface map
@@ -446,22 +447,22 @@ display-only until Kandev calls the plugin again to authorize it for submission.
registerComponent currently has these mounted slots. The source type is open
to strings, but an unmounted name renders nowhere.
| Slot | Mounted location | slotProps |
| ------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| task-sidebar | Bottom of task-detail sidebar | none |
| settings-nav | Settings navigation tree | none |
| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps |
| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps |
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
| 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 |
| Slot | Mounted location | slotProps |
| ------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
| task-sidebar | Bottom of task-detail sidebar | none |
| settings-nav | Settings navigation tree | none |
| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps |
| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps |
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
| 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,
activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a
@@ -1188,7 +1189,9 @@ interface PluginHostApi {
getActiveWorkspaceId(): string | undefined;
subscribeActiveWorkspace(listener): () => void;
getWorkspaceIds(): readonly string[];
subscribeWorkspaces(listener: (workspaceIds: readonly string[]) => void): () => void;
subscribeWorkspaces(
listener: (workspaceIds: readonly string[]) => void,
): () => void;
getTaskCreationContext(workspaceId: string): TaskCreationContext | null;
subscribeTaskCreationContext(workspaceId: string, listener): () => void;
resolveRepositoryId(identity: {
@@ -1247,7 +1250,11 @@ interface PluginHostApi {
// Publishes one integration registration's live enabled state for one
// workspace. Persist the source of truth with host.storage and republish it
// after load.
setIntegrationEnabled(integrationId: string, workspaceId: string, enabled: boolean): void;
setIntegrationEnabled(
integrationId: string,
workspaceId: string,
enabled: boolean,
): void;
}
interface SettingsSaveContributor {
@@ -2112,6 +2119,24 @@ An empty selection is implicit "All" for that section: `matches` is only
called once at least one option is selected, and a `matches` that throws is
caught, logged, and treated as non-matching for that task.
Use `registerTaskListFacet` when a plugin owns values that users should sort or group on in the
`/tasks` list. Values are synchronous and apply only to the currently loaded page. A facet is
removed automatically when its plugin unloads; optional subscriptions let it refresh the page.
Return each value at most once for a task. Keep the same label and color for a value across all
tasks. Facet sorting uses the first value label after a case-insensitive alphabetical comparison.
Tasks with multiple values appear in every matching group. A task without a value is shown in the
host's `Ungrouped` section. A child whose parent does not match a group is rendered at that group's
root.
```js
registry.registerTaskListFacet({
id: "tags",
label: "Tag",
getValues: ({ taskId, workspaceId }) => taskTags(taskId, workspaceId),
subscribe: (listener) => tagStore.subscribe(listener),
});
```
## Build, package, install, and test
### Plugin repository