diff --git a/examples/plugins/thread-organizer/README.md b/examples/plugins/thread-organizer/README.md new file mode 100644 index 0000000000..e719aa7a4e --- /dev/null +++ b/examples/plugins/thread-organizer/README.md @@ -0,0 +1,46 @@ +# Thread Organizer example + +Thread Organizer is the reference consumer for plugin-owned thread workflows. +It demonstrates the experimental section-icon, section-action, and runtime +skill-slot APIs introduced by the lower layers of this PR stack. + +This example intentionally declares `engines.bbPluginSdk >=0.4.11`, the first +planned SDK release that can contain all three capabilities. It consumes the +workspace SDK directly and does not vendor or relabel the released 0.4.10 +package. + +## Behavior + +- Running threads appear in their remembered workflow stage. +- Idle unread threads appear in Inbox and stay there until work resumes. +- Read and unread changes do not move a thread after it reaches Inbox. +- A user move or `bb organizer phase ` changes the remembered stage. +- Inbox keeps that system behavior even when its visible title or icon changes. +- Inbox starts expanded. Other configured sections start collapsed until the + user changes their collapse state. +- Reordering a non-Inbox stage in the native sidebar saves the same workflow + order used by plugin settings and future agent instructions. +- Every native section header gets a direct Full Screen Section action whose + pressed state stays visible until the user exits it. + +The plugin does not classify prompts, titles, history, or quoted text. Its +bundled skill tells agents to move their own thread only when the work clearly +matches a user-configured stage rule. + +## Configure + +Open Thread Organizer under Extensions → Plugins. Users can rename and re-icon +Inbox while retaining its protected unread routing, and can add, remove, +reorder, rename, re-icon, and describe every other workflow stage. + +The defaults are Planning, Spec Review, Building, Testing / Deploy, Handoff, +and On Hold. Whenever an agent session starts or resumes, the plugin fills the +bundled skill's predefined workflow slot from the latest saved configuration. + +## Run from this repository + +```bash +pnpm exec turbo run typecheck --filter=bb-plugin-thread-organizer-example +pnpm exec turbo run test --filter=bb-plugin-thread-organizer-example +bb plugin install "path:$PWD/examples/plugins/thread-organizer" --yes +``` diff --git a/examples/plugins/thread-organizer/app.test.ts b/examples/plugins/thread-organizer/app.test.ts new file mode 100644 index 0000000000..63ea79a817 --- /dev/null +++ b/examples/plugins/thread-organizer/app.test.ts @@ -0,0 +1,501 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, within } from "@testing-library/react"; +import { + loadPluginApp, + mountPluginContentScripts, + renderSlot, +} from "@get-bb/plugin-sdk/testing/app"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_WORKFLOW_CONFIG, + INBOX_RULE, + SECTION_ICON_OPTIONS, + cloneWorkflowConfig, + type EditableWorkflowConfig, + type WorkflowConfig, +} from "./core.js"; +import type { rpcContract } from "./server.js"; + +async function loadApp() { + return loadPluginApp(() => import("./app.js")); +} + +function configuredWorkflow(): WorkflowConfig { + const config = cloneWorkflowConfig(DEFAULT_WORKFLOW_CONFIG); + config.stages.forEach((stage) => { + stage.sectionId = `sec_${stage.key}`; + }); + return config; +} + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe("Thread Organizer app registration", () => { + it("registers the settings editor, sidebar controller, and native section action", async () => { + const app = await loadApp(); + + expect(app.settingsSections).toEqual([ + expect.objectContaining({ id: "workflow-sections" }), + ]); + expect(app.contentScripts).toEqual([ + expect.objectContaining({ id: "workflow-sidebar" }), + ]); + expect(app.sidebarSectionActions).toEqual([ + expect.objectContaining({ + id: "fullscreen-section", + placement: "inline-preferred", + experimental_requiresPrimaryModifier: true, + }), + ]); + }); + + it("uses the runtime plugin id when its sidebar controller loads config", async () => { + const app = await loadApp(); + const fetchMock = vi.fn(async (_input: string) => ({ + ok: true, + status: 200, + json: async () => ({ ok: true, result: configuredWorkflow() }), + })); + vi.stubGlobal("fetch", fetchMock); + + const mounted = await mountPluginContentScripts(app, { + pluginId: "thread-organizer-example", + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "/api/v1/plugins/thread-organizer-example/rpc/getConfig", + ); + await mounted.lifecycle.dispose(); + }); + + it("toggles the direct action between Maximize2 and Minimize2", async () => { + const app = await loadApp(); + const action = app.sidebarSectionActions[0]!; + let fullscreenSectionId: string | null = null; + const context = () => ({ + section: { + id: "sec_building", + name: "Building", + experimental_icon: "Code", + depth: 0, + threadCount: 3, + }, + sidebar: { + experimental_fullscreenSectionId: fullscreenSectionId, + experimental_visibleSectionCount: 7, + experimental_setFullscreenSection(sectionId: string | null) { + fullscreenSectionId = sectionId; + }, + }, + isCompactViewport: false, + }); + + expect(action.presentation(context())).toEqual({ + title: "Full Screen Section", + icon: "Maximize2", + pressed: false, + disabled: false, + }); + await action.run(context()); + expect(fullscreenSectionId).toBe("sec_building"); + expect(action.presentation(context())).toEqual({ + title: "Exit Full Screen", + icon: "Minimize2", + pressed: true, + disabled: false, + }); + await action.run(context()); + expect(fullscreenSectionId).toBeNull(); + }); + + it("disables entry when only one section is visible but always allows exit", async () => { + const app = await loadApp(); + const action = app.sidebarSectionActions[0]!; + const base = { + section: { + id: "sec_inbox", + name: "Inbox", + experimental_icon: "Mail", + depth: 0, + threadCount: 0, + }, + isCompactViewport: false, + }; + const sidebar = { + experimental_fullscreenSectionId: null as string | null, + experimental_visibleSectionCount: 1, + experimental_setFullscreenSection: vi.fn(), + }; + + expect(action.presentation({ ...base, sidebar })).toMatchObject({ + disabled: true, + pressed: false, + }); + sidebar.experimental_fullscreenSectionId = "sec_inbox"; + expect(action.presentation({ ...base, sidebar })).toMatchObject({ + disabled: false, + pressed: true, + }); + }); +}); + +describe("workflow settings", () => { + it("preserves newer edits while save and realtime responses are in flight", async () => { + const app = await loadApp(); + const initial = configuredWorkflow(); + let submitted: EditableWorkflowConfig | null = null; + let resolveSave!: (value: WorkflowConfig) => void; + const saveResponse = new Promise((resolve) => { + resolveSave = resolve; + }); + const rendered = renderSlot<{}, typeof rpcContract>( + app.settingsSections[0]!, + {}, + { + rpc: { + getConfig: async () => initial, + saveConfig: async (input) => { + submitted = input; + return saveResponse; + }, + }, + }, + ); + + const title = (await rendered.findByLabelText( + "Planning section title", + )) as HTMLInputElement; + fireEvent.change(title, { target: { value: "Shaping" } }); + fireEvent.click(rendered.getByRole("button", { name: "Save" })); + await vi.waitFor(() => expect(submitted).not.toBeNull()); + + fireEvent.change(title, { target: { value: "Latest shaping" } }); + await rendered.behavior.emitRealtime("workflow-config-changed", { + version: 1, + }); + expect( + rendered.inspection.rpcCalls.filter( + ({ method }) => method === "getConfig", + ), + ).toHaveLength(1); + + const saved = submitted!; + await act(async () => { + resolveSave({ + ...saved, + stages: saved.stages.map((stage) => ({ + ...stage, + sectionId: `sec_${stage.key}`, + })), + }); + await saveResponse; + }); + + await vi.waitFor(() => expect(title.value).toBe("Latest shaping")); + expect(rendered.getByRole("button", { name: "Save" })).toBeTruthy(); + expect(rendered.queryByRole("button", { name: "Saved" })).toBeNull(); + rendered.lifecycle.unmount(); + }); + + it("lets Inbox change title and icon while locking its routing rule", async () => { + const app = await loadApp(); + const initial = configuredWorkflow(); + let savedInput: EditableWorkflowConfig | null = null; + const rendered = renderSlot<{}, typeof rpcContract>( + app.settingsSections[0]!, + {}, + { + rpc: { + getConfig: async () => initial, + saveConfig: async (input) => { + savedInput = input; + return { + ...input, + stages: input.stages.map((stage) => ({ + ...stage, + sectionId: + initial.stages.find( + (candidate) => candidate.key === stage.key, + )?.sectionId ?? null, + })), + }; + }, + }, + }, + ); + + await vi.waitFor(() => + expect(rendered.getByDisplayValue("Inbox")).toBeTruthy(), + ); + const inboxTitle = rendered.getByDisplayValue("Inbox") as HTMLInputElement; + const inboxRule = rendered.getByText(INBOX_RULE); + expect(inboxTitle.disabled).toBe(false); + expect(inboxRule.tagName).toBe("P"); + expect(inboxRule.textContent).toContain("can’t be customized"); + expect(rendered.queryByLabelText("Unread routing is automatic")).toBeNull(); + + fireEvent.change(inboxTitle, { target: { value: "Needs Me" } }); + const iconButton = rendered.getByRole("button", { + name: "Choose icon for Needs Me", + }); + expect( + iconButton.compareDocumentPosition(inboxTitle) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + fireEvent.click(iconButton); + expect( + rendered.getByRole("listbox", { name: "Icons for Needs Me" }), + ).toBeTruthy(); + fireEvent.click(rendered.getByRole("option", { name: "Mail Open" })); + fireEvent.click(rendered.getByRole("button", { name: "Save" })); + + await vi.waitFor(() => expect(savedInput).not.toBeNull()); + await vi.waitFor(() => + expect(rendered.getByRole("button", { name: "Saved" })).toBeTruthy(), + ); + expect(savedInput!.stages[0]).toMatchObject({ + key: "inbox", + title: "Needs Me", + icon: "MailOpen", + rule: INBOX_RULE, + }); + expect(rendered.inspection.rpcCalls.map(({ method }) => method)).toEqual([ + "getConfig", + "saveConfig", + ]); + rendered.lifecycle.unmount(); + }); + + it("keeps the editor compact and omits internal metadata and instruction previews", async () => { + const app = await loadApp(); + const initial = configuredWorkflow(); + const rendered = renderSlot<{}, typeof rpcContract>( + app.settingsSections[0]!, + {}, + { + rpc: { + getConfig: async () => initial, + saveConfig: async (input) => ({ + ...input, + stages: input.stages.map((stage) => ({ + ...stage, + sectionId: `sec_${stage.key}`, + })), + }), + }, + }, + ); + + await vi.waitFor(() => + expect(rendered.getByLabelText("Planning section title")).toBeTruthy(), + ); + expect(rendered.queryByText("Agent instruction preview")).toBeNull(); + expect(rendered.queryByText("spec-review")).toBeNull(); + expect(rendered.queryByText("Section title")).toBeNull(); + expect(rendered.queryByText("Agent may move here")).toBeNull(); + expect(rendered.queryByText("Agent movement")).toBeNull(); + expect(rendered.queryByRole("radio")).toBeNull(); + expect(rendered.queryByText("Fallback for active work")).toBeNull(); + + const planningActionTrigger = rendered.getByLabelText( + "More actions for Planning", + ); + const planningActions = planningActionTrigger.parentElement!; + fireEvent.click(planningActionTrigger); + expect( + within(planningActions).getByRole("menuitem", { name: "Move down" }), + ).toBeTruthy(); + + const addStage = rendered.getByRole("button", { name: "Add stage" }); + const save = rendered.getByRole("button", { name: "Save" }); + const actions = rendered.getByRole("group", { name: "Workflow actions" }); + const description = rendered.getByText( + "Rename, re-icon, reorder, and define the workflow your agents follow.", + ); + expect(description.className).toContain("ps-[var(--radius-lg,0.5rem)]"); + expect(description.className).toContain("[text-indent:-0.088em]"); + expect(description.className).not.toContain("pl-px"); + const planningTitle = rendered.getByLabelText("Planning section title"); + expect(description.parentElement?.parentElement).toBe( + actions.parentElement, + ); + expect(actions.parentElement?.className).toContain("items-end"); + expect(actions.className).toContain("justify-end"); + expect(within(actions).getAllByRole("button")).toEqual([addStage, save]); + expect(addStage.className).toContain("border-input"); + expect(addStage.className).toContain("bg-transparent"); + expect(addStage.querySelector("svg")).not.toBeNull(); + expect(save.className).toContain("bg-foreground"); + expect(save.className).toContain("text-background"); + expect(save.querySelector("svg")).not.toBeNull(); + expect( + addStage.compareDocumentPosition(planningTitle) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + expect( + save.compareDocumentPosition(planningTitle) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + + fireEvent.click( + rendered.getByRole("button", { name: "Choose icon for Planning" }), + ); + const iconList = rendered.getByRole("listbox", { + name: "Icons for Planning", + }); + expect(within(iconList).getAllByRole("option")).toHaveLength( + SECTION_ICON_OPTIONS.length, + ); + expect( + within(iconList) + .getByRole("option", { name: "List Todo" }) + .getAttribute("aria-selected"), + ).toBe("true"); + expect(SECTION_ICON_OPTIONS).toHaveLength(142); + fireEvent.change( + rendered.getByRole("searchbox", { name: "Search icons" }), + { + target: { value: "calendar" }, + }, + ); + expect(within(iconList).getAllByRole("option")).toHaveLength(2); + rendered.lifecycle.unmount(); + }); + + it("dismisses stage actions after Escape, outside press, and selection", async () => { + const app = await loadApp(); + const rendered = renderSlot<{}, typeof rpcContract>( + app.settingsSections[0]!, + {}, + { + rpc: { + getConfig: async () => configuredWorkflow(), + saveConfig: async (input) => ({ + ...input, + stages: input.stages.map((stage) => ({ + ...stage, + sectionId: `sec_${stage.key}`, + })), + }), + }, + }, + ); + + const planningActions = await rendered.findByLabelText( + "More actions for Planning", + ); + fireEvent.click(planningActions); + expect( + rendered.getByRole("menu", { name: "Actions for Planning" }), + ).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + expect( + rendered.queryByRole("menu", { name: "Actions for Planning" }), + ).toBeNull(); + + fireEvent.click(planningActions); + fireEvent.pointerDown(document.body); + expect( + rendered.queryByRole("menu", { name: "Actions for Planning" }), + ).toBeNull(); + + fireEvent.click(planningActions); + fireEvent.click(rendered.getByRole("menuitem", { name: "Move down" })); + expect( + rendered.queryByRole("menu", { name: "Actions for Planning" }), + ).toBeNull(); + const specReviewTitle = rendered.getByLabelText( + "Spec Review section title", + ); + const planningTitle = rendered.getByLabelText("Planning section title"); + expect( + specReviewTitle.compareDocumentPosition(planningTitle) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + + rendered.lifecycle.unmount(); + }); + + it("keeps a shared desktop spine and aligns narrow descriptions under stage titles", async () => { + const app = await loadApp(); + const rendered = renderSlot<{}, typeof rpcContract>( + app.settingsSections[0]!, + {}, + { + rpc: { + getConfig: async () => configuredWorkflow(), + saveConfig: async (input) => ({ + ...input, + stages: input.stages.map((stage) => ({ + ...stage, + sectionId: `sec_${stage.key}`, + })), + }), + }, + }, + ); + + const planningTitle = await rendered.findByLabelText( + "Planning section title", + ); + const planningIcon = rendered.getByRole("button", { + name: "Choose icon for Planning", + }); + const planningRule = rendered.getByLabelText("What belongs in Planning"); + const planningGrid = planningTitle.parentElement!; + const planningCard = planningGrid.parentElement!; + const stageList = planningCard.parentElement!; + const planningRuleLayout = planningRule.closest("label")!; + expect(planningIcon.parentElement?.parentElement).toBe(planningGrid); + expect(planningRuleLayout.parentElement).toBe(planningGrid); + expect(planningGrid.children).toHaveLength(5); + expect(planningGrid.className).toContain( + "grid-cols-[2rem_minmax(0,1fr)_2rem]", + ); + expect(planningGrid.className).toContain( + "lg:grid-cols-[2rem_2rem_minmax(7rem,9rem)_minmax(0,1fr)_2rem]", + ); + expect(planningGrid.className).toContain("gap-y-0"); + expect(planningCard.className).toContain("px-3"); + expect(planningCard.className).toContain("py-2.5"); + expect(planningCard.className).toContain("lg:p-3"); + expect(planningCard.className).toContain("first:rounded-t-lg"); + expect(planningCard.className).toContain("last:rounded-b-lg"); + expect(stageList.className).toContain("overflow-visible"); + expect(planningRuleLayout.className).toContain("col-span-2"); + expect(planningRuleLayout.className).toContain("col-start-2"); + expect(planningRuleLayout.className).toContain("row-start-2"); + expect(planningRuleLayout.className).toContain("lg:col-start-4"); + expect(planningRuleLayout.className).toContain("lg:row-start-1"); + expect(planningRule.className).toContain("border-transparent"); + expect(planningRule.className).toContain("resize-none"); + expect(planningRule.className).not.toContain("resize-y"); + expect(planningTitle.className).toContain("border-transparent"); + + const inboxTitle = rendered.getByLabelText("Inbox section title"); + const inboxIcon = rendered.getByRole("button", { + name: "Choose icon for Inbox", + }); + const inboxRule = rendered.getByText(INBOX_RULE); + const inboxGrid = inboxTitle.parentElement!; + expect(inboxIcon.parentElement?.parentElement).toBe(inboxGrid); + expect(inboxRule.parentElement).toBe(inboxGrid); + expect(inboxGrid.children).toHaveLength(5); + expect(inboxGrid.className).toBe(planningGrid.className); + expect(inboxRule.className).toContain("col-span-2"); + expect(inboxRule.className).toContain("col-start-2"); + expect(inboxRule.className).toContain("row-start-2"); + expect(inboxRule.className).toContain("lg:col-start-4"); + expect(inboxRule.className).toContain("lg:row-start-1"); + expect(inboxRule.className).toContain("px-2.5"); + expect(inboxRule.className).toContain("text-sm"); + + rendered.lifecycle.unmount(); + }); +}); diff --git a/examples/plugins/thread-organizer/app.tsx b/examples/plugins/thread-organizer/app.tsx new file mode 100644 index 0000000000..cf26d074ed --- /dev/null +++ b/examples/plugins/thread-organizer/app.tsx @@ -0,0 +1,648 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type DragEvent, +} from "react"; +import { + ArrowDown02Icon, + ArrowUp02Icon, + Delete02Icon, + DragDropVerticalIcon, + MoreHorizontalIcon, + PlusSignIcon, + Tick02Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { definePluginApp, useRealtime, useRpc } from "@get-bb/plugin-sdk/app"; + +import { + SECTION_ICON_OPTIONS, + WORKFLOW_CONFIG_VERSION, + createStageKey, + editableWorkflowConfig, + normalizeEditableWorkflowConfig, + type EditableWorkflowConfig, + type EditableWorkflowStage, +} from "./core.js"; +import { SectionIcon, sectionIconLabel } from "./section-icon.js"; +import type { rpcContract } from "./server.js"; +import { + cacheWorkflowConfig, + mountThreadOrganizerSidebar, +} from "./sidebar-controller.js"; + +const fieldClass = + "min-w-0 w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none focus:border-foreground/45 disabled:cursor-not-allowed disabled:opacity-60"; +const quietFieldClass = + "min-w-0 w-full rounded-md border border-transparent bg-transparent px-2.5 py-1.5 text-sm text-foreground outline-none hover:border-border focus:border-foreground/45 focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"; +const buttonBaseClass = + "inline-flex h-8 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0"; +const outlineButtonClass = `${buttonBaseClass} border border-input bg-transparent text-foreground hover:bg-muted`; +const primaryButtonClass = `${buttonBaseClass} bg-foreground text-background hover:bg-foreground/90`; +const iconButtonClass = + "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"; +const stageRowClass = + "grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_2rem] items-start gap-x-2 gap-y-0 lg:grid-cols-[2rem_2rem_minmax(7rem,9rem)_minmax(0,1fr)_2rem]"; +const stageRuleLayoutClass = + "col-span-2 col-start-2 row-start-2 min-w-0 lg:col-span-1 lg:col-start-4 lg:row-start-1"; +const workflowSettingsDescription = + "Rename, re-icon, reorder, and define the workflow your agents follow."; +// Align the visible R to the rounded-lg panel's top-left tangent. Inter's +// capital R starts 180/2048 em inside its advance box, so hang that sidebearing +// back out of the shared radius inset rather than introducing a pixel nudge. +// The fallback matches the host's rounded-lg token in isolated plugin roots. +const workflowSettingsDescriptionClass = + "ps-[var(--radius-lg,0.5rem)] [text-indent:-0.088em] text-sm leading-5 text-muted-foreground"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function uniqueNewStageTitle(stages: readonly EditableWorkflowStage[]): string { + const titles = new Set( + stages.map((stage) => stage.title.toLocaleLowerCase()), + ); + if (!titles.has("new stage")) return "New Stage"; + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const title = `New Stage ${suffix}`; + if (!titles.has(title.toLocaleLowerCase())) return title; + } + return "Untitled Stage"; +} + +function IconPicker({ + label, + onChange, + value, +}: { + label: string; + onChange(value: EditableWorkflowStage["icon"]): void; + value: EditableWorkflowStage["icon"]; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const rootRef = useRef(null); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filteredIcons = SECTION_ICON_OPTIONS.filter((icon) => + sectionIconLabel(icon).toLocaleLowerCase().includes(normalizedQuery), + ); + + useEffect(() => { + if (!open) return; + const closeOnOutsidePress = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("pointerdown", closeOnOutsidePress); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePress); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ setQuery(event.target.value)} + placeholder="Search icons" + type="search" + value={query} + /> +
+ {filteredIcons.map((icon) => { + const iconLabel = sectionIconLabel(icon); + return ( + + ); + })} + {filteredIcons.length === 0 ? ( +

+ No matching icons +

+ ) : null} +
+
+ ) : null} +
+ ); +} + +function StageActions({ + index, + onMove, + onRemove, + stage, + stageCount, +}: Pick< + StageCardProps, + "index" | "onMove" | "onRemove" | "stage" | "stageCount" +>) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) return; + const closeOnOutsidePress = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener("pointerdown", closeOnOutsidePress); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePress); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [open]); + + return ( +
+ + {open ? ( +
+ + + +
+ ) : null} +
+ ); +} + +interface StageCardProps { + index: number; + onChange(stage: EditableWorkflowStage): void; + onDragStart(index: number): void; + onDrop(index: number): void; + onMove(index: number, direction: -1 | 1): void; + onRemove(index: number): void; + stage: EditableWorkflowStage; + stageCount: number; +} + +function StageCard({ + index, + onChange, + onDragStart, + onDrop, + onMove, + onRemove, + stage, + stageCount, +}: StageCardProps) { + const inbox = stage.role === "inbox"; + const update = ( + key: Key, + value: EditableWorkflowStage[Key], + ) => onChange({ ...stage, [key]: value }); + + return ( +
{ + if (!inbox) event.preventDefault(); + }} + onDrop={(event: DragEvent) => { + event.preventDefault(); + if (!inbox) onDrop(index); + }} + > +
+ {inbox ? ( +