@stigmer/react 3.12.8 → 3.13.0

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 (76) hide show
  1. package/README.md +1 -3
  2. package/conversation/ConversationTimelineView.d.ts +15 -1
  3. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  4. package/conversation/ConversationTimelineView.js +3 -2
  5. package/conversation/ConversationTimelineView.js.map +1 -1
  6. package/conversation/ConversationsWorkbench.d.ts +14 -1
  7. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  8. package/conversation/ConversationsWorkbench.js +11 -2
  9. package/conversation/ConversationsWorkbench.js.map +1 -1
  10. package/execution/MessageThread.d.ts +33 -2
  11. package/execution/MessageThread.d.ts.map +1 -1
  12. package/execution/MessageThread.js +44 -6
  13. package/execution/MessageThread.js.map +1 -1
  14. package/execution/RecalledMemoriesCard.d.ts +47 -0
  15. package/execution/RecalledMemoriesCard.d.ts.map +1 -0
  16. package/execution/RecalledMemoriesCard.js +58 -0
  17. package/execution/RecalledMemoriesCard.js.map +1 -0
  18. package/execution/index.d.ts +2 -0
  19. package/execution/index.d.ts.map +1 -1
  20. package/execution/index.js +1 -0
  21. package/execution/index.js.map +1 -1
  22. package/identity-account/AccountPreferencesPanel.js +1 -1
  23. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  24. package/index.d.ts +2 -2
  25. package/index.d.ts.map +1 -1
  26. package/index.js +1 -1
  27. package/index.js.map +1 -1
  28. package/internal/VirtualizedThread.d.ts +3 -1
  29. package/internal/VirtualizedThread.d.ts.map +1 -1
  30. package/internal/VirtualizedThread.js +3 -1
  31. package/internal/VirtualizedThread.js.map +1 -1
  32. package/internal/useAutoScroll.d.ts +18 -0
  33. package/internal/useAutoScroll.d.ts.map +1 -1
  34. package/internal/useAutoScroll.js +50 -0
  35. package/internal/useAutoScroll.js.map +1 -1
  36. package/organization/OrgPreferencesPanel.js +1 -1
  37. package/organization/OrgPreferencesPanel.js.map +1 -1
  38. package/package.json +4 -4
  39. package/provider-standing/useProviderStanding.d.ts +1 -1
  40. package/provider-standing/useProviderStanding.js +1 -1
  41. package/settings/MemorySection.d.ts.map +1 -1
  42. package/settings/MemorySection.js +1 -1
  43. package/settings/MemorySection.js.map +1 -1
  44. package/src/conversation/ConversationTimelineView.tsx +17 -1
  45. package/src/conversation/ConversationsWorkbench.tsx +25 -0
  46. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
  47. package/src/execution/MessageThread.tsx +75 -3
  48. package/src/execution/RecalledMemoriesCard.tsx +164 -0
  49. package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
  50. package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
  51. package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
  52. package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
  53. package/src/execution/index.ts +2 -0
  54. package/src/identity-account/AccountPreferencesPanel.tsx +1 -1
  55. package/src/index.ts +3 -0
  56. package/src/internal/VirtualizedThread.tsx +5 -0
  57. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
  58. package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
  59. package/src/internal/useAutoScroll.ts +54 -0
  60. package/src/organization/OrgPreferencesPanel.tsx +1 -1
  61. package/src/provider-standing/useProviderStanding.ts +1 -1
  62. package/src/settings/MemorySection.tsx +4 -2
  63. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
  64. package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +11 -9
  65. package/src/workflow/__tests__/WorkflowTaskThread.test.tsx +54 -2
  66. package/src/workflow/__tests__/task-presentation.test.ts +49 -12
  67. package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
  68. package/src/workflow/thread/WorkflowTaskThread.tsx +102 -50
  69. package/src/workflow/thread/task-presentation.ts +52 -7
  70. package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
  71. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  72. package/workflow/thread/WorkflowTaskThread.js +62 -31
  73. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  74. package/workflow/thread/task-presentation.d.ts.map +1 -1
  75. package/workflow/thread/task-presentation.js +50 -7
  76. package/workflow/thread/task-presentation.js.map +1 -1
@@ -69,6 +69,13 @@ export function useAutoScroll(): UseAutoScrollReturn {
69
69
  const [isFollowing, setIsFollowing] = useState(true);
70
70
  const isFollowingRef = useRef(true);
71
71
  const rafIdRef = useRef(0);
72
+ // The last scrollTop THIS HOOK wrote (mount pin, rAF growth writes,
73
+ // jumpToLatest). The reader-vs-growth discriminator for the observer
74
+ // callbacks: content growth never moves scrollTop — only the reader
75
+ // does — so "not near bottom" while scrollTop still sits exactly on the
76
+ // system's own last write means content grew under a pin, not that the
77
+ // reader escaped.
78
+ const lastSystemScrollTopRef = useRef<number | null>(null);
72
79
 
73
80
  // Keep ref in sync with state so observer callbacks read the latest
74
81
  // value without re-subscribing on every state change.
@@ -85,6 +92,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
85
92
  // Establish initial position at the bottom so the first IO
86
93
  // callback sees the sentinel as intersecting.
87
94
  scroller.scrollTop = scroller.scrollHeight;
95
+ lastSystemScrollTopRef.current = scroller.scrollTop;
88
96
 
89
97
  const io = new IntersectionObserver(
90
98
  () => {
@@ -101,6 +109,18 @@ export function useAutoScroll(): UseAutoScrollReturn {
101
109
  const visible =
102
110
  el.scrollHeight - el.scrollTop - el.clientHeight <=
103
111
  NEAR_BOTTOM_MARGIN_PX;
112
+ // The growth-vs-reader discriminator (the write-time guard's
113
+ // mirror, found via the stigmer-cloud#267 pin-on-send suite): a
114
+ // delivery can measure geometry where content ALREADY grew below
115
+ // a system pin but the pin's ResizeObserver write has not run
116
+ // yet. Measured live that reads "not visible" — yet the reader
117
+ // never moved (scrollTop still sits exactly on the system's own
118
+ // last write). Disengaging here would make the imminent RO
119
+ // callback drop its write and strand the thread one row shy of
120
+ // the bottom with follow off. Only the READER may disengage.
121
+ if (!visible && el.scrollTop === lastSystemScrollTopRef.current) {
122
+ return;
123
+ }
104
124
  isFollowingRef.current = visible;
105
125
  setIsFollowing(visible);
106
126
  },
@@ -147,6 +167,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
147
167
  if (!el) return;
148
168
  if (scheduledAt !== null && el.scrollTop !== scheduledAt) return;
149
169
  el.scrollTop = el.scrollHeight;
170
+ lastSystemScrollTopRef.current = el.scrollTop;
150
171
  });
151
172
  });
152
173
  ro.observe(node);
@@ -157,6 +178,7 @@ export function useAutoScroll(): UseAutoScrollReturn {
157
178
  const el = scrollRef.current;
158
179
  if (!el) return;
159
180
  el.scrollTop = el.scrollHeight;
181
+ lastSystemScrollTopRef.current = el.scrollTop;
160
182
  // Eagerly set following — IO callback will confirm when the
161
183
  // sentinel becomes visible after the scroll.
162
184
  isFollowingRef.current = true;
@@ -165,3 +187,35 @@ export function useAutoScroll(): UseAutoScrollReturn {
165
187
 
166
188
  return { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest };
167
189
  }
190
+
191
+ /**
192
+ * Pins the thread to its latest content whenever `signal` changes — the
193
+ * scroll-on-send idiom (stigmer-cloud#267): each surface increments a
194
+ * monotonic counter at its own "the reader sent something" moment (an
195
+ * optimistic message appearing, a conversation reply dispatched, a HITL
196
+ * decision submitted), and the pin re-engages follow mode so the resulting
197
+ * content lands in view even for a reader who had deliberately scrolled up.
198
+ * WhatsApp convention: showing the result of the reader's OWN action is
199
+ * Nielsen #1 system-status feedback — distinct from INCOMING content, which
200
+ * must never move a scrolled-up reader (the F-09 posture, unchanged).
201
+ *
202
+ * <p>No pin fires on mount, on an `undefined` signal (surface opted out or
203
+ * prop not wired), or on the `undefined`→number transition (a prop
204
+ * appearing is not a send).
205
+ *
206
+ * @internal Not part of the public API.
207
+ */
208
+ export function usePinToLatestOnSignal(
209
+ signal: number | undefined,
210
+ pinToLatest: () => void,
211
+ ): void {
212
+ const previousRef = useRef(signal);
213
+ useEffect(() => {
214
+ const previous = previousRef.current;
215
+ previousRef.current = signal;
216
+ if (signal === undefined || previous === undefined || signal === previous) {
217
+ return;
218
+ }
219
+ pinToLatest();
220
+ }, [signal, pinToLatest]);
221
+ }
@@ -221,7 +221,7 @@ export function OrgPreferencesPanel({
221
221
  saving={isSavingMemoryFlag}
222
222
  readOnly={!canEdit}
223
223
  error={memoryFlagError}
224
- helperText="Allow agents to remember confirmed facts about members of this organization. Each member must also turn memory on in their own account preferences. Changes apply immediately."
224
+ helperText="Allow agents to remember confirmed facts about members of this organization. Each member must also turn memory on in their own account preferences; once a member has many memories, each conversation recalls their most relevant ones, shown on the execution. Changes apply immediately."
225
225
  />
226
226
 
227
227
  {!canEdit && (
@@ -25,7 +25,7 @@ export interface UseProviderStandingReturn {
25
25
  *
26
26
  * Platform-operator surface: the caller needs
27
27
  * `can_view_provider_standing` on `platform:stigmer`. Cloud-only — the
28
- * OSS Go server does not implement the controller.
28
+ * OSS server does not implement the controller.
29
29
  *
30
30
  * Pass `enabled: false` to skip fetching (stable no-op).
31
31
  *
@@ -29,8 +29,10 @@ export function MemorySection() {
29
29
  <p className="stg:text-muted-foreground stg:mb-6 stg:text-xs">
30
30
  Facts agents proposed to remember about you in this organization.
31
31
  Confirmed memories are shared with agents in your future sessions
32
- and appear in those executions&apos; records. Only you can see
33
- them here, and you can edit or delete any of them at any time.
32
+ and appear in those executions&apos; records; once you have many,
33
+ each conversation recalls the most relevant ones, shown on the
34
+ execution. Only you can see them here, and you can edit or delete
35
+ any of them at any time.
34
36
  </p>
35
37
 
36
38
  {!org ? (
@@ -31,7 +31,11 @@ import {
31
31
  } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
32
32
  import type { WorkflowExecutionEvent } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
33
33
  import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
34
- import { ExecutionPhase as AgentExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
34
+ import {
35
+ ApprovalAction,
36
+ ExecutionPhase as AgentExecutionPhase,
37
+ FileDecisionAction,
38
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
39
  import type { DerivedCostSummary } from "../../internal/store/workflow-execution-event-store";
36
40
  import { WorkflowExecutionEventStore } from "../../internal/store/workflow-execution-event-store";
37
41
  import { useWorkflowExecution } from "../useWorkflowExecution";
@@ -303,14 +307,29 @@ describe("WorkflowExecutionViewer in-thread HITL (S10/T06/T07)", () => {
303
307
  const actions = arrange();
304
308
  render(<WorkflowExecutionViewer executionId="wex_1" />);
305
309
 
306
- // Identity, not equivalence: the transcript submits through the SAME
307
- // workflow-level handlers every other surface uses, so in-flight and
308
- // error state can never fork — and never the child's agentExecution.*
309
- // path (checked exhaustively in WorkflowAgentCallTranscript.test.tsx).
310
+ // The transcript submits through the SAME workflow-level handlers
311
+ // every other surface uses, so in-flight and error state can never
312
+ // fork — and never the child's agentExecution.* path (checked
313
+ // exhaustively in WorkflowAgentCallTranscript.test.tsx). State fields
314
+ // pass by IDENTITY; the submit fns are asserted by DELEGATION since
315
+ // the thread's scroll-on-send wrapper (stigmer-cloud#267) pins the
316
+ // view before handing each call to the single instance — a per-card
317
+ // duplicate would still fail here, because the delegate IS the
318
+ // viewer's own spy.
319
+ expect(mockedTranscript.mock.calls.length).toBeGreaterThan(0);
310
320
  for (const call of mockedTranscript.mock.calls) {
311
321
  const hitl = call[0].hitl!;
312
- expect(hitl.submitApproval).toBe(actions.submitApproval);
313
- expect(hitl.submitFileDecision).toBe(actions.submitFileDecision);
322
+ hitl.submitApproval("tc-probe", ApprovalAction.APPROVE);
323
+ expect(actions.submitApproval).toHaveBeenLastCalledWith(
324
+ "tc-probe",
325
+ ApprovalAction.APPROVE,
326
+ );
327
+ hitl.submitFileDecision("agx-probe", "cs-probe", FileDecisionAction.APPROVE);
328
+ expect(actions.submitFileDecision).toHaveBeenLastCalledWith(
329
+ "agx-probe",
330
+ "cs-probe",
331
+ FileDecisionAction.APPROVE,
332
+ );
314
333
  expect(hitl.approvalSubmittingToolCallIds).toBe(
315
334
  actions.approvalSubmittingToolCallIds,
316
335
  );
@@ -383,19 +383,21 @@ describe("WorkflowExecutionViewer (center-column Thread|Graph toggle, S9)", () =
383
383
  it("a thread card offers no selection or drill-down gesture — the card IS the surface (T06)", () => {
384
384
  renderViewer();
385
385
 
386
- // build-report has kind 0 (unspecified summary disclosure): its header
387
- // is the expand gesture, never a selection.
388
- const header = screen.getByRole("button", { name: /^build-report/ });
389
- expect(header.getAttribute("aria-expanded")).toBe("false");
390
- expect(header.getAttribute("aria-pressed")).toBeNull();
386
+ // build-report renders from the snapshot fallback (no events): kind 0
387
+ // (unspecified summary disclosure) with nothing beyond what its
388
+ // header row already carries so it is a PLAIN row (stigmer#886): no
389
+ // expand chevron, no selection, no Inspect drill-down. (Expansion
390
+ // being card-local for content-bearing summary cards is pinned in
391
+ // WorkflowTaskThread.test.tsx.)
392
+ expect(
393
+ screen.queryByRole("button", { name: /^build-report/ }),
394
+ ).toBeNull();
391
395
  expect(
392
396
  screen.queryByRole("button", { name: "Inspect build-report" }),
393
397
  ).toBeNull();
394
398
 
395
- fireEvent.click(header);
396
-
397
- // Expansion is card-local; the panel never opens on a card gesture.
398
- expect(header.getAttribute("aria-expanded")).toBe("true");
399
+ // Clicking the row is inert: the panel never opens on a card gesture.
400
+ fireEvent.click(screen.getByText("build-report"));
399
401
  expect(screen.queryByRole("radio", { name: "Inspect" })).toBeNull();
400
402
  });
401
403
 
@@ -451,6 +451,48 @@ describe("WorkflowTaskThread", () => {
451
451
  expect(screen.getByText(/at dial tcp 10\.0\.0\.1:443/)).toBeTruthy();
452
452
  });
453
453
 
454
+ it("a summary-kind card with a body-less detail renders no expand gesture (stigmer#886)", () => {
455
+ // A settled wait card with nothing the header cannot carry — no retry,
456
+ // no usage, no error, no I/O. Since R6-6 moved Status/Duration onto
457
+ // the header, its detail body would be empty, so the header is a plain
458
+ // row: offering a chevron that reveals nothing is worse than none.
459
+ render(
460
+ <WorkflowTaskThread
461
+ taskStates={statesOf(
462
+ taskState({ taskName: "blocking_wait", taskKind: WorkflowTaskKind.wait }),
463
+ )}
464
+ totalTasks={1}
465
+ isRunning={false}
466
+ />,
467
+ );
468
+
469
+ expect(screen.getByText("blocking_wait")).toBeTruthy();
470
+ expect(screen.queryByRole("button", { name: /^blocking_wait/ })).toBeNull();
471
+ });
472
+
473
+ it("the expand gesture returns the moment the detail body has content (stigmer#886)", () => {
474
+ // The same wait card with a retry behind it: the Attempt row gives the
475
+ // body content, so the header is the expand gesture again.
476
+ render(
477
+ <WorkflowTaskThread
478
+ taskStates={statesOf(
479
+ taskState({
480
+ taskName: "blocking_wait",
481
+ taskKind: WorkflowTaskKind.wait,
482
+ attemptNumber: 2,
483
+ }),
484
+ )}
485
+ totalTasks={1}
486
+ isRunning={false}
487
+ />,
488
+ );
489
+
490
+ const header = screen.getByRole("button", { name: /^blocking_wait/ });
491
+ fireEvent.click(header);
492
+ expect(screen.getByText("Attempt")).toBeTruthy();
493
+ expect(screen.getByText("2")).toBeTruthy();
494
+ });
495
+
454
496
  it("shows the full error in a summary-kind card's expanded body", () => {
455
497
  render(
456
498
  <WorkflowTaskThread
@@ -471,9 +513,15 @@ describe("WorkflowTaskThread", () => {
471
513
  });
472
514
 
473
515
  it("keeps a card's expanded state across streaming updates (no remount)", () => {
516
+ // The wait card carries input so its detail body has content — a
517
+ // body-less summary card has no expand gesture at all (stigmer#886).
474
518
  const running = () =>
475
519
  statesOf(
476
- taskState({ taskName: "settled", taskKind: WorkflowTaskKind.wait }),
520
+ taskState({
521
+ taskName: "settled",
522
+ taskKind: WorkflowTaskKind.wait,
523
+ inputSummary: { duration: "10s" },
524
+ }),
477
525
  taskState({ taskName: "live", status: "running", messagesCount: 1 }),
478
526
  );
479
527
  const { rerender } = render(
@@ -495,7 +543,11 @@ describe("WorkflowTaskThread", () => {
495
543
  rerender(
496
544
  <WorkflowTaskThread
497
545
  taskStates={statesOf(
498
- taskState({ taskName: "settled", taskKind: WorkflowTaskKind.wait }),
546
+ taskState({
547
+ taskName: "settled",
548
+ taskKind: WorkflowTaskKind.wait,
549
+ inputSummary: { duration: "10s" },
550
+ }),
499
551
  taskState({ taskName: "live", status: "running", messagesCount: 2 }),
500
552
  )}
501
553
  totalTasks={2}
@@ -286,21 +286,55 @@ describe("per-kind preview lines", () => {
286
286
  overrides: { agentSlug: "blog-writer", messagesCount: 12, toolCallsCount: 5 },
287
287
  expected: "blog-writer · 12 msgs · 5 tools",
288
288
  },
289
+ {
290
+ label: "switch_case settled on a target branch (R6-5)",
291
+ kind: WorkflowTaskKind.switch_case,
292
+ overrides: { outputSummary: { __flow_directive__: "approved-path" } },
293
+ expected: "→ approved-path",
294
+ },
295
+ {
296
+ label: "switch_case settled on a termination directive",
297
+ kind: WorkflowTaskKind.switch_case,
298
+ overrides: { outputSummary: { __flow_directive__: "exit" } },
299
+ expected: "→ exit",
300
+ },
301
+ {
302
+ label: "try_catch recovered via catch retry (R6-5)",
303
+ kind: WorkflowTaskKind.try_catch,
304
+ overrides: { attemptNumber: 3, outputSummary: { result: "ok" } },
305
+ expected: "recovered after 3 attempts",
306
+ },
307
+ {
308
+ label: "try_catch settled block result (first-attempt success)",
309
+ kind: WorkflowTaskKind.try_catch,
310
+ overrides: { outputSummary: { result: 42 } },
311
+ expected: "42",
312
+ },
289
313
  ])("$label", ({ kind, overrides, expected }) => {
290
314
  const { previewLine } = resolveTaskPreview(state({ taskKind: kind, ...overrides }));
291
315
  expect(previewLine).toBe(expected);
292
316
  });
293
317
 
294
- // Kinds that deliberately stay status-only: control flow (branch/fork
295
- // detail needs graph topology the thread lacks deferred), raise_error
296
- // (the failure precedence carries the message), the invocation kinds
297
- // (no verified output envelope — backend follow-up), and unspecified
298
- // (the snapshot fallback).
318
+ // switch_case degrades to status-only when no case matched: the executor
319
+ // returns no directive, so there is honestly nothing to say (R6-5).
320
+ it("switch_case yields an empty line without a flow directive", () => {
321
+ const { previewLine } = resolveTaskPreview(
322
+ state({
323
+ taskKind: WorkflowTaskKind.switch_case,
324
+ outputSummary: { some: "data" },
325
+ }),
326
+ );
327
+ expect(previewLine).toBe("");
328
+ });
329
+
330
+ // Kinds that deliberately stay status-only: remaining control flow
331
+ // (for/fork detail needs graph topology the thread lacks — deferred),
332
+ // raise_error (the failure precedence carries the message; its preview
333
+ // BODY carries the detail), the invocation kinds (no verified output
334
+ // envelope — backend follow-up), and unspecified (the snapshot fallback).
299
335
  it.each([
300
- WorkflowTaskKind.switch_case,
301
336
  WorkflowTaskKind.for_each,
302
337
  WorkflowTaskKind.fork,
303
- WorkflowTaskKind.try_catch,
304
338
  WorkflowTaskKind.raise_error,
305
339
  WorkflowTaskKind.http_call,
306
340
  WorkflowTaskKind.grpc_call,
@@ -396,9 +430,12 @@ type JsonValueLike = Parameters<typeof valueSnippet>[0];
396
430
  describe("defaultDisclosureForKind", () => {
397
431
  // T05 (DD-T05-5): every kind whose output CAN matter is a preview kind —
398
432
  // the showBody gate keeps output-less cards as one-line rows, so preview
399
- // disclosure costs nothing until the runner writes real output. Only
400
- // genuinely body-less kinds (control flow, wait/listen, unspecified)
401
- // remain summary.
433
+ // disclosure costs nothing until the runner writes real output. R6-5
434
+ // adds try_catch (its output is the block's settled result) and
435
+ // raise_error (always fails; the preview body IS the always-visible
436
+ // failure detail). Only genuinely body-less kinds remain summary —
437
+ // including switch_case DELIBERATELY: its whole content is the one-word
438
+ // directive on its preview line; a body would only repeat it.
402
439
  it.each([
403
440
  [WorkflowTaskKind.transform, "preview"],
404
441
  [WorkflowTaskKind.validate, "preview"],
@@ -413,13 +450,13 @@ describe("defaultDisclosureForKind", () => {
413
450
  [WorkflowTaskKind.grpc_call, "preview"],
414
451
  [WorkflowTaskKind.activity_call, "preview"],
415
452
  [WorkflowTaskKind.run_workflow, "preview"],
453
+ [WorkflowTaskKind.try_catch, "preview"],
454
+ [WorkflowTaskKind.raise_error, "preview"],
416
455
  [WorkflowTaskKind.wait, "summary"],
417
456
  [WorkflowTaskKind.switch_case, "summary"],
418
457
  [WorkflowTaskKind.fork, "summary"],
419
- [WorkflowTaskKind.try_catch, "summary"],
420
458
  [WorkflowTaskKind.for_each, "summary"],
421
459
  [WorkflowTaskKind.listen, "summary"],
422
- [WorkflowTaskKind.raise_error, "summary"],
423
460
  [WorkflowTaskKind.workflow_task_kind_unspecified, "summary"],
424
461
  ] as const)("kind %d defaults to %s", (kind, expected) => {
425
462
  expect(defaultDisclosureForKind(kind)).toBe(expected);
@@ -0,0 +1,145 @@
1
+ // Scroll-on-send wiring pins for WorkflowTaskThread (stigmer-cloud#267):
2
+ // the workflow surface's send-analog is a HITL decision submission —
3
+ // submitting pins the thread (default-ON, opt-out via
4
+ // `scrollOnSend={false}`) BEFORE delegating, so the unblocked run's
5
+ // continuation lands in view. The real scroll mechanics are pinned in
6
+ // `internal/__tests__/useAutoScroll.layout.test.tsx`; this file pins the
7
+ // hitl-wrapper wiring through a spied `jumpToLatest`.
8
+
9
+ import { describe, it, expect, vi, afterEach } from "vitest";
10
+ import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
11
+ import { create } from "@bufbuild/protobuf";
12
+ import { WorkflowTaskKind } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/enum_pb";
13
+ import { ApprovalRequestedPayloadSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/event_pb";
14
+ import type { DerivedTaskState } from "../../internal/store/workflow-execution-event-store";
15
+
16
+ const jumpToLatestSpy = vi.fn();
17
+
18
+ // Replaces only the scroll machine (browser observers happy-dom lacks) with
19
+ // a spied jumpToLatest — the wrapper under test calls it directly.
20
+ vi.mock("../../internal/useAutoScroll", async (importOriginal) => {
21
+ const original =
22
+ await importOriginal<typeof import("../../internal/useAutoScroll")>();
23
+ return {
24
+ ...original,
25
+ useAutoScroll: () => ({
26
+ scrollRef: { current: null },
27
+ sentinelRef: { current: null },
28
+ contentRef: () => {},
29
+ isFollowing: true,
30
+ jumpToLatest: jumpToLatestSpy,
31
+ }),
32
+ };
33
+ });
34
+
35
+ import {
36
+ WorkflowTaskThread,
37
+ type WorkflowThreadHitl,
38
+ } from "../thread/WorkflowTaskThread";
39
+
40
+ afterEach(() => {
41
+ cleanup();
42
+ jumpToLatestSpy.mockClear();
43
+ });
44
+
45
+ function makeHitl(): WorkflowThreadHitl {
46
+ return {
47
+ submitApproval: vi.fn(),
48
+ approvalSubmittingToolCallIds: new Set<string>(),
49
+ approvalErrorsByToolCallId: new Map<string, Error>(),
50
+ submitTaskApproval: vi.fn(),
51
+ taskApprovalSubmittingTaskNames: new Set<string>(),
52
+ taskApprovalErrorsByTaskName: new Map<string, Error>(),
53
+ submitFileDecision: vi.fn(),
54
+ fileDecisionSubmittingKeys: new Set<string>(),
55
+ fileDecisionErrorsByKey: new Map<string, Error>(),
56
+ };
57
+ }
58
+
59
+ /** A gating human_input task carrying its captured request payload. */
60
+ function gatedHumanInput(taskName: string): DerivedTaskState {
61
+ return {
62
+ taskName,
63
+ taskKind: WorkflowTaskKind.human_input,
64
+ status: "waiting_approval",
65
+ durationMs: 0,
66
+ costMicros: 0n,
67
+ tokensUsed: 0n,
68
+ attemptNumber: 1,
69
+ error: "",
70
+ childExecutionId: "",
71
+ agentSlug: "",
72
+ currentToolName: "",
73
+ messagesCount: 0,
74
+ toolCallsCount: 0,
75
+ inputSummary: null,
76
+ outputSummary: null,
77
+ approvalRequest: create(ApprovalRequestedPayloadSchema, {
78
+ prompt: "Ship the release?",
79
+ outcomes: [
80
+ { name: "ship", label: "Ship It" },
81
+ { name: "hold", label: "Hold" },
82
+ ],
83
+ }),
84
+ approvalResolution: null,
85
+ } as DerivedTaskState;
86
+ }
87
+
88
+ function statesOf(...states: DerivedTaskState[]): ReadonlyMap<string, DerivedTaskState> {
89
+ return new Map(states.map((s) => [s.taskName, s]));
90
+ }
91
+
92
+ function submitShipIt(): void {
93
+ const form = screen.getByRole("form", {
94
+ name: "Approval decision for review-gate",
95
+ });
96
+ fireEvent.click(within(form).getByRole("button", { name: "Ship It" }));
97
+ }
98
+
99
+ describe("WorkflowTaskThread — scroll-on-send (stigmer-cloud#267)", () => {
100
+ it("pins the thread when a HITL decision is submitted, then delegates to the workflow-level submit (default-on)", () => {
101
+ const hitl = makeHitl();
102
+ render(
103
+ <WorkflowTaskThread
104
+ taskStates={statesOf(gatedHumanInput("review-gate"))}
105
+ totalTasks={1}
106
+ isRunning
107
+ hitl={hitl}
108
+ />,
109
+ );
110
+ expect(jumpToLatestSpy).not.toHaveBeenCalled();
111
+
112
+ submitShipIt();
113
+
114
+ expect(jumpToLatestSpy).toHaveBeenCalledTimes(1);
115
+ expect(hitl.submitTaskApproval).toHaveBeenCalledWith(
116
+ "review-gate",
117
+ "ship",
118
+ undefined,
119
+ undefined,
120
+ );
121
+ });
122
+
123
+ it("never pins with scrollOnSend={false} — the decision still routes through unchanged", () => {
124
+ const hitl = makeHitl();
125
+ render(
126
+ <WorkflowTaskThread
127
+ taskStates={statesOf(gatedHumanInput("review-gate"))}
128
+ totalTasks={1}
129
+ isRunning
130
+ hitl={hitl}
131
+ scrollOnSend={false}
132
+ />,
133
+ );
134
+
135
+ submitShipIt();
136
+
137
+ expect(jumpToLatestSpy).not.toHaveBeenCalled();
138
+ expect(hitl.submitTaskApproval).toHaveBeenCalledWith(
139
+ "review-gate",
140
+ "ship",
141
+ undefined,
142
+ undefined,
143
+ );
144
+ });
145
+ });