@stigmer/react 3.1.10 → 3.1.12

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
@@ -0,0 +1,283 @@
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { StigmerContext } from "../../context";
5
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
6
+ import { ReviewRendererContext, type ReviewRenderers } from "../ReviewRendererContext";
7
+ import { WorkflowTaskReviewGate } from "../WorkflowTaskReviewGate";
8
+
9
+ /**
10
+ * Minimal Stigmer client stub. `artifact.getContent` only matters for
11
+ * artifact-backed payload tests; inline tests never call it.
12
+ */
13
+ function createMockStigmer(overrides: {
14
+ getContent?: (...args: unknown[]) => Promise<unknown>;
15
+ } = {}) {
16
+ return {
17
+ artifact: {
18
+ getContent:
19
+ overrides.getContent ??
20
+ vi.fn().mockRejectedValue(new Error("getContent not stubbed")),
21
+ },
22
+ } as never;
23
+ }
24
+
25
+ function jsonContentResponse(value: unknown) {
26
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
27
+ return {
28
+ content: bytes,
29
+ contentType: "application/json",
30
+ totalSizeBytes: BigInt(bytes.length),
31
+ truncated: false,
32
+ };
33
+ }
34
+
35
+ function wrapper(client: unknown, renderers: ReviewRenderers = {}) {
36
+ return function Wrapper({ children }: { children: ReactNode }) {
37
+ return (
38
+ <FetchCacheContext.Provider value={null}>
39
+ <StigmerContext.Provider value={client as never}>
40
+ <ReviewRendererContext.Provider value={renderers}>
41
+ {children}
42
+ </ReviewRendererContext.Provider>
43
+ </StigmerContext.Provider>
44
+ </FetchCacheContext.Provider>
45
+ );
46
+ };
47
+ }
48
+
49
+ const defaultProps = {
50
+ taskName: "editorial_review",
51
+ prompt: "Review the proposed revision before publishing.",
52
+ outcomes: [
53
+ { name: "approve", label: "Approve" },
54
+ { name: "request_changes", label: "Request Changes" },
55
+ ],
56
+ onSubmit: vi.fn().mockResolvedValue(undefined),
57
+ isSubmitting: false,
58
+ error: null,
59
+ };
60
+
61
+ describe("WorkflowTaskReviewGate", () => {
62
+ beforeEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+ afterEach(cleanup);
66
+
67
+ describe("renderer dispatch", () => {
68
+ it("renders the built-in card when the gate has no ui_hint", () => {
69
+ render(<WorkflowTaskReviewGate {...defaultProps} />, {
70
+ wrapper: wrapper(createMockStigmer()),
71
+ });
72
+
73
+ expect(
74
+ screen.getByRole("form", { name: /approval decision for editorial_review/i }),
75
+ ).toBeTruthy();
76
+ });
77
+
78
+ it("falls back to the built-in card for an unregistered ui_hint (portability)", () => {
79
+ render(
80
+ <WorkflowTaskReviewGate
81
+ {...defaultProps}
82
+ uiHint="article-diff"
83
+ payload={{ title: "Draft v2" }}
84
+ />,
85
+ { wrapper: wrapper(createMockStigmer(), {}) },
86
+ );
87
+
88
+ expect(
89
+ screen.getByRole("form", { name: /approval decision for editorial_review/i }),
90
+ ).toBeTruthy();
91
+ });
92
+
93
+ it("renders the registered custom renderer for a matching ui_hint", () => {
94
+ const renderers: ReviewRenderers = {
95
+ "article-diff": ({ payload }) => (
96
+ <div data-testid="custom-renderer">
97
+ {(payload as { title: string }).title}
98
+ </div>
99
+ ),
100
+ };
101
+
102
+ render(
103
+ <WorkflowTaskReviewGate
104
+ {...defaultProps}
105
+ uiHint="article-diff"
106
+ payload={{ title: "Draft v2" }}
107
+ />,
108
+ { wrapper: wrapper(createMockStigmer(), renderers) },
109
+ );
110
+
111
+ expect(screen.getByTestId("custom-renderer").textContent).toBe("Draft v2");
112
+ expect(screen.queryByRole("form")).toBeNull();
113
+ });
114
+
115
+ it("passes formSchema and outcomes through to the custom renderer", () => {
116
+ const seen: { formSchema?: unknown; outcomes?: unknown } = {};
117
+ const renderers: ReviewRenderers = {
118
+ "article-diff": ({ formSchema, outcomes }) => {
119
+ seen.formSchema = formSchema;
120
+ seen.outcomes = outcomes;
121
+ return <div data-testid="custom-renderer" />;
122
+ },
123
+ };
124
+
125
+ const formSchema = { type: "object", properties: { notes: { type: "string" } } };
126
+ render(
127
+ <WorkflowTaskReviewGate
128
+ {...defaultProps}
129
+ uiHint="article-diff"
130
+ payload={{ title: "Draft" }}
131
+ formSchema={formSchema}
132
+ />,
133
+ { wrapper: wrapper(createMockStigmer(), renderers) },
134
+ );
135
+
136
+ expect(seen.formSchema).toEqual(formSchema);
137
+ expect(seen.outcomes).toEqual(defaultProps.outcomes);
138
+ });
139
+
140
+ it("binds taskName into the renderer's submit callback", () => {
141
+ const onSubmit = vi.fn().mockResolvedValue(undefined);
142
+ const renderers: ReviewRenderers = {
143
+ "article-diff": ({ submit }) => (
144
+ <button
145
+ type="button"
146
+ onClick={() => submit("approve", { notes: "lgtm" }, "ship it")}
147
+ >
148
+ Decide
149
+ </button>
150
+ ),
151
+ };
152
+
153
+ render(
154
+ <WorkflowTaskReviewGate
155
+ {...defaultProps}
156
+ onSubmit={onSubmit}
157
+ uiHint="article-diff"
158
+ payload={{ title: "Draft" }}
159
+ />,
160
+ { wrapper: wrapper(createMockStigmer(), renderers) },
161
+ );
162
+
163
+ fireEvent.click(screen.getByRole("button", { name: "Decide" }));
164
+
165
+ expect(onSubmit).toHaveBeenCalledWith(
166
+ "editorial_review",
167
+ "approve",
168
+ { notes: "lgtm" },
169
+ "ship it",
170
+ );
171
+ });
172
+ });
173
+
174
+ describe("inline payload fallback display", () => {
175
+ it("shows the payload as structured data in the built-in card", () => {
176
+ render(
177
+ <WorkflowTaskReviewGate
178
+ {...defaultProps}
179
+ payload={{ severity: "P1", summary: "Database migration plan" }}
180
+ />,
181
+ { wrapper: wrapper(createMockStigmer()) },
182
+ );
183
+
184
+ expect(screen.getByLabelText("Review material for editorial_review")).toBeTruthy();
185
+ expect(screen.getByText("Database migration plan")).toBeTruthy();
186
+ });
187
+
188
+ it("omits the review material section when the gate has no payload", () => {
189
+ render(<WorkflowTaskReviewGate {...defaultProps} />, {
190
+ wrapper: wrapper(createMockStigmer()),
191
+ });
192
+
193
+ expect(screen.queryByLabelText("Review material for editorial_review")).toBeNull();
194
+ });
195
+ });
196
+
197
+ describe("artifact-backed payload", () => {
198
+ it("shows a loading state, then the custom renderer with fetched content", async () => {
199
+ const getContent = vi
200
+ .fn()
201
+ .mockResolvedValue(jsonContentResponse({ records: ["r1", "r2"] }));
202
+ const renderers: ReviewRenderers = {
203
+ "infra-proposal": ({ payload }) => (
204
+ <div data-testid="custom-renderer">
205
+ {(payload as { records: string[] }).records.length} records
206
+ </div>
207
+ ),
208
+ };
209
+
210
+ render(
211
+ <WorkflowTaskReviewGate
212
+ {...defaultProps}
213
+ uiHint="infra-proposal"
214
+ payloadArtifactId="art_review123"
215
+ />,
216
+ { wrapper: wrapper(createMockStigmer({ getContent }), renderers) },
217
+ );
218
+
219
+ expect(
220
+ screen.getByRole("status", { name: /loading review material/i }),
221
+ ).toBeTruthy();
222
+
223
+ await waitFor(() =>
224
+ expect(screen.getByTestId("custom-renderer").textContent).toBe("2 records"),
225
+ );
226
+ expect(getContent).toHaveBeenCalledWith(
227
+ expect.objectContaining({ artifactId: "art_review123" }),
228
+ );
229
+ });
230
+
231
+ it("shows an error state with retry when the artifact fetch fails", async () => {
232
+ const getContent = vi.fn().mockRejectedValue(new Error("storage unavailable"));
233
+
234
+ render(
235
+ <WorkflowTaskReviewGate {...defaultProps} payloadArtifactId="art_review123" />,
236
+ { wrapper: wrapper(createMockStigmer({ getContent })) },
237
+ );
238
+
239
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
240
+ expect(screen.getByText(/storage unavailable/)).toBeTruthy();
241
+ expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
242
+ // The gate must never offer a decision without its review material.
243
+ expect(screen.queryByRole("form")).toBeNull();
244
+ });
245
+
246
+ it("retries the fetch when the reviewer clicks Retry", async () => {
247
+ const getContent = vi
248
+ .fn()
249
+ .mockRejectedValueOnce(new Error("transient"))
250
+ .mockResolvedValue(jsonContentResponse({ ok: true }));
251
+
252
+ render(
253
+ <WorkflowTaskReviewGate {...defaultProps} payloadArtifactId="art_review123" />,
254
+ { wrapper: wrapper(createMockStigmer({ getContent })) },
255
+ );
256
+
257
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
258
+ fireEvent.click(screen.getByRole("button", { name: "Retry" }));
259
+
260
+ await waitFor(() =>
261
+ expect(screen.getByRole("form", { name: /approval decision/i })).toBeTruthy(),
262
+ );
263
+ expect(getContent).toHaveBeenCalledTimes(2);
264
+ });
265
+
266
+ it("surfaces a parse failure as an error instead of rendering garbage", async () => {
267
+ const bytes = new TextEncoder().encode("{not json");
268
+ const getContent = vi.fn().mockResolvedValue({
269
+ content: bytes,
270
+ contentType: "application/json",
271
+ totalSizeBytes: BigInt(bytes.length),
272
+ truncated: false,
273
+ });
274
+
275
+ render(
276
+ <WorkflowTaskReviewGate {...defaultProps} payloadArtifactId="art_review123" />,
277
+ { wrapper: wrapper(createMockStigmer({ getContent })) },
278
+ );
279
+
280
+ await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
281
+ });
282
+ });
283
+ });
@@ -1,4 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import { fromJson } from "@bufbuild/protobuf";
3
+ import { ValueSchema } from "@bufbuild/protobuf/wkt";
2
4
  import type { WorkflowExecutionEvent } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
3
5
  import type { WorkflowTask } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
4
6
  import type { DerivedTaskState } from "../../internal/store/workflow-execution-event-store";
@@ -615,6 +617,82 @@ describe("deriveTaskDetail", () => {
615
617
  expect(result!.approval!.decision).toBeNull();
616
618
  });
617
619
 
620
+ // 13b. Review payload fields (issue #234)
621
+ it("unwraps an inline review payload with ui_hint from the approvalRequested event", () => {
622
+ const derived = makeDerived({ status: "waiting_approval" });
623
+ const events = [
624
+ makeEvent("my-task", 1, "2026-01-01T00:00:00Z", {
625
+ case: "approvalRequested",
626
+ value: {
627
+ prompt: "Review the draft",
628
+ approvers: [],
629
+ timeoutSeconds: 0,
630
+ outcomes: [],
631
+ formSchema: null,
632
+ // Real events carry a google.protobuf.Value message.
633
+ payload: fromJson(ValueSchema, { title: "Q3 plan", items: [1, 2] }),
634
+ uiHint: "plan-review",
635
+ payloadArtifactId: "",
636
+ },
637
+ }),
638
+ ];
639
+
640
+ const result = deriveTaskDetail("my-task", events, undefined, derived);
641
+
642
+ expect(result!.approval!.payload).toEqual({ title: "Q3 plan", items: [1, 2] });
643
+ expect(result!.approval!.uiHint).toBe("plan-review");
644
+ expect(result!.approval!.payloadArtifactId).toBeNull();
645
+ });
646
+
647
+ it("carries the artifact reference instead of inline data for promoted payloads", () => {
648
+ const derived = makeDerived({ status: "waiting_approval" });
649
+ const events = [
650
+ makeEvent("my-task", 1, "2026-01-01T00:00:00Z", {
651
+ case: "approvalRequested",
652
+ value: {
653
+ prompt: "Review the proposal",
654
+ approvers: [],
655
+ timeoutSeconds: 0,
656
+ outcomes: [],
657
+ formSchema: null,
658
+ payload: undefined,
659
+ uiHint: "infra-proposal",
660
+ payloadArtifactId: "art_review123",
661
+ },
662
+ }),
663
+ ];
664
+
665
+ const result = deriveTaskDetail("my-task", events, undefined, derived);
666
+
667
+ expect(result!.approval!.payload).toBeNull();
668
+ expect(result!.approval!.uiHint).toBe("infra-proposal");
669
+ expect(result!.approval!.payloadArtifactId).toBe("art_review123");
670
+ });
671
+
672
+ it("defaults review payload fields when the gate carries none (pre-#234 events)", () => {
673
+ const derived = makeDerived({ status: "waiting_approval" });
674
+ const events = [
675
+ makeEvent("my-task", 1, "2026-01-01T00:00:00Z", {
676
+ case: "approvalRequested",
677
+ value: {
678
+ prompt: "Continue?",
679
+ approvers: [],
680
+ timeoutSeconds: 0,
681
+ outcomes: [],
682
+ formSchema: null,
683
+ uiHint: "",
684
+ payloadArtifactId: "",
685
+ },
686
+ }),
687
+ ];
688
+
689
+ const result = deriveTaskDetail("my-task", events, undefined, derived);
690
+
691
+ expect(result!.approval!.payload).toBeNull();
692
+ expect(result!.approval!.uiHint).toBe("");
693
+ expect(result!.approval!.payloadArtifactId).toBeNull();
694
+ });
695
+
618
696
  // 14. Approval resolved — event-only (the brief "finalizing" window before
619
697
  // the task-output snapshot reflects the decision)
620
698
  it("populates approval decision from the event when the task output is not yet available", () => {
@@ -16,7 +16,7 @@ import { RetriesTab } from "./RetriesTab.js";
16
16
  import { AgentCallTab } from "./AgentCallTab.js";
17
17
  import { EventLogTab } from "./EventLogTab.js";
18
18
  import { WorkflowExecutionApprovalCard } from "../WorkflowExecutionApprovalCard.js";
19
- import { WorkflowTaskApprovalCard } from "../WorkflowTaskApprovalCard.js";
19
+ import { WorkflowTaskReviewGate } from "../WorkflowTaskReviewGate.js";
20
20
  import { WorkflowTaskApprovalSummary } from "../WorkflowTaskApprovalSummary.js";
21
21
 
22
22
  /** Props for {@link ExecutionInspector}. */
@@ -250,12 +250,17 @@ export const ExecutionInspector = memo(function ExecutionInspector({
250
250
  )}
251
251
  {effectiveTab === "approval" && detail.approval && (
252
252
  detail.status === "waiting_approval" && onSubmitTaskApproval ? (
253
- // Gate still awaiting a decision — collect one.
254
- <WorkflowTaskApprovalCard
253
+ // Gate still awaiting a decision — collect one. The gate
254
+ // resolves artifact-backed payloads and dispatches to a
255
+ // registered review renderer (by ui_hint) or the built-in card.
256
+ <WorkflowTaskReviewGate
255
257
  taskName={detail.taskName}
256
258
  prompt={detail.approval.prompt}
257
259
  outcomes={detail.approval.outcomes}
258
260
  formSchema={detail.approval.formSchema ?? undefined}
261
+ payload={detail.approval.payload}
262
+ uiHint={detail.approval.uiHint}
263
+ payloadArtifactId={detail.approval.payloadArtifactId}
259
264
  onSubmit={onSubmitTaskApproval}
260
265
  isSubmitting={taskApprovalSubmittingTaskNames?.has(detail.taskName) ?? false}
261
266
  error={taskApprovalErrorsByTaskName?.get(detail.taskName) ?? null}
@@ -11,7 +11,8 @@
11
11
  import type { WorkflowExecutionEvent } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
12
12
  import type { WorkflowTask } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
13
13
  import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
14
- import type { JsonObject } from "@bufbuild/protobuf";
14
+ import { toJson, type JsonObject, type JsonValue } from "@bufbuild/protobuf";
15
+ import { ValueSchema } from "@bufbuild/protobuf/wkt";
15
16
  import type { DerivedTaskState } from "../../internal/store/workflow-execution-event-store.js";
16
17
  import { kindToDisplayName } from "../kind-metadata.js";
17
18
  import { taskKindToString } from "../workflow-graph-conversions.js";
@@ -96,6 +97,25 @@ export interface TaskDetailApproval {
96
97
  readonly outcomes: readonly { readonly name: string; readonly label: string }[];
97
98
  readonly formSchema: JsonObject | null;
98
99
  readonly timeoutSeconds: number;
100
+ /**
101
+ * Resolved review payload the gate presented — the material under
102
+ * review (issue #234). `null` when the gate carries no payload or when
103
+ * the payload is artifact-backed (see {@link payloadArtifactId}).
104
+ */
105
+ readonly payload: JsonValue | null;
106
+ /**
107
+ * Renderer discriminator from the task config's `ui_hint`. Consumers
108
+ * with a registered review renderer for this hint present domain-native
109
+ * UI; everything else falls back to structured-data display. The empty
110
+ * string when the task config sets no hint.
111
+ */
112
+ readonly uiHint: string;
113
+ /**
114
+ * Artifact holding the payload when it exceeded the inline promotion
115
+ * threshold. Mutually exclusive with {@link payload}; resolve the
116
+ * content via `stigmer.artifact.getContent`. `null` when inline.
117
+ */
118
+ readonly payloadArtifactId: string | null;
99
119
  readonly decision: TaskDetailApprovalDecision | null;
100
120
  }
101
121
 
@@ -204,7 +224,7 @@ interface EventBuckets {
204
224
  agentStarted: { childExecutionId: string; agentSlug: string; messageSummary: string } | null;
205
225
  agentProgress: { childExecutionId: string; agentPhase: number; currentToolName: string; tokensConsumed: bigint; messagesCount: number; toolCallsCount: number } | null;
206
226
  agentCompleted: { durationMs: number; tokensConsumed: bigint; costMicros: bigint; error: string; agentPhase: number } | null;
207
- approvalRequested: { prompt: string; approvers: string[]; timeoutSeconds: number; outcomes: Array<{ name: string; label: string }>; formSchema: JsonObject | null } | null;
227
+ approvalRequested: { prompt: string; approvers: string[]; timeoutSeconds: number; outcomes: Array<{ name: string; label: string }>; formSchema: JsonObject | null; payload: JsonValue | null; uiHint: string; payloadArtifactId: string | null } | null;
208
228
  approvalResolved: { action: number; resolvedBy: string; comment: string; waitDurationMs: number } | null;
209
229
  inputSummary: JsonObject | null;
210
230
  outputSummary: JsonObject | null;
@@ -315,6 +335,12 @@ function bucketEvents(taskEvents: readonly WorkflowExecutionEvent[]): EventBucke
315
335
  timeoutSeconds: p.value.timeoutSeconds,
316
336
  outcomes: (p.value.outcomes ?? []).map((o) => ({ name: o.name, label: o.label })),
317
337
  formSchema: p.value.formSchema ? (p.value.formSchema as unknown as JsonObject) : null,
338
+ // The payload rides the event as a google.protobuf.Value message;
339
+ // unwrap it to plain JSON once here so all consumers downstream
340
+ // (renderers, fallback card) work with ordinary values.
341
+ payload: p.value.payload ? (toJson(ValueSchema, p.value.payload) as JsonValue) : null,
342
+ uiHint: p.value.uiHint,
343
+ payloadArtifactId: p.value.payloadArtifactId || null,
318
344
  };
319
345
  break;
320
346
 
@@ -515,6 +541,9 @@ function buildApproval(
515
541
  outcomes: req.outcomes,
516
542
  formSchema: req.formSchema,
517
543
  timeoutSeconds: req.timeoutSeconds,
544
+ payload: req.payload,
545
+ uiHint: req.uiHint,
546
+ payloadArtifactId: req.payloadArtifactId,
518
547
  decision: null,
519
548
  };
520
549
  }
@@ -525,6 +554,9 @@ function buildApproval(
525
554
  outcomes: req.outcomes,
526
555
  formSchema: req.formSchema,
527
556
  timeoutSeconds: req.timeoutSeconds,
557
+ payload: req.payload,
558
+ uiHint: req.uiHint,
559
+ payloadArtifactId: req.payloadArtifactId,
528
560
  decision: {
529
561
  outcome: outputOutcome,
530
562
  reviewer: readSnapshotString(taskOutput, "reviewer") || (res?.resolvedBy ?? ""),
@@ -220,6 +220,22 @@ export {
220
220
  type TaskOutcome,
221
221
  } from "./WorkflowTaskApprovalCard.js";
222
222
 
223
+ // Review payloads (issue #234): custom renderers for human_input gates
224
+ export {
225
+ WorkflowTaskReviewGate,
226
+ type WorkflowTaskReviewGateProps,
227
+ } from "./WorkflowTaskReviewGate.js";
228
+ export {
229
+ ReviewRendererContext,
230
+ useReviewRenderer,
231
+ type ReviewRendererProps,
232
+ type ReviewRenderers,
233
+ } from "./ReviewRendererContext.js";
234
+ export {
235
+ useReviewPayload,
236
+ type UseReviewPayloadReturn,
237
+ } from "./useReviewPayload.js";
238
+
223
239
  export {
224
240
  WorkflowTaskApprovalSummary,
225
241
  type WorkflowTaskApprovalSummaryProps,
@@ -0,0 +1,115 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import type { JsonValue } from "@bufbuild/protobuf";
6
+ import { GetArtifactContentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/artifact/v1/io_pb";
7
+ import { StigmerContext } from "../context.js";
8
+ import { useFetch } from "../internal/useFetch.js";
9
+
10
+ /**
11
+ * Request cap for artifact-backed review payloads.
12
+ *
13
+ * Matches the artifact store's 50MB creation limit
14
+ * (`CreateArtifactInput.content` max_len), so a promoted payload can
15
+ * never legitimately exceed this. Unlike preview flows, a review
16
+ * renderer needs the complete payload — a truncated JSON document
17
+ * would not even parse — so we request the full artifact rather than
18
+ * the server's 512KB preview default.
19
+ */
20
+ const REVIEW_PAYLOAD_MAX_BYTES = 50 * 1024 * 1024;
21
+
22
+ /** Return value of {@link useReviewPayload}. */
23
+ export interface UseReviewPayloadReturn {
24
+ /**
25
+ * The materialized review payload: the inline value when the gate
26
+ * carried it directly, or the fetched-and-parsed artifact content when
27
+ * it was artifact-backed. `null` while loading, on error, or when the
28
+ * gate has no payload at all.
29
+ */
30
+ readonly payload: JsonValue | null;
31
+ /** `true` while an artifact-backed payload fetch is in flight. */
32
+ readonly isLoading: boolean;
33
+ /** Error from a failed artifact fetch or parse, or `null`. */
34
+ readonly error: Error | null;
35
+ /** Re-fetch an artifact-backed payload after a failure. */
36
+ readonly refetch: () => void;
37
+ }
38
+
39
+ /**
40
+ * Behavior hook that materializes a human_input gate's review payload.
41
+ *
42
+ * The `approval_requested` event carries the payload in one of two forms
43
+ * (mutually exclusive): inline JSON for small payloads, or an artifact
44
+ * reference for payloads that exceeded the runner's promotion threshold.
45
+ * This hook collapses the difference — consumers (custom review renderers,
46
+ * the built-in approval card) always receive plain JSON plus loading and
47
+ * error states, and never deal with artifact plumbing.
48
+ *
49
+ * Artifact content is read through `stigmer.artifact.getContent`, which
50
+ * proxies bytes through the Stigmer API — embedded SDK consumers on
51
+ * third-party origins avoid the CORS exposure of presigned URLs.
52
+ *
53
+ * Inline payloads resolve synchronously: `isLoading` is `false` and
54
+ * `payload` is available on first render.
55
+ *
56
+ * @param inlinePayload - Inline payload from `TaskDetailApproval.payload`,
57
+ * or `null`.
58
+ * @param payloadArtifactId - Artifact reference from
59
+ * `TaskDetailApproval.payloadArtifactId`, or `null`.
60
+ */
61
+ export function useReviewPayload(
62
+ inlinePayload: JsonValue | null,
63
+ payloadArtifactId: string | null,
64
+ ): UseReviewPayloadReturn {
65
+ // Nullable on purpose: gates without an artifact-backed payload (no
66
+ // payload, or an inline one) must keep working outside StigmerProvider,
67
+ // as the approval surfaces always have. The client is a requirement of
68
+ // the artifact fetch, not of the hook — enforced below with a
69
+ // descriptive error (DD-006) only when a fetch is actually needed.
70
+ const stigmer = useContext(StigmerContext);
71
+
72
+ const { data, isLoading, error, refetch } = useFetch<JsonValue | null>(
73
+ payloadArtifactId
74
+ ? async () => {
75
+ if (!stigmer) {
76
+ throw new Error(
77
+ "This gate's review payload is artifact-backed and needs a " +
78
+ "Stigmer client to fetch. Wrap your component tree with " +
79
+ "<StigmerProvider client={stigmerClient}>.",
80
+ );
81
+ }
82
+ const response = await stigmer.artifact.getContent(
83
+ create(GetArtifactContentRequestSchema, {
84
+ artifactId: payloadArtifactId,
85
+ maxBytes: BigInt(REVIEW_PAYLOAD_MAX_BYTES),
86
+ }),
87
+ );
88
+ // Defensive: cannot happen while the promotion path and this cap
89
+ // both honor the 50MB artifact limit, but a truncated JSON body
90
+ // must fail loudly rather than parse into partial review material.
91
+ if (response.truncated) {
92
+ throw new Error(
93
+ `Review payload artifact ${payloadArtifactId} exceeds the ` +
94
+ `${REVIEW_PAYLOAD_MAX_BYTES} byte limit and was truncated`,
95
+ );
96
+ }
97
+ const text = new TextDecoder().decode(response.content);
98
+ return JSON.parse(text) as JsonValue;
99
+ }
100
+ : null,
101
+ [payloadArtifactId, stigmer],
102
+ null,
103
+ );
104
+
105
+ if (!payloadArtifactId) {
106
+ return {
107
+ payload: inlinePayload,
108
+ isLoading: false,
109
+ error: null,
110
+ refetch,
111
+ };
112
+ }
113
+
114
+ return { payload: data, isLoading, error, refetch };
115
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"PendingApprovalsWidget.d.ts","sourceRoot":"","sources":["../../src/workflow/PendingApprovalsWidget.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+DAA+D,CAAC;AAIrG,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACvD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAUD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,mEAkFjC,CAAC"}
1
+ {"version":3,"file":"PendingApprovalsWidget.d.ts","sourceRoot":"","sources":["../../src/workflow/PendingApprovalsWidget.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+DAA+D,CAAC;AAIrG,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,SAAS,EAAE,SAAS,eAAe,EAAE,CAAC;IAC/C,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,2DAA2D;IAC3D,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACvD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAUD;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,mEAyFjC,CAAC"}
@@ -29,7 +29,7 @@ export const PendingApprovalsWidget = memo(function PendingApprovalsWidget({ app
29
29
  const requestedAt = approval.requestedAt
30
30
  ? timestampDate(approval.requestedAt)
31
31
  : null;
32
- return (_jsx("li", { className: "rounded-lg border border-border px-3 py-2.5", children: _jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium text-foreground", children: approval.workflowName || approval.executionId }), _jsxs("p", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: ["Task: ", approval.taskName, requestedAt && (_jsxs(_Fragment, { children: [" \u00B7 ", formatTimeAgo(requestedAt)] }))] })] }), onReviewClick && (_jsx("button", { type: "button", onClick: () => onReviewClick(approval.executionId), className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: "Review" }))] }) }, `${approval.executionId}-${approval.taskName}`));
32
+ return (_jsx("li", { className: "rounded-lg border border-border px-3 py-2.5", children: _jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("p", { className: "truncate text-sm font-medium text-foreground", children: approval.workflowName || approval.executionId }), approval.uiHint && (_jsx("span", { className: "shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground", children: approval.uiHint }))] }), _jsxs("p", { className: "mt-0.5 truncate text-xs text-muted-foreground", children: ["Task: ", approval.taskName, requestedAt && (_jsxs(_Fragment, { children: [" \u00B7 ", formatTimeAgo(requestedAt)] }))] })] }), onReviewClick && (_jsx("button", { type: "button", onClick: () => onReviewClick(approval.executionId), className: "shrink-0 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: "Review" }))] }) }, `${approval.executionId}-${approval.taskName}`));
33
33
  }) }))] }));
34
34
  });
35
35
  //# sourceMappingURL=PendingApprovalsWidget.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"PendingApprovalsWidget.js","sourceRoot":"","sources":["../../src/workflow/PendingApprovalsWidget.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PendingApprovalsWidget.js","sourceRoot":"","sources":["../../src/workflow/PendingApprovalsWidget.tsx"],"names":[],"mappings":"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"}