diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index 730d9b8564..8bb8be3792 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -42,6 +42,7 @@ export default { stories: [ "src/**/*.stories.tsx", "../../plugins/provider-retry/**/*.stories.tsx", + "../../plugins/workflows/**/*.stories.tsx", ], defaultStory: "", viteConfig: "./.ladle/vite.config.ts", diff --git a/plugins/workflows/panel.stories.tsx b/plugins/workflows/panel.stories.tsx new file mode 100644 index 0000000000..43453cc495 --- /dev/null +++ b/plugins/workflows/panel.stories.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef } from "react"; +import { + installTestPluginRuntime, + loadPluginApp, + renderSlot, +} from "@get-bb/plugin-sdk/testing/app"; +import type { WorkflowRunView } from "./src/ui-contract.js"; + +installTestPluginRuntime(); +const workflowAppModule = await import("./src/app.js"); +const { EmptyOrError, LoadingPreview, WorkflowRunPanelState } = + workflowAppModule; +const workflowApp = await loadPluginApp(async () => workflowAppModule); +const workflowPanel = workflowApp.threadPanelActions.find( + (registration) => registration.id === "workflow-run", +)!; + +export default { title: "plugins/Workflows/Workflow panel" }; + +const STATES = [ + ["Loading", null], + ["Initial RPC error", "Could not load this workflow run."], + ["No run", "No workflow runs were found for this thread."], + ["Invalid parameters", "This workflow panel has invalid run parameters."], +] as const; + +const LOADED_RUN: WorkflowRunView = { + id: "wfr_11111111-1111-4111-8111-111111111111", + name: "Review the release", + description: "Run independent checks before shipping.", + status: "succeeded", + currentPhase: null, + phases: [ + { + title: "Review", + detail: "Challenge the combined result.", + calls: [ + { + id: "wfc_1", + index: 0, + label: "Adversarial review", + phase: "Review", + status: "succeeded", + provider: "codex", + model: "gpt-5.6", + reasoningLevel: "high", + cached: false, + childThreadId: "thr_worker_1", + providerRetryAttempts: 0, + repairAttempts: 0, + error: null, + createdAt: 1_700_000_000_000, + startedAt: 1_700_000_001_000, + finishedAt: 1_700_000_011_000, + }, + ], + }, + ], + unphasedCalls: [], + resultAvailable: true, + error: null, + createdAt: 1_700_000_000_000, + startedAt: 1_700_000_001_000, + finishedAt: 1_700_000_012_000, +}; + +function LoadedPanel() { + const mountRef = useRef(null); + useEffect(() => { + let cancelled = false; + let cleanup = () => undefined; + queueMicrotask(() => { + if (cancelled || mountRef.current === null) return; + const slot = renderSlot( + workflowPanel, + { threadId: "thr_origin", params: { runId: LOADED_RUN.id } }, + { rpc: { workflowRunView: () => ({ run: LOADED_RUN }) } }, + ); + slot.container.classList.add("h-full"); + mountRef.current.append(slot.container); + cleanup = () => { + slot.unmount(); + slot.container.remove(); + }; + }); + return () => { + cancelled = true; + cleanup(); + }; + }, []); + return ( +
+ ); +} + +export function PanelStates() { + return ( +
+

+ Flush workflow panel states +

+

+ Early and loaded content should start 16px from both panel edges. +

+
+ {STATES.map(([label, message]) => ( +
+

{label}

+
+ + {message === null ? ( + + ) : ( + {message} + )} + +
+
+ ))} +
+

Loaded

+ +
+
+
+ ); +} diff --git a/plugins/workflows/src/app.test.tsx b/plugins/workflows/src/app.test.tsx index 5b1e99c383..972b8befe1 100644 --- a/plugins/workflows/src/app.test.tsx +++ b/plugins/workflows/src/app.test.tsx @@ -710,6 +710,28 @@ describe("workflow thread panel", () => { }); }); + it.each([ + ["loading", () => new Promise(() => undefined)], + ["an initial RPC error", () => Promise.reject(new Error("Unavailable"))], + ["no matching run", () => ({ run: null })], + ])("keeps local spacing while showing %s", async (_name, workflowRunView) => { + const slot = renderSlot( + app.threadPanelActions[0]!, + { threadId: "thr_origin", params: { runId: run.id } }, + { rpc: { workflowRunView } }, + ); + + await waitFor(() => { + const state = + slot.container.querySelector('[aria-busy="true"]') ?? + slot.container.querySelector('[role="alert"]'); + expect(state?.parentElement?.className).toContain("p-4"); + expect(state?.className).not.toMatch( + /\b(?:bg-muted|border|p-3|px-3|rounded-lg|rounded-md)\b/, + ); + }); + }); + it("rejects restored panel params with unknown fields", async () => { const slot = renderSlot( app.threadPanelActions[0]!, diff --git a/plugins/workflows/src/app.tsx b/plugins/workflows/src/app.tsx index 3bb21a3e7b..ff69e46eb1 100644 --- a/plugins/workflows/src/app.tsx +++ b/plugins/workflows/src/app.tsx @@ -509,23 +509,17 @@ function useActiveWorkflowRuns(threadId: string): { return { state, setRuns }; } -function EmptyOrError({ children }: { children: ReactNode }) { +export function EmptyOrError({ children }: { children: ReactNode }) { return ( -
+
{children}
); } -function LoadingPreview() { +export function LoadingPreview() { return ( -
+
@@ -533,6 +527,10 @@ function LoadingPreview() { ); } +export function WorkflowRunPanelState({ children }: { children: ReactNode }) { + return
{children}
; +} + function RefreshWarning({ message }: { message: string }) { return (
{runId === undefined ? ( -
+ This workflow panel has invalid run parameters. -
+ ) : ( )} @@ -892,13 +890,27 @@ function WorkflowRunPanelLoaded({ () => (run === null ? null : buildSharedWorkflowView(run)), [run], ); - if (state.status === "loading") return ; + if (state.status === "loading") { + return ( + + + + ); + } if (state.status === "error") { - return {state.message}; + return ( + + {state.message} + + ); } if (run === null || shared === null) { return ( - No workflow runs were found for this thread. + + + No workflow runs were found for this thread. + + ); } const pillState = runPillState(run.status);