@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.
- 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/provider-standing/useProviderStanding.d.ts +1 -1
- package/provider-standing/useProviderStanding.js +1 -1
- 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/provider-standing/useProviderStanding.ts +1 -1
- package/src/settings/MemorySection.tsx +4 -2
- package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
- package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +11 -9
- package/src/workflow/__tests__/WorkflowTaskThread.test.tsx +54 -2
- 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 +102 -50
- 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 +62 -31
- 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 && (
|
|
@@ -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
|
|
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' 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
|
);
|
|
@@ -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
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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
|
-
//
|
|
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
|
+
});
|