@stigmer/react 3.1.9 → 3.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/index.d.ts +2 -2
  2. package/index.d.ts.map +1 -1
  3. package/index.js +3 -1
  4. package/index.js.map +1 -1
  5. package/package.json +4 -4
  6. package/provider.d.ts +33 -1
  7. package/provider.d.ts.map +1 -1
  8. package/provider.js +4 -2
  9. package/provider.js.map +1 -1
  10. package/src/index.ts +10 -0
  11. package/src/provider.tsx +46 -8
  12. package/src/workflow/PendingApprovalsWidget.tsx +10 -3
  13. package/src/workflow/ReviewRendererContext.ts +90 -0
  14. package/src/workflow/WorkflowExecutionTimelineEvent.tsx +9 -2
  15. package/src/workflow/WorkflowTaskApprovalCard.tsx +32 -0
  16. package/src/workflow/WorkflowTaskReviewGate.tsx +232 -0
  17. package/src/workflow/__tests__/PendingApprovalsWidget.test.tsx +80 -0
  18. package/src/workflow/__tests__/WorkflowTaskApprovalCard.test.tsx +27 -0
  19. package/src/workflow/__tests__/WorkflowTaskReviewGate.test.tsx +283 -0
  20. package/src/workflow/__tests__/derive-task-detail.test.ts +78 -0
  21. package/src/workflow/execution-inspector/ExecutionInspector.tsx +8 -3
  22. package/src/workflow/execution-inspector/derive-task-detail.ts +34 -2
  23. package/src/workflow/index.ts +16 -0
  24. package/src/workflow/useReviewPayload.ts +115 -0
  25. package/workflow/PendingApprovalsWidget.d.ts.map +1 -1
  26. package/workflow/PendingApprovalsWidget.js +1 -1
  27. package/workflow/PendingApprovalsWidget.js.map +1 -1
  28. package/workflow/ReviewRendererContext.d.ts +71 -0
  29. package/workflow/ReviewRendererContext.d.ts.map +1 -0
  30. package/workflow/ReviewRendererContext.js +23 -0
  31. package/workflow/ReviewRendererContext.js.map +1 -0
  32. package/workflow/WorkflowExecutionTimelineEvent.d.ts.map +1 -1
  33. package/workflow/WorkflowExecutionTimelineEvent.js +7 -3
  34. package/workflow/WorkflowExecutionTimelineEvent.js.map +1 -1
  35. package/workflow/WorkflowTaskApprovalCard.d.ts +9 -0
  36. package/workflow/WorkflowTaskApprovalCard.d.ts.map +1 -1
  37. package/workflow/WorkflowTaskApprovalCard.js +8 -2
  38. package/workflow/WorkflowTaskApprovalCard.js.map +1 -1
  39. package/workflow/WorkflowTaskReviewGate.d.ts +54 -0
  40. package/workflow/WorkflowTaskReviewGate.d.ts.map +1 -0
  41. package/workflow/WorkflowTaskReviewGate.js +56 -0
  42. package/workflow/WorkflowTaskReviewGate.js.map +1 -0
  43. package/workflow/execution-inspector/ExecutionInspector.d.ts.map +1 -1
  44. package/workflow/execution-inspector/ExecutionInspector.js +5 -3
  45. package/workflow/execution-inspector/ExecutionInspector.js.map +1 -1
  46. package/workflow/execution-inspector/derive-task-detail.d.ts +20 -1
  47. package/workflow/execution-inspector/derive-task-detail.d.ts.map +1 -1
  48. package/workflow/execution-inspector/derive-task-detail.js +14 -0
  49. package/workflow/execution-inspector/derive-task-detail.js.map +1 -1
  50. package/workflow/index.d.ts +3 -0
  51. package/workflow/index.d.ts.map +1 -1
  52. package/workflow/index.js +4 -0
  53. package/workflow/index.js.map +1 -1
  54. package/workflow/useReviewPayload.d.ts +41 -0
  55. package/workflow/useReviewPayload.d.ts.map +1 -0
  56. package/workflow/useReviewPayload.js +79 -0
  57. package/workflow/useReviewPayload.js.map +1 -0
@@ -5,8 +5,10 @@ import type { WorkflowExecutionEvent } from "@stigmer/protos/ai/stigmer/agentic/
5
5
  import { WorkflowEventType } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
6
6
  import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
7
7
  import { cn } from "@stigmer/theme";
8
+ import { toJson, type JsonValue } from "@bufbuild/protobuf";
9
+ import { ValueSchema } from "@bufbuild/protobuf/wkt";
8
10
  import type { DerivedTaskState } from "../internal/store/workflow-execution-event-store.js";
9
- import { WorkflowTaskApprovalCard } from "./WorkflowTaskApprovalCard.js";
11
+ import { WorkflowTaskReviewGate } from "./WorkflowTaskReviewGate.js";
10
12
  import { formatDuration, formatMicroUsd, formatBytes, formatTimestamp, formatMetaChips } from "./format-utils.js";
11
13
 
12
14
  interface TimelineEventProps {
@@ -270,7 +272,7 @@ export const WorkflowExecutionTimelineEvent = memo(function WorkflowExecutionTim
270
272
  </p>
271
273
  )}
272
274
  {showCard && (
273
- <WorkflowTaskApprovalCard
275
+ <WorkflowTaskReviewGate
274
276
  taskName={event.taskName}
275
277
  prompt={p.value.prompt}
276
278
  outcomes={(p.value.outcomes ?? []).map((o) => ({
@@ -280,6 +282,11 @@ export const WorkflowExecutionTimelineEvent = memo(function WorkflowExecutionTim
280
282
  formSchema={p.value.formSchema
281
283
  ? (p.value.formSchema as unknown as Record<string, unknown>)
282
284
  : undefined}
285
+ payload={p.value.payload
286
+ ? (toJson(ValueSchema, p.value.payload) as JsonValue)
287
+ : null}
288
+ uiHint={p.value.uiHint}
289
+ payloadArtifactId={p.value.payloadArtifactId || null}
283
290
  onSubmit={onSubmitTaskApproval}
284
291
  isSubmitting={taskApprovalSubmittingTaskNames?.has(event.taskName) ?? false}
285
292
  error={taskApprovalErrorsByTaskName?.get(event.taskName) ?? null}
@@ -2,10 +2,13 @@
2
2
 
3
3
  import { memo, useCallback, useEffect, useMemo, useState } from "react";
4
4
  import Markdown from "react-markdown";
5
+ import type { JsonObject, JsonValue } from "@bufbuild/protobuf";
5
6
  import { cn } from "@stigmer/theme";
6
7
  import { MARKDOWN_COMPONENTS, REMARK_PLUGINS } from "../internal/markdown-components.js";
7
8
  import { DecisionButton, type DecisionVariant } from "../internal/DecisionButton.js";
8
9
  import { InCardDecisionError } from "../internal/InCardDecisionError.js";
10
+ import { formatJson } from "../execution/tool-rendering-primitives.js";
11
+ import { StructuredDataViewer } from "./execution-inspector/StructuredDataViewer.js";
9
12
 
10
13
  /** Outcome descriptor for UI rendering. */
11
14
  export interface TaskOutcome {
@@ -35,6 +38,14 @@ export interface WorkflowTaskApprovalCardProps {
35
38
  * Text properties render as textareas; others as text inputs.
36
39
  */
37
40
  readonly formSchema?: Record<string, unknown>;
41
+ /**
42
+ * Resolved review payload — the material under review (issue #234).
43
+ * Rendered as structured data between the prompt and the form. This is
44
+ * the portable fallback presentation; gates whose `ui_hint` matches a
45
+ * registered review renderer never reach this card (see
46
+ * {@link WorkflowTaskReviewGate}).
47
+ */
48
+ readonly payload?: JsonValue | null;
38
49
  /**
39
50
  * Called when the reviewer submits a decision.
40
51
  * The consumer (typically {@link WorkflowExecutionViewer}) wires this
@@ -99,6 +110,7 @@ export const WorkflowTaskApprovalCard = memo(function WorkflowTaskApprovalCard({
99
110
  prompt,
100
111
  outcomes: outcomesProp,
101
112
  formSchema,
113
+ payload = null,
102
114
  onSubmit,
103
115
  isSubmitting,
104
116
  error,
@@ -161,6 +173,21 @@ export const WorkflowTaskApprovalCard = memo(function WorkflowTaskApprovalCard({
161
173
  </div>
162
174
  )}
163
175
 
176
+ {payload !== null && (
177
+ <div
178
+ aria-label={`Review material for ${taskName}`}
179
+ className="mb-3 max-h-96 overflow-y-auto rounded border border-border bg-background p-3"
180
+ >
181
+ {isPlainObject(payload) ? (
182
+ <StructuredDataViewer data={payload} />
183
+ ) : (
184
+ <pre className="overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground">
185
+ <code>{formatJson(payload)}</code>
186
+ </pre>
187
+ )}
188
+ </div>
189
+ )}
190
+
164
191
  {formFields.length > 0 && (
165
192
  <div className="mb-3 space-y-2">
166
193
  {formFields.map((field) => (
@@ -287,3 +314,8 @@ function resolveVariant(
287
314
  function capitalize(s: string): string {
288
315
  return s.charAt(0).toUpperCase() + s.slice(1);
289
316
  }
317
+
318
+ /** Object payloads get the structured viewer; arrays and scalars get JSON. */
319
+ function isPlainObject(value: JsonValue): value is JsonObject {
320
+ return value !== null && typeof value === "object" && !Array.isArray(value);
321
+ }
@@ -0,0 +1,232 @@
1
+ "use client";
2
+
3
+ import { memo, useCallback } from "react";
4
+ import type { JsonValue } from "@bufbuild/protobuf";
5
+ import { cn } from "@stigmer/theme";
6
+ import { useReviewRenderer } from "./ReviewRendererContext.js";
7
+ import { useReviewPayload } from "./useReviewPayload.js";
8
+ import {
9
+ WorkflowTaskApprovalCard,
10
+ type TaskOutcome,
11
+ } from "./WorkflowTaskApprovalCard.js";
12
+
13
+ /** Props for {@link WorkflowTaskReviewGate}. */
14
+ export interface WorkflowTaskReviewGateProps {
15
+ /** Name of the human_input task awaiting a decision. */
16
+ readonly taskName: string;
17
+ /** Resolved prompt text from the task config, rendered as markdown. */
18
+ readonly prompt?: string;
19
+ /** Configured outcomes; empty falls back to Approve / Reject. */
20
+ readonly outcomes: readonly TaskOutcome[];
21
+ /** JSON Schema for the reviewer's input form, when the gate defines one. */
22
+ readonly formSchema?: Record<string, unknown>;
23
+ /** Inline review payload from the approval_requested event, or `null`. */
24
+ readonly payload?: JsonValue | null;
25
+ /** Renderer discriminator from the task config's `ui_hint`. */
26
+ readonly uiHint?: string;
27
+ /** Artifact reference when the payload was promoted, or `null`. */
28
+ readonly payloadArtifactId?: string | null;
29
+ /**
30
+ * Called when the reviewer submits a decision. The consumer (typically
31
+ * {@link WorkflowExecutionViewer}) wires this to
32
+ * `useWorkflowExecutionActions().submitTaskApproval`.
33
+ */
34
+ readonly onSubmit: (
35
+ taskName: string,
36
+ outcome: string,
37
+ formData?: Record<string, unknown>,
38
+ comment?: string,
39
+ ) => Promise<unknown>;
40
+ /** True while this gate's submission RPC is in flight. */
41
+ readonly isSubmitting: boolean;
42
+ /** This gate's last failed decision, or `null`. */
43
+ readonly error?: Error | null;
44
+ /** Additional CSS class names for the root container. */
45
+ readonly className?: string;
46
+ }
47
+
48
+ /**
49
+ * The single decision surface for a pending workflow human_input gate.
50
+ *
51
+ * Owns the two review-payload concerns that sit above the approval card
52
+ * (issue #234):
53
+ *
54
+ * 1. **Payload materialization** — inline payloads pass straight through;
55
+ * artifact-backed payloads are fetched via the API-proxied
56
+ * `artifact.getContent` (see {@link useReviewPayload}), with loading
57
+ * and error states rendered here so custom renderers always receive
58
+ * resolved data.
59
+ * 2. **Renderer dispatch** — when the gate's `ui_hint` matches a renderer
60
+ * registered through `StigmerProvider`'s `reviewRenderers`, the host
61
+ * application's component presents the review; otherwise the built-in
62
+ * {@link WorkflowTaskApprovalCard} renders with the payload shown as
63
+ * structured data. An unrecognized hint never blocks the gate.
64
+ *
65
+ * Both mount points — the execution inspector's Approval tab and the
66
+ * timeline's inline event card — render this component, so custom
67
+ * renderers light up everywhere a gate appears without per-surface wiring.
68
+ */
69
+ export const WorkflowTaskReviewGate = memo(function WorkflowTaskReviewGate({
70
+ taskName,
71
+ prompt,
72
+ outcomes,
73
+ formSchema,
74
+ payload = null,
75
+ uiHint,
76
+ payloadArtifactId = null,
77
+ onSubmit,
78
+ isSubmitting,
79
+ error,
80
+ className,
81
+ }: WorkflowTaskReviewGateProps) {
82
+ const Renderer = useReviewRenderer(uiHint);
83
+ const resolved = useReviewPayload(payload, payloadArtifactId ?? null);
84
+
85
+ const submit = useCallback(
86
+ (outcome: string, formData?: Record<string, unknown>, comment?: string) =>
87
+ onSubmit(taskName, outcome, formData, comment),
88
+ [onSubmit, taskName],
89
+ );
90
+
91
+ if (resolved.isLoading) {
92
+ return <PayloadLoadingState taskName={taskName} className={className} />;
93
+ }
94
+
95
+ if (resolved.error) {
96
+ return (
97
+ <PayloadErrorState
98
+ taskName={taskName}
99
+ error={resolved.error}
100
+ onRetry={resolved.refetch}
101
+ className={className}
102
+ />
103
+ );
104
+ }
105
+
106
+ if (Renderer && resolved.payload !== null) {
107
+ return (
108
+ <div className={className}>
109
+ <Renderer
110
+ taskName={taskName}
111
+ payload={resolved.payload}
112
+ formSchema={formSchema ?? null}
113
+ outcomes={outcomes}
114
+ submit={submit}
115
+ isSubmitting={isSubmitting}
116
+ error={error ?? null}
117
+ />
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <WorkflowTaskApprovalCard
124
+ taskName={taskName}
125
+ prompt={prompt}
126
+ outcomes={outcomes}
127
+ formSchema={formSchema}
128
+ payload={resolved.payload}
129
+ onSubmit={onSubmit}
130
+ isSubmitting={isSubmitting}
131
+ error={error}
132
+ className={className}
133
+ />
134
+ );
135
+ });
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Payload resolution states (DD-006: never a blank screen)
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function PayloadLoadingState({
142
+ taskName,
143
+ className,
144
+ }: {
145
+ readonly taskName: string;
146
+ readonly className?: string;
147
+ }) {
148
+ return (
149
+ <div
150
+ role="status"
151
+ aria-label={`Loading review material for ${taskName}`}
152
+ className={cn(
153
+ "mt-2 rounded-lg border border-border-prominent border-l-2 border-l-warning p-3",
154
+ className,
155
+ )}
156
+ >
157
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
158
+ <Spinner />
159
+ Loading review material…
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ function PayloadErrorState({
166
+ taskName,
167
+ error,
168
+ onRetry,
169
+ className,
170
+ }: {
171
+ readonly taskName: string;
172
+ readonly error: Error;
173
+ readonly onRetry: () => void;
174
+ readonly className?: string;
175
+ }) {
176
+ return (
177
+ <div
178
+ role="alert"
179
+ className={cn(
180
+ "mt-2 rounded-lg border border-border-prominent border-l-2 border-l-destructive p-3",
181
+ className,
182
+ )}
183
+ >
184
+ <p className="text-xs font-medium text-foreground">
185
+ Could not load the review material for {taskName}
186
+ </p>
187
+ <p className="mt-1 break-words text-xs text-muted-foreground">{error.message}</p>
188
+ <p className="mt-1 text-xs text-muted-foreground">
189
+ The gate stays pending — retry to load the material before deciding.
190
+ </p>
191
+ <button
192
+ type="button"
193
+ onClick={onRetry}
194
+ className={cn(
195
+ "mt-2 rounded border border-border px-2 py-1 text-xs font-medium text-foreground",
196
+ "transition-colors hover:bg-muted",
197
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
198
+ )}
199
+ >
200
+ Retry
201
+ </button>
202
+ </div>
203
+ );
204
+ }
205
+
206
+ function Spinner() {
207
+ return (
208
+ <svg
209
+ width="12"
210
+ height="12"
211
+ viewBox="0 0 12 12"
212
+ fill="none"
213
+ className="animate-spin"
214
+ aria-hidden="true"
215
+ >
216
+ <circle
217
+ cx="6"
218
+ cy="6"
219
+ r="5"
220
+ stroke="currentColor"
221
+ strokeWidth="1.5"
222
+ strokeOpacity="0.25"
223
+ />
224
+ <path
225
+ d="M11 6a5 5 0 0 0-5-5"
226
+ stroke="currentColor"
227
+ strokeWidth="1.5"
228
+ strokeLinecap="round"
229
+ />
230
+ </svg>
231
+ );
232
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import { create, type MessageInitShape } from "@bufbuild/protobuf";
4
+ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
5
+ import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
6
+ import { PendingApprovalsWidget } from "../PendingApprovalsWidget";
7
+
8
+ function makeApproval(
9
+ overrides: MessageInitShape<typeof PendingApprovalSchema> = {},
10
+ ): PendingApproval {
11
+ return create(PendingApprovalSchema, {
12
+ executionId: "wfx-1",
13
+ workflowName: "Article Pipeline",
14
+ taskName: "reviewDraft",
15
+ requester: "usr-alice",
16
+ ...overrides,
17
+ });
18
+ }
19
+
20
+ describe("PendingApprovalsWidget", () => {
21
+ afterEach(cleanup);
22
+
23
+ it("renders workflow name and task name", () => {
24
+ render(
25
+ <PendingApprovalsWidget
26
+ approvals={[makeApproval()]}
27
+ totalCount={1}
28
+ isLoading={false}
29
+ />,
30
+ );
31
+ expect(screen.getByText("Article Pipeline")).toBeTruthy();
32
+ expect(screen.getByText(/reviewDraft/)).toBeTruthy();
33
+ });
34
+
35
+ describe("review-type badge", () => {
36
+ it("shows the uiHint as a badge so dashboards can distinguish review types", () => {
37
+ render(
38
+ <PendingApprovalsWidget
39
+ approvals={[makeApproval({ uiHint: "article-diff" })]}
40
+ totalCount={1}
41
+ isLoading={false}
42
+ />,
43
+ );
44
+ expect(screen.getByText("article-diff")).toBeTruthy();
45
+ });
46
+
47
+ it("renders no badge for a generic review (empty uiHint)", () => {
48
+ const { container } = render(
49
+ <PendingApprovalsWidget
50
+ approvals={[makeApproval()]}
51
+ totalCount={1}
52
+ isLoading={false}
53
+ />,
54
+ );
55
+ // Only the workflow-name/task-name texts render; no badge span appears.
56
+ expect(container.querySelectorAll("span.rounded")).toHaveLength(0);
57
+ });
58
+ });
59
+
60
+ it("shows the empty state when there are no approvals", () => {
61
+ render(
62
+ <PendingApprovalsWidget approvals={[]} totalCount={0} isLoading={false} />,
63
+ );
64
+ expect(screen.getByText("No approvals pending")).toBeTruthy();
65
+ });
66
+
67
+ it("invokes onReviewClick with the execution id", () => {
68
+ const onReviewClick = vi.fn();
69
+ render(
70
+ <PendingApprovalsWidget
71
+ approvals={[makeApproval()]}
72
+ totalCount={1}
73
+ isLoading={false}
74
+ onReviewClick={onReviewClick}
75
+ />,
76
+ );
77
+ fireEvent.click(screen.getByRole("button", { name: "Review" }));
78
+ expect(onReviewClick).toHaveBeenCalledWith("wfx-1");
79
+ });
80
+ });
@@ -134,6 +134,33 @@ describe("WorkflowTaskApprovalCard", () => {
134
134
  });
135
135
  });
136
136
 
137
+ describe("review payload (issue #234 fallback display)", () => {
138
+ it("renders an object payload as structured data", () => {
139
+ render(
140
+ <WorkflowTaskApprovalCard
141
+ {...defaultProps}
142
+ payload={{ severity: "P1", summary: "Escalate the incident" }}
143
+ />,
144
+ );
145
+ expect(screen.getByLabelText("Review material for review_task")).toBeTruthy();
146
+ expect(screen.getByText("Escalate the incident")).toBeTruthy();
147
+ });
148
+
149
+ it("renders non-object payloads as formatted JSON", () => {
150
+ render(
151
+ <WorkflowTaskApprovalCard {...defaultProps} payload={["item-1", "item-2"]} />,
152
+ );
153
+ const section = screen.getByLabelText("Review material for review_task");
154
+ expect(section.textContent).toContain("item-1");
155
+ expect(section.textContent).toContain("item-2");
156
+ });
157
+
158
+ it("renders no review material section when payload is absent", () => {
159
+ render(<WorkflowTaskApprovalCard {...defaultProps} />);
160
+ expect(screen.queryByLabelText("Review material for review_task")).toBeNull();
161
+ });
162
+ });
163
+
137
164
  describe("submission", () => {
138
165
  it("onSubmit called with (taskName, outcomeName, undefined, undefined) when no form or comment", () => {
139
166
  const onSubmit = vi.fn().mockResolvedValue(undefined);