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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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">,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"groupByRepository": "Repository",
|
||||
"groupByState": "State",
|
||||
"groupByWorkflow": "Workflow",
|
||||
"ungrouped": "Ungrouped",
|
||||
"loading": "Loading...",
|
||||
"loadingTasks": "Loading tasks...",
|
||||
"noRepository": "No repository",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"groupByRepository": "Ŕēƥōśĩţōŕŷ",
|
||||
"groupByState": "Śţàţē",
|
||||
"groupByWorkflow": "Ŵōŕķƒĺōŵ",
|
||||
"ungrouped": "Ŭńĝŕōũƥēđ",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loadingTasks": "Ĺōàďĩńĝ ţàśķś...",
|
||||
"noRepository": "Ńō ŕēƥōśĩţōŕŷ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"groupByRepository": "仓库",
|
||||
"groupByState": "状态",
|
||||
"groupByWorkflow": "工作流",
|
||||
"ungrouped": "未分组",
|
||||
"loading": "加载中…",
|
||||
"loadingTasks": "正在加载任务…",
|
||||
"noRepository": "无仓库",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"groupByRepository": "儲存庫",
|
||||
"groupByState": "狀態",
|
||||
"groupByWorkflow": "工作流程",
|
||||
"ungrouped": "未分組",
|
||||
"loading": "載入中…",
|
||||
"loadingTasks": "正在載入任務…",
|
||||
"noRepository": "無儲存庫",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"groupByRepository": "儲存庫",
|
||||
"groupByState": "狀態",
|
||||
"groupByWorkflow": "工作流程",
|
||||
"ungrouped": "未分組",
|
||||
"loading": "載入中…",
|
||||
"loadingTasks": "正在載入任務…",
|
||||
"noRepository": "無儲存庫",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user