Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/app/.ladle/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions plugins/workflows/panel.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div
ref={mountRef}
className="h-72 w-full max-w-sm overflow-hidden border border-border-seam"
/>
);
}

export function PanelStates() {
return (
<main className="mx-auto w-full max-w-5xl p-6">
<h1 className="text-sm font-semibold text-foreground">
Flush workflow panel states
</h1>
<p className="mt-1 text-xs text-muted-foreground">
Early and loaded content should start 16px from both panel edges.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
{STATES.map(([label, message]) => (
<section key={label}>
<h2 className="mb-1 text-xs text-muted-foreground">{label}</h2>
<div className="w-full max-w-sm overflow-hidden border border-border-seam bg-border">
<WorkflowRunPanelState>
{message === null ? (
<LoadingPreview />
) : (
<EmptyOrError>{message}</EmptyOrError>
)}
</WorkflowRunPanelState>
</div>
</section>
))}
<section className="sm:col-span-2">
<h2 className="mb-1 text-xs text-muted-foreground">Loaded</h2>
<LoadedPanel />
</section>
</div>
</main>
);
}
22 changes: 22 additions & 0 deletions plugins/workflows/src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,28 @@ describe("workflow thread panel", () => {
});
});

it.each([
["loading", () => new Promise<never>(() => 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]!,
Expand Down
42 changes: 27 additions & 15 deletions plugins/workflows/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,30 +509,28 @@ function useActiveWorkflowRuns(threadId: string): {
return { state, setRuns };
}

function EmptyOrError({ children }: { children: ReactNode }) {
export function EmptyOrError({ children }: { children: ReactNode }) {
return (
<div
role="alert"
className="my-2 rounded-md border border-border bg-muted px-3 py-2 text-sm text-muted-foreground"
>
<div role="alert" className="text-sm text-muted-foreground">
{children}
</div>
);
}

function LoadingPreview() {
export function LoadingPreview() {
return (
<div
className="my-2 space-y-2 rounded-lg border border-border p-3"
aria-busy="true"
>
<div className="space-y-2" aria-busy="true">
<Skeleton className="h-3.5 w-44 rounded-sm" />
<Skeleton className="h-3 w-2/3 rounded-sm" />
<Skeleton className="h-3 w-1/2 rounded-sm" />
</div>
);
}

export function WorkflowRunPanelState({ children }: { children: ReactNode }) {
return <div className="p-4">{children}</div>;
}

function RefreshWarning({ message }: { message: string }) {
return (
<div
Expand Down Expand Up @@ -863,11 +861,11 @@ function WorkflowRunPanel({ threadId, params }: PluginThreadPanelProps) {
return (
<div className="h-full min-h-0 flex-1 bg-border">
{runId === undefined ? (
<div className="p-4">
<WorkflowRunPanelState>
<EmptyOrError>
This workflow panel has invalid run parameters.
</EmptyOrError>
</div>
</WorkflowRunPanelState>
) : (
<WorkflowRunPanelLoaded threadId={threadId} runId={runId} />
)}
Expand All @@ -892,13 +890,27 @@ function WorkflowRunPanelLoaded({
() => (run === null ? null : buildSharedWorkflowView(run)),
[run],
);
if (state.status === "loading") return <LoadingPreview />;
if (state.status === "loading") {
return (
<WorkflowRunPanelState>
<LoadingPreview />
</WorkflowRunPanelState>
);
}
if (state.status === "error") {
return <EmptyOrError>{state.message}</EmptyOrError>;
return (
<WorkflowRunPanelState>
<EmptyOrError>{state.message}</EmptyOrError>
</WorkflowRunPanelState>
);
}
if (run === null || shared === null) {
return (
<EmptyOrError>No workflow runs were found for this thread.</EmptyOrError>
<WorkflowRunPanelState>
<EmptyOrError>
No workflow runs were found for this thread.
</EmptyOrError>
</WorkflowRunPanelState>
);
}
const pillState = runPillState(run.status);
Expand Down
Loading