From 0700911e0584625fa627a29fea281dd65c259607 Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:59:14 +0000 Subject: [PATCH] feat(plugins): plugin task list facets for sort and group (#2932) Co-authored-by: ayattara Co-authored-by: Carlos Florencio Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fixture-package/ui/bundle.js | 31 ++- apps/packages/plugin-sdk/src/index.ts | 15 ++ apps/web/app/tasks/mobile-tasks-actions.tsx | 32 +++ .../app/tasks/mobile-tasks-create-dialog.tsx | 42 ++++ apps/web/app/tasks/tasks-list-controls.tsx | 38 ++-- apps/web/app/tasks/tasks-list-view.test.tsx | 110 ++++++++++ apps/web/app/tasks/tasks-list-view.tsx | 73 +++++-- apps/web/app/tasks/tasks-page-client.tsx | 199 +++++++++--------- apps/web/app/tasks/tasks-page-content.tsx | 77 +++++++ .../kanban/mobile-menu-task-list-options.tsx | 41 ++-- .../plugins/mobile-task-list-facet.spec.ts | 46 ++++ .../e2e/tests/plugins/task-list-facet.spec.ts | 50 +++++ .../use-task-list-facet-selection.test.tsx | 33 +++ .../hooks/use-task-list-facet-selection.ts | 66 ++++++ apps/web/hooks/use-task-list-facets.test.tsx | 144 +++++++++++++ apps/web/hooks/use-task-list-facets.ts | 82 ++++++++ .../plugins/registry-registration-types.ts | 12 ++ apps/web/lib/plugins/registry.test.ts | 27 +++ apps/web/lib/plugins/registry.ts | 29 ++- apps/web/lib/plugins/types.ts | 4 + apps/web/lib/tasks/tasks-list-options.test.ts | 20 ++ apps/web/lib/tasks/tasks-list-options.ts | 38 ++++ apps/web/src/locales/en/tasks.json | 1 + apps/web/src/locales/pseudo/tasks.json | 1 + apps/web/src/locales/pt-pt/tasks.json | 1 + apps/web/src/locales/zh-cn/tasks.json | 1 + apps/web/src/locales/zh-hk/tasks.json | 1 + apps/web/src/locales/zh-tw/tasks.json | 1 + docs/plans/plugins/PLUGIN-API.md | 40 +++- docs/public/plugins-authoring.md | 119 ++++++----- 30 files changed, 1170 insertions(+), 204 deletions(-) create mode 100644 apps/web/app/tasks/mobile-tasks-actions.tsx create mode 100644 apps/web/app/tasks/mobile-tasks-create-dialog.tsx create mode 100644 apps/web/app/tasks/tasks-page-content.tsx create mode 100644 apps/web/e2e/tests/plugins/mobile-task-list-facet.spec.ts create mode 100644 apps/web/e2e/tests/plugins/task-list-facet.spec.ts create mode 100644 apps/web/hooks/use-task-list-facet-selection.test.tsx create mode 100644 apps/web/hooks/use-task-list-facet-selection.ts create mode 100644 apps/web/hooks/use-task-list-facets.test.tsx create mode 100644 apps/web/hooks/use-task-list-facets.ts diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index ea4551f3aa..3bc29182fe 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -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); diff --git a/apps/packages/plugin-sdk/src/index.ts b/apps/packages/plugin-sdk/src/index.ts index ce15726504..24ee082758 100644 --- a/apps/packages/plugin-sdk/src/index.ts +++ b/apps/packages/plugin-sdk/src/index.ts @@ -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; diff --git a/apps/web/app/tasks/mobile-tasks-actions.tsx b/apps/web/app/tasks/mobile-tasks-actions.tsx new file mode 100644 index 0000000000..ca86b1ea6f --- /dev/null +++ b/apps/web/app/tasks/mobile-tasks-actions.tsx @@ -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; +}) { + return ( + <> + onOpenChange(true)} /> + + + ); +} diff --git a/apps/web/app/tasks/mobile-tasks-create-dialog.tsx b/apps/web/app/tasks/mobile-tasks-create-dialog.tsx new file mode 100644 index 0000000000..122e277920 --- /dev/null +++ b/apps/web/app/tasks/mobile-tasks-create-dialog.tsx @@ -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 }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + }; +}; + +export function MobileTasksCreateDialog({ + open, + onOpenChange, + workspaceId, + workflowId, + steps, + onCreated, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + workflowId: string | null; + steps: MobileTaskStep[]; + onCreated: () => Promise; +}) { + return ( + { + onOpenChange(false); + void onCreated(); + }} + /> + ); +} diff --git a/apps/web/app/tasks/tasks-list-controls.tsx b/apps/web/app/tasks/tasks-list-controls.tsx index 46b0c827c1..c8124e47d5 100644 --- a/apps/web/app/tasks/tasks-list-controls.tsx +++ b/apps/web/app/tasks/tasks-list-controls.tsx @@ -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 (
onTasksListSortChange(value as TasksListSort)} + onChange={onTasksListSortChange} testId="tasks-list-sort" /> onTasksListGroupChange(value as TasksListGroup)} + onChange={onTasksListGroupChange} testId="tasks-list-group" />