@stigmer/react 3.12.8 → 3.12.9

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 (71) 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/settings/MemorySection.d.ts.map +1 -1
  40. package/settings/MemorySection.js +1 -1
  41. package/settings/MemorySection.js.map +1 -1
  42. package/src/conversation/ConversationTimelineView.tsx +17 -1
  43. package/src/conversation/ConversationsWorkbench.tsx +25 -0
  44. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
  45. package/src/execution/MessageThread.tsx +75 -3
  46. package/src/execution/RecalledMemoriesCard.tsx +164 -0
  47. package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
  48. package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
  49. package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
  50. package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
  51. package/src/execution/index.ts +2 -0
  52. package/src/identity-account/AccountPreferencesPanel.tsx +1 -1
  53. package/src/index.ts +3 -0
  54. package/src/internal/VirtualizedThread.tsx +5 -0
  55. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
  56. package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
  57. package/src/internal/useAutoScroll.ts +54 -0
  58. package/src/organization/OrgPreferencesPanel.tsx +1 -1
  59. package/src/settings/MemorySection.tsx +4 -2
  60. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
  61. package/src/workflow/__tests__/task-presentation.test.ts +49 -12
  62. package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
  63. package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
  64. package/src/workflow/thread/task-presentation.ts +52 -7
  65. package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
  66. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  67. package/workflow/thread/WorkflowTaskThread.js +33 -25
  68. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  69. package/workflow/thread/task-presentation.d.ts.map +1 -1
  70. package/workflow/thread/task-presentation.js +50 -7
  71. 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 && (
@@ -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
  );
@@ -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
+ });
@@ -92,6 +92,19 @@ export interface WorkflowTaskThreadProps {
92
92
  * summaries already on the items.
93
93
  */
94
94
  readonly taskSnapshotsByName?: ReadonlyMap<string, WorkflowTask>;
95
+ /**
96
+ * Scroll to the latest content when the reader submits a HITL decision
97
+ * from a scrolled-up position (stigmer-cloud#267) — the workflow surface's
98
+ * send-analog: approving a gate, deciding a file review, or answering a
99
+ * task-level human_input gate re-engages follow mode, so the run's
100
+ * continuation lands in view. Incoming task activity is unaffected — it
101
+ * still never moves a scrolled-up reader. Default `true` on all three SDK
102
+ * thread surfaces at once (the ratified DD-011 divergence — cross-surface
103
+ * consistency is the point); set `false` to keep today's behavior.
104
+ *
105
+ * @default true
106
+ */
107
+ readonly scrollOnSend?: boolean;
95
108
  /** Additional CSS class names for the root container. */
96
109
  readonly className?: string;
97
110
  }
@@ -135,12 +148,38 @@ export const WorkflowTaskThread = memo(function WorkflowTaskThread({
135
148
  onNavigateToAgentExecution,
136
149
  hitl,
137
150
  taskSnapshotsByName,
151
+ scrollOnSend = true,
138
152
  className,
139
153
  }: WorkflowTaskThreadProps) {
140
154
  const { items, progress } = useWorkflowThreadItems(taskStates, totalTasks);
141
155
  const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
142
156
  useAutoScroll();
143
157
 
158
+ // Scroll-on-send (stigmer-cloud#267): submitting a decision pins the
159
+ // thread before delegating, so the unblocked run's continuation lands in
160
+ // view. The wrapper's identity moves with the bundle's — deliberate: the
161
+ // bundle re-materializes exactly when its gate state flips (see the
162
+ // render-site note below), and a stale wrapper would leak stale state
163
+ // fields. `jumpToLatest` is referentially stable (useAutoScroll pins it).
164
+ const threadHitl = useMemo<WorkflowThreadHitl | undefined>(() => {
165
+ if (!hitl || !scrollOnSend) return hitl;
166
+ return {
167
+ ...hitl,
168
+ submitApproval: (...args: Parameters<WorkflowThreadHitl["submitApproval"]>) => {
169
+ jumpToLatest();
170
+ return hitl.submitApproval(...args);
171
+ },
172
+ submitTaskApproval: (...args: Parameters<WorkflowThreadHitl["submitTaskApproval"]>) => {
173
+ jumpToLatest();
174
+ return hitl.submitTaskApproval(...args);
175
+ },
176
+ submitFileDecision: (...args: Parameters<WorkflowThreadHitl["submitFileDecision"]>) => {
177
+ jumpToLatest();
178
+ return hitl.submitFileDecision(...args);
179
+ },
180
+ };
181
+ }, [hitl, scrollOnSend, jumpToLatest]);
182
+
144
183
  return (
145
184
  <div className={cn("stg:relative stg:flex stg:h-full stg:min-h-0 stg:flex-col", className)}>
146
185
  <ThreadProgressHeader progress={progress} isRunning={isRunning} />
@@ -164,7 +203,7 @@ export const WorkflowTaskThread = memo(function WorkflowTaskThread({
164
203
  // would re-render the whole column per spinner tick. Scoped
165
204
  // here, non-gating cards keep `undefined === undefined` and
166
205
  // their memo bails hold.
167
- hitl={item.status === "waiting_approval" ? hitl : undefined}
206
+ hitl={item.status === "waiting_approval" ? threadHitl : undefined}
168
207
  snapshot={taskSnapshotsByName?.get(item.taskName)}
169
208
  />
170
209
  ))
@@ -412,6 +451,10 @@ const ThreadTaskCard = memo(function ThreadTaskCard({
412
451
  </pre>
413
452
  </BoundedContent>
414
453
  )}
454
+ {/* Carries the card's (wrapped) bundle: a child-gate decision
455
+ inside this transcript is a send too, and the gating card is
456
+ the thread's active tail — pinning the outer thread keeps
457
+ the transcript's continuation in view as it streams. */}
415
458
  <WorkflowAgentCallTranscript
416
459
  childExecutionId={item.childExecutionId}
417
460
  agentSlug={item.agentSlug || undefined}
@@ -596,10 +639,11 @@ function ThreadTaskDetail({
596
639
  readonly inputIO: TaskDetailIO | null;
597
640
  readonly outputIO: TaskDetailIO | null;
598
641
  }) {
599
- const rows: Array<[string, string]> = [["Status", statusLabel(item.status)]];
600
- if (item.durationMs > 0) {
601
- rows.push(["Duration", formatMetaChips({ durationMs: item.durationMs }) ?? ""]);
602
- }
642
+ // No Status/Duration rows (R6-6): the card header is the single source
643
+ // for both — the status glyph and the duration meta chip. The detail
644
+ // body carries only what the header cannot: attempt count, usage, the
645
+ // agent slug.
646
+ const rows: Array<[string, string]> = [];
603
647
  if (item.attemptNumber > 1) rows.push(["Attempt", String(item.attemptNumber)]);
604
648
  const costChip = formatMetaChips({
605
649
  costMicros: item.costMicros,
@@ -612,14 +656,16 @@ function ThreadTaskDetail({
612
656
 
613
657
  return (
614
658
  <div className="stg:flex stg:flex-col stg:gap-2">
615
- <dl className="stg:grid stg:grid-cols-[auto_1fr] stg:gap-x-4 stg:gap-y-1 stg:text-xs">
616
- {rows.map(([label, value]) => (
617
- <div key={label} className="stg:contents">
618
- <dt className="stg:text-muted-foreground">{label}</dt>
619
- <dd className="stg:text-foreground">{value}</dd>
620
- </div>
621
- ))}
622
- </dl>
659
+ {rows.length > 0 && (
660
+ <dl className="stg:grid stg:grid-cols-[auto_1fr] stg:gap-x-4 stg:gap-y-1 stg:text-xs">
661
+ {rows.map(([label, value]) => (
662
+ <div key={label} className="stg:contents">
663
+ <dt className="stg:text-muted-foreground">{label}</dt>
664
+ <dd className="stg:text-foreground">{value}</dd>
665
+ </div>
666
+ ))}
667
+ </dl>
668
+ )}
623
669
 
624
670
  {item.error && (
625
671
  <BoundedContent>
@@ -637,25 +683,6 @@ function ThreadTaskDetail({
637
683
  );
638
684
  }
639
685
 
640
- function statusLabel(status: WorkflowThreadItem["status"]): string {
641
- switch (status) {
642
- case "waiting_approval":
643
- return "Waiting for approval";
644
- case "retrying":
645
- return "Retrying";
646
- case "running":
647
- return "Running";
648
- case "completed":
649
- return "Completed";
650
- case "failed":
651
- return "Failed";
652
- case "skipped":
653
- return "Skipped";
654
- case "pending":
655
- return "Pending";
656
- }
657
- }
658
-
659
686
  // ---------------------------------------------------------------------------
660
687
  // Status glyph — the shared thread-card set, colored by status token (T05)
661
688
  // ---------------------------------------------------------------------------
@@ -124,9 +124,18 @@ export function getTaskPresenter(
124
124
  * the output-envelope follow-up standardizes them (DD-T04-3) — stays a
125
125
  * clean one-line row with zero cost. `set_vars` earns its place from live
126
126
  * data: the runner writes the seeded variables to task output (T05, R2-5).
127
+ * `try_catch` earns it the same way: its output is the try (or catch.do)
128
+ * block's settled result — the `transform` class. `raise_error` earns it
129
+ * from the OTHER `showBody` arm: it always fails, and the preview body
130
+ * renders the full failure detail without a click (R6-5).
127
131
  *
128
- * Only genuinely body-less kinds (control flow, `wait`/`listen`, and the
129
- * snapshot fallback's `unspecified`) stay compact summary rows.
132
+ * Only genuinely body-less kinds stay compact summary rows: `wait`/
133
+ * `listen`, the snapshot fallback's `unspecified`, and the remaining
134
+ * control flow — including `switch_case`, DELIBERATELY (an R6-5
135
+ * refinement): its entire settled content is the one-word flow directive,
136
+ * carried by its always-visible preview line below; a body would only
137
+ * re-render the raw `{__flow_directive__}` marker, failing the "does the
138
+ * body carry content the one-line row cannot?" rule.
130
139
  */
131
140
  const PREVIEW_KINDS: ReadonlySet<WorkflowTaskKind> = new Set([
132
141
  WorkflowTaskKind.transform,
@@ -142,6 +151,8 @@ const PREVIEW_KINDS: ReadonlySet<WorkflowTaskKind> = new Set([
142
151
  WorkflowTaskKind.grpc_call,
143
152
  WorkflowTaskKind.activity_call,
144
153
  WorkflowTaskKind.run_workflow,
154
+ WorkflowTaskKind.try_catch,
155
+ WorkflowTaskKind.raise_error,
145
156
  ]);
146
157
 
147
158
  /** Default disclosure mode for a task kind. */
@@ -218,12 +229,17 @@ function defaultPreviewLine(state: DerivedTaskState): string | null {
218
229
  return waitLine(state);
219
230
  case WorkflowTaskKind.listen:
220
231
  return listenLine(state);
232
+ case WorkflowTaskKind.switch_case:
233
+ return switchCaseLine(state.outputSummary);
234
+ case WorkflowTaskKind.try_catch:
235
+ return tryCatchLine(state);
221
236
  default:
222
- // Control flow (branch-taken / fork progress need graph topology the
223
- // thread does not have — deferred), raise_error (the failure
224
- // precedence already shows the message), the invocation kinds
225
- // (pending the backend output-envelope follow-up), and the snapshot
226
- // fallback's `unspecified` all stay status-only.
237
+ // Remaining control flow (for_each / fork progress need graph
238
+ // topology the thread does not have — deferred), raise_error (the
239
+ // failure precedence already shows the message; its preview body
240
+ // carries the full detail), the invocation kinds (pending the
241
+ // backend output-envelope follow-up), and the snapshot fallback's
242
+ // `unspecified` all stay status-only.
227
243
  return null;
228
244
  }
229
245
  }
@@ -354,6 +370,35 @@ function listenLine(state: DerivedTaskState): string | null {
354
370
  return null;
355
371
  }
356
372
 
373
+ /**
374
+ * `→ approved-path` / `→ exit` — the flow directive the switch settled on
375
+ * (R6-5). The switch executor's only output is the `__flow_directive__`
376
+ * marker carrying the matched case's `then` (a target task name, or the
377
+ * `continue`/`end`/`exit` terminals), and `do-executor` writes raw task
378
+ * output to the event summary — so the branch taken is already on the
379
+ * wire. No case matched means no output: status-only, honestly.
380
+ */
381
+ function switchCaseLine(output: JsonObject | null): string | null {
382
+ const directive = asString(output?.["__flow_directive__"]);
383
+ return directive ? `\u2192 ${directive}` : null;
384
+ }
385
+
386
+ /**
387
+ * `recovered after 2 attempts` / snippet of the settled block result
388
+ * (R6-5). The try task's own `task_retrying` events drive
389
+ * `attemptNumber`, so a completion above attempt 1 IS a catch-retry
390
+ * recovery. A catch.do-path recovery (no retry configured) is NOT
391
+ * distinguishable from plain success in the emitted data — deliberately
392
+ * unmarked: adding a marker to task output would leak presentation into
393
+ * workflow dataflow (`processTaskOutput` consumes it downstream).
394
+ */
395
+ function tryCatchLine(state: DerivedTaskState): string | null {
396
+ if (state.status === "completed" && state.attemptNumber > 1) {
397
+ return `recovered after ${state.attemptNumber} attempts`;
398
+ }
399
+ return state.outputSummary ? valueSnippet(state.outputSummary) : null;
400
+ }
401
+
357
402
  // ---------------------------------------------------------------------------
358
403
  // Struct-reading helpers (defensive by construction)
359
404
  // ---------------------------------------------------------------------------
@@ -44,6 +44,19 @@ export interface WorkflowTaskThreadProps {
44
44
  * summaries already on the items.
45
45
  */
46
46
  readonly taskSnapshotsByName?: ReadonlyMap<string, WorkflowTask>;
47
+ /**
48
+ * Scroll to the latest content when the reader submits a HITL decision
49
+ * from a scrolled-up position (stigmer-cloud#267) — the workflow surface's
50
+ * send-analog: approving a gate, deciding a file review, or answering a
51
+ * task-level human_input gate re-engages follow mode, so the run's
52
+ * continuation lands in view. Incoming task activity is unaffected — it
53
+ * still never moves a scrolled-up reader. Default `true` on all three SDK
54
+ * thread surfaces at once (the ratified DD-011 divergence — cross-surface
55
+ * consistency is the point); set `false` to keep today's behavior.
56
+ *
57
+ * @default true
58
+ */
59
+ readonly scrollOnSend?: boolean;
47
60
  /** Additional CSS class names for the root container. */
48
61
  readonly className?: string;
49
62
  }