@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.
- package/README.md +1 -3
- package/conversation/ConversationTimelineView.d.ts +15 -1
- package/conversation/ConversationTimelineView.d.ts.map +1 -1
- package/conversation/ConversationTimelineView.js +3 -2
- package/conversation/ConversationTimelineView.js.map +1 -1
- package/conversation/ConversationsWorkbench.d.ts +14 -1
- package/conversation/ConversationsWorkbench.d.ts.map +1 -1
- package/conversation/ConversationsWorkbench.js +11 -2
- package/conversation/ConversationsWorkbench.js.map +1 -1
- package/execution/MessageThread.d.ts +33 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +44 -6
- package/execution/MessageThread.js.map +1 -1
- package/execution/RecalledMemoriesCard.d.ts +47 -0
- package/execution/RecalledMemoriesCard.d.ts.map +1 -0
- package/execution/RecalledMemoriesCard.js +58 -0
- package/execution/RecalledMemoriesCard.js.map +1 -0
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/identity-account/AccountPreferencesPanel.js +1 -1
- package/identity-account/AccountPreferencesPanel.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -1
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/useAutoScroll.d.ts +18 -0
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +50 -0
- package/internal/useAutoScroll.js.map +1 -1
- package/organization/OrgPreferencesPanel.js +1 -1
- package/organization/OrgPreferencesPanel.js.map +1 -1
- package/package.json +4 -4
- package/settings/MemorySection.d.ts.map +1 -1
- package/settings/MemorySection.js +1 -1
- package/settings/MemorySection.js.map +1 -1
- package/src/conversation/ConversationTimelineView.tsx +17 -1
- package/src/conversation/ConversationsWorkbench.tsx +25 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
- package/src/execution/MessageThread.tsx +75 -3
- package/src/execution/RecalledMemoriesCard.tsx +164 -0
- package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
- package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
- package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
- package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
- package/src/execution/index.ts +2 -0
- package/src/identity-account/AccountPreferencesPanel.tsx +1 -1
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +5 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
- package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
- package/src/internal/useAutoScroll.ts +54 -0
- package/src/organization/OrgPreferencesPanel.tsx +1 -1
- package/src/settings/MemorySection.tsx +4 -2
- package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
- package/src/workflow/__tests__/task-presentation.test.ts +49 -12
- package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
- package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
- package/src/workflow/thread/task-presentation.ts +52 -7
- package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
- package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
- package/workflow/thread/WorkflowTaskThread.js +33 -25
- package/workflow/thread/WorkflowTaskThread.js.map +1 -1
- package/workflow/thread/task-presentation.d.ts.map +1 -1
- package/workflow/thread/task-presentation.js +50 -7
- 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' records
|
|
33
|
-
|
|
32
|
+
and appear in those executions' 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 {
|
|
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
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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
|
-
|
|
313
|
-
expect(
|
|
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
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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.
|
|
400
|
-
//
|
|
401
|
-
//
|
|
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" ?
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
<
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
|
129
|
-
* snapshot fallback's `unspecified
|
|
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
|
-
//
|
|
223
|
-
// thread does not have — deferred), raise_error (the
|
|
224
|
-
// precedence already shows the message
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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
|
}
|