@stigmer/react 3.2.2 → 3.2.3
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/agent/AgentDetailView.d.ts.map +1 -1
- package/agent/AgentDetailView.js +6 -3
- package/agent/AgentDetailView.js.map +1 -1
- package/execution/index.d.ts +1 -2
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +0 -1
- package/execution/index.js.map +1 -1
- package/execution/useLiveAgentExecution.d.ts +17 -1
- package/execution/useLiveAgentExecution.d.ts.map +1 -1
- package/execution/useLiveAgentExecution.js +19 -4
- package/execution/useLiveAgentExecution.js.map +1 -1
- package/index.d.ts +5 -5
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/useInViewport.d.ts +24 -0
- package/internal/useInViewport.d.ts.map +1 -0
- package/internal/useInViewport.js +49 -0
- package/internal/useInViewport.js.map +1 -0
- package/library/VisibilitySelector.d.ts +22 -6
- package/library/VisibilitySelector.d.ts.map +1 -1
- package/library/VisibilitySelector.js +12 -4
- package/library/VisibilitySelector.js.map +1 -1
- package/library/index.d.ts +1 -1
- package/library/index.d.ts.map +1 -1
- package/library/index.js.map +1 -1
- package/mcp-server/McpServerDetailView.d.ts.map +1 -1
- package/mcp-server/McpServerDetailView.js +4 -3
- package/mcp-server/McpServerDetailView.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +8 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/audience.d.ts +6 -1
- package/session/audience.d.ts.map +1 -1
- package/skill/SkillDetailView.d.ts.map +1 -1
- package/skill/SkillDetailView.js +4 -3
- package/skill/SkillDetailView.js.map +1 -1
- package/src/access/__tests__/useManageAccess.test.tsx +104 -0
- package/src/agent/AgentDetailView.tsx +9 -3
- package/src/execution/__tests__/useLiveAgentExecution.test.tsx +125 -0
- package/src/execution/index.ts +4 -7
- package/src/execution/useLiveAgentExecution.ts +41 -3
- package/src/index.ts +4 -5
- package/src/internal/__tests__/useInViewport.test.tsx +115 -0
- package/src/internal/useInViewport.ts +63 -0
- package/src/library/VisibilitySelector.tsx +50 -9
- package/src/library/__tests__/VisibilitySelector.test.tsx +38 -0
- package/src/library/index.ts +4 -1
- package/src/mcp-server/McpServerDetailView.tsx +7 -3
- package/src/session/SessionViewer.tsx +7 -1
- package/src/session/__tests__/audienceWiring.test.tsx +31 -1
- package/src/session/audience.ts +6 -1
- package/src/skill/SkillDetailView.tsx +7 -3
- package/src/workflow/WorkflowAgentCallTranscript.tsx +279 -0
- package/src/workflow/WorkflowDetailView.tsx +7 -3
- package/src/workflow/WorkflowExecutionViewer.tsx +20 -77
- package/src/workflow/WorkflowTaskApprovalSummary.tsx +45 -8
- package/src/workflow/__tests__/WorkflowAgentCallTranscript.liveregion.test.tsx +124 -0
- package/src/workflow/__tests__/{WorkflowAgentExecutionDocument.test.tsx → WorkflowAgentCallTranscript.test.tsx} +146 -121
- package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +81 -220
- package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +17 -16
- package/src/workflow/__tests__/WorkflowTaskApprovalSummary.test.tsx +52 -2
- package/src/workflow/__tests__/WorkflowTaskThread.test.tsx +168 -185
- package/src/workflow/__tests__/agent-call-live-experience.test.ts +4 -4
- package/src/workflow/__tests__/task-approval.test.ts +119 -0
- package/src/workflow/__tests__/task-presentation.test.ts +35 -1
- package/src/workflow/__tests__/useWorkflowExecutionPanel.test.ts +0 -57
- package/src/workflow/index.ts +6 -3
- package/src/workflow/task-detail/index.ts +3 -0
- package/src/workflow/task-detail/task-approval.ts +95 -1
- package/src/workflow/thread/WorkflowTaskThread.tsx +96 -252
- package/src/workflow/thread/task-presentation.ts +8 -2
- package/src/workflow/useWorkflowExecutionPanel.ts +0 -47
- package/workflow/WorkflowAgentCallTranscript.d.ts +87 -0
- package/workflow/WorkflowAgentCallTranscript.d.ts.map +1 -0
- package/workflow/WorkflowAgentCallTranscript.js +98 -0
- package/workflow/WorkflowAgentCallTranscript.js.map +1 -0
- package/workflow/WorkflowDetailView.d.ts.map +1 -1
- package/workflow/WorkflowDetailView.js +4 -3
- package/workflow/WorkflowDetailView.js.map +1 -1
- package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
- package/workflow/WorkflowExecutionViewer.js +23 -52
- package/workflow/WorkflowExecutionViewer.js.map +1 -1
- package/workflow/WorkflowTaskApprovalSummary.d.ts +1 -1
- package/workflow/WorkflowTaskApprovalSummary.d.ts.map +1 -1
- package/workflow/WorkflowTaskApprovalSummary.js +18 -1
- package/workflow/WorkflowTaskApprovalSummary.js.map +1 -1
- package/workflow/index.d.ts +2 -2
- package/workflow/index.d.ts.map +1 -1
- package/workflow/index.js +2 -2
- package/workflow/index.js.map +1 -1
- package/workflow/task-detail/index.d.ts +1 -1
- package/workflow/task-detail/index.d.ts.map +1 -1
- package/workflow/task-detail/index.js +1 -1
- package/workflow/task-detail/index.js.map +1 -1
- package/workflow/task-detail/task-approval.d.ts +44 -1
- package/workflow/task-detail/task-approval.d.ts.map +1 -1
- package/workflow/task-detail/task-approval.js +47 -0
- package/workflow/task-detail/task-approval.js.map +1 -1
- package/workflow/thread/WorkflowTaskThread.d.ts +17 -25
- package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
- package/workflow/thread/WorkflowTaskThread.js +60 -105
- package/workflow/thread/WorkflowTaskThread.js.map +1 -1
- package/workflow/thread/task-presentation.d.ts.map +1 -1
- package/workflow/thread/task-presentation.js +7 -2
- package/workflow/thread/task-presentation.js.map +1 -1
- package/workflow/useWorkflowExecutionPanel.d.ts +0 -12
- package/workflow/useWorkflowExecutionPanel.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionPanel.js +0 -12
- package/workflow/useWorkflowExecutionPanel.js.map +1 -1
- package/execution/agent-execution-document.d.ts +0 -31
- package/execution/agent-execution-document.d.ts.map +0 -1
- package/execution/agent-execution-document.js +0 -48
- package/execution/agent-execution-document.js.map +0 -1
- package/src/execution/__tests__/agent-execution-document.test.ts +0 -45
- package/src/execution/agent-execution-document.ts +0 -54
- package/src/workflow/WorkflowAgentExecutionDocument.tsx +0 -283
- package/workflow/WorkflowAgentExecutionDocument.d.ts +0 -79
- package/workflow/WorkflowAgentExecutionDocument.d.ts.map +0 -1
- package/workflow/WorkflowAgentExecutionDocument.js +0 -87
- package/workflow/WorkflowAgentExecutionDocument.js.map +0 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
3
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
4
|
+
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
5
|
+
import { useManageAccess } from "../useManageAccess";
|
|
6
|
+
import { VisibilityBadge } from "../../library/VisibilitySelector";
|
|
7
|
+
import type { AccessResource } from "../types";
|
|
8
|
+
|
|
9
|
+
// Drive the gate through the permission hook (owned by useManageAccess) and
|
|
10
|
+
// stand in a lightweight dialog so the open-state wiring is observable
|
|
11
|
+
// without exercising the real modal (covered by its own test).
|
|
12
|
+
const checkPermission = vi.fn<() => { allowed: boolean; isLoading: boolean; error: null }>(
|
|
13
|
+
() => ({ allowed: true, isLoading: false, error: null }),
|
|
14
|
+
);
|
|
15
|
+
vi.mock("../../iam-policy/useCheckPermission", () => ({
|
|
16
|
+
useCheckPermission: () => checkPermission(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../ManageAccessDialog", () => ({
|
|
20
|
+
ManageAccessDialog: ({ open }: { open: boolean }) => (
|
|
21
|
+
<div data-testid="dialog" data-open={String(open)} />
|
|
22
|
+
),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
checkPermission.mockReset();
|
|
28
|
+
checkPermission.mockReturnValue({ allowed: true, isLoading: false, error: null });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const RESOURCE: AccessResource = {
|
|
32
|
+
kind: ApiResourceKind.agent,
|
|
33
|
+
kindString: "agent",
|
|
34
|
+
id: "agt_123",
|
|
35
|
+
org: "acme",
|
|
36
|
+
name: "Release Bot",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mirrors how the blueprint detail views (agent/skill/mcp_server/workflow)
|
|
41
|
+
* wire the header visibility chip: clickable into the Manage access dialog
|
|
42
|
+
* only when the user can view access, a static badge otherwise.
|
|
43
|
+
*/
|
|
44
|
+
function DetailHeaderHarness({ resource }: { resource: AccessResource | null }) {
|
|
45
|
+
const access = useManageAccess({ resource });
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<VisibilityBadge
|
|
49
|
+
visibility={ApiResourceVisibility.visibility_org}
|
|
50
|
+
onClick={access.action ? access.open : undefined}
|
|
51
|
+
/>
|
|
52
|
+
{access.dialog}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("useManageAccess — header visibility chip wiring", () => {
|
|
58
|
+
it("renders the chip as a Manage-access button and opens the dialog on click", () => {
|
|
59
|
+
render(<DetailHeaderHarness resource={RESOURCE} />);
|
|
60
|
+
|
|
61
|
+
const chip = screen.getByRole("button", {
|
|
62
|
+
name: /Organization visibility — manage access/i,
|
|
63
|
+
});
|
|
64
|
+
expect(screen.getByTestId("dialog").getAttribute("data-open")).toBe("false");
|
|
65
|
+
|
|
66
|
+
fireEvent.click(chip);
|
|
67
|
+
expect(screen.getByTestId("dialog").getAttribute("data-open")).toBe("true");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("keeps the chip a static badge when the user cannot view access", () => {
|
|
71
|
+
checkPermission.mockReturnValue({ allowed: false, isLoading: false, error: null });
|
|
72
|
+
render(<DetailHeaderHarness resource={RESOURCE} />);
|
|
73
|
+
|
|
74
|
+
// Visibility stays legible, but there is nothing to click.
|
|
75
|
+
expect(screen.getByText("Organization")).toBeTruthy();
|
|
76
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps the chip static and mounts no dialog while the resource is loading", () => {
|
|
80
|
+
render(<DetailHeaderHarness resource={null} />);
|
|
81
|
+
|
|
82
|
+
expect(screen.getByText("Organization")).toBeTruthy();
|
|
83
|
+
expect(screen.queryByRole("button")).toBeNull();
|
|
84
|
+
expect(screen.queryByTestId("dialog")).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("useManageAccess — action gating", () => {
|
|
89
|
+
function ActionProbe({ resource }: { resource: AccessResource | null }) {
|
|
90
|
+
const access = useManageAccess({ resource });
|
|
91
|
+
return <div data-testid="action" data-present={String(access.action !== null)} />;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
it("returns a sharing-group action when permitted", () => {
|
|
95
|
+
render(<ActionProbe resource={RESOURCE} />);
|
|
96
|
+
expect(screen.getByTestId("action").getAttribute("data-present")).toBe("true");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns null without can_view_access", () => {
|
|
100
|
+
checkPermission.mockReturnValue({ allowed: false, isLoading: false, error: null });
|
|
101
|
+
render(<ActionProbe resource={RESOURCE} />);
|
|
102
|
+
expect(screen.getByTestId("action").getAttribute("data-present")).toBe("false");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -360,10 +360,16 @@ export function AgentDetailView({
|
|
|
360
360
|
updatedAt: specAudit?.updatedAt ? timestampDate(specAudit.updatedAt) : null,
|
|
361
361
|
};
|
|
362
362
|
|
|
363
|
-
// Inline visibility is
|
|
364
|
-
//
|
|
363
|
+
// Inline visibility is at-a-glance AND a shortcut into the Manage access
|
|
364
|
+
// dialog — the single writer for both access axes. The chip is navigation
|
|
365
|
+
// only (never edits in place); it stays a static badge for users who
|
|
366
|
+
// cannot view access. An inert chip reads as "not editable" (2026-07-18
|
|
367
|
+
// dogfood friction), so the shortcut is the discoverability affordance.
|
|
365
368
|
const visibilityControl = meta ? (
|
|
366
|
-
<VisibilityBadge
|
|
369
|
+
<VisibilityBadge
|
|
370
|
+
visibility={meta.visibility}
|
|
371
|
+
onClick={access.action ? access.open : undefined}
|
|
372
|
+
/>
|
|
367
373
|
) : undefined;
|
|
368
374
|
|
|
369
375
|
// Share — the sibling consent to Manage access: visibility governs who
|
|
@@ -200,3 +200,128 @@ describe("useLiveAgentExecution", () => {
|
|
|
200
200
|
}
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// The { live } visibility gate (T07)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
describe("useLiveAgentExecution { live }", () => {
|
|
209
|
+
it("live: false fetches the snapshot but never subscribes", async () => {
|
|
210
|
+
const running = executionFixture("aex_p1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
211
|
+
mockGet(async () => running);
|
|
212
|
+
|
|
213
|
+
const { result } = renderHook(() =>
|
|
214
|
+
useLiveAgentExecution("aex_p1", { live: false }),
|
|
215
|
+
);
|
|
216
|
+
await waitFor(() => expect(result.current.execution).toBe(running));
|
|
217
|
+
|
|
218
|
+
// Non-terminal + fetched, yet the gate stays closed on every render.
|
|
219
|
+
for (const call of mockUseExecutionStream.mock.calls) {
|
|
220
|
+
expect(call[0]).toBeNull();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("live: true (and omitted) behave identically for a non-terminal execution", async () => {
|
|
225
|
+
const running = executionFixture("aex_p2", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
226
|
+
mockGet(async () => running);
|
|
227
|
+
|
|
228
|
+
const { result } = renderHook(() =>
|
|
229
|
+
useLiveAgentExecution("aex_p2", { live: true }),
|
|
230
|
+
);
|
|
231
|
+
await waitFor(() => expect(result.current.execution).not.toBeNull());
|
|
232
|
+
|
|
233
|
+
const lastCall =
|
|
234
|
+
mockUseExecutionStream.mock.calls[mockUseExecutionStream.mock.calls.length - 1];
|
|
235
|
+
expect(lastCall[0]).toBe("aex_p2");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("a terminal execution never streams regardless of live", async () => {
|
|
239
|
+
const terminal = executionFixture("aex_p3", ExecutionPhase.EXECUTION_COMPLETED);
|
|
240
|
+
mockGet(async () => terminal);
|
|
241
|
+
|
|
242
|
+
const { result } = renderHook(() =>
|
|
243
|
+
useLiveAgentExecution("aex_p3", { live: true }),
|
|
244
|
+
);
|
|
245
|
+
await waitFor(() => expect(result.current.execution).toBe(terminal));
|
|
246
|
+
|
|
247
|
+
for (const call of mockUseExecutionStream.mock.calls) {
|
|
248
|
+
expect(call[0]).toBeNull();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("false → true attaches the stream in place", async () => {
|
|
253
|
+
const running = executionFixture("aex_p4", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
254
|
+
mockGet(async () => running);
|
|
255
|
+
|
|
256
|
+
const { result, rerender } = renderHook(
|
|
257
|
+
({ live }: { live: boolean }) =>
|
|
258
|
+
useLiveAgentExecution("aex_p4", { live }),
|
|
259
|
+
{ initialProps: { live: false } },
|
|
260
|
+
);
|
|
261
|
+
await waitFor(() => expect(result.current.execution).toBe(running));
|
|
262
|
+
|
|
263
|
+
let lastCall =
|
|
264
|
+
mockUseExecutionStream.mock.calls[mockUseExecutionStream.mock.calls.length - 1];
|
|
265
|
+
expect(lastCall[0]).toBeNull();
|
|
266
|
+
|
|
267
|
+
rerender({ live: true });
|
|
268
|
+
|
|
269
|
+
lastCall =
|
|
270
|
+
mockUseExecutionStream.mock.calls[mockUseExecutionStream.mock.calls.length - 1];
|
|
271
|
+
expect(lastCall[0]).toBe("aex_p4");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("never rewinds: a stream pause keeps the last-streamed snapshot, not the mount fetch", async () => {
|
|
275
|
+
const fetched = executionFixture("aex_p5", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
276
|
+
const streamed = executionFixture("aex_p5", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
277
|
+
mockGet(async () => fetched);
|
|
278
|
+
mockUseExecutionStream.mockReturnValue({
|
|
279
|
+
...idleStream(),
|
|
280
|
+
execution: streamed,
|
|
281
|
+
isStreaming: true,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const { result, rerender } = renderHook(
|
|
285
|
+
({ live }: { live: boolean }) =>
|
|
286
|
+
useLiveAgentExecution("aex_p5", { live }),
|
|
287
|
+
{ initialProps: { live: true } },
|
|
288
|
+
);
|
|
289
|
+
await waitFor(() => expect(result.current.execution).toBe(streamed));
|
|
290
|
+
|
|
291
|
+
// Pause: the stream hook resets to its idle shape (execution: null) —
|
|
292
|
+
// the exact behavior of an unsubscribed useExecutionStream.
|
|
293
|
+
mockUseExecutionStream.mockReturnValue(idleStream());
|
|
294
|
+
rerender({ live: false });
|
|
295
|
+
|
|
296
|
+
// The freshest streamed snapshot stays visible; the view never rolls
|
|
297
|
+
// back to the mount-time fetch.
|
|
298
|
+
expect(result.current.execution).toBe(streamed);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("does not leak the previous execution's snapshot across an id switch", async () => {
|
|
302
|
+
const first = executionFixture("aex_p6a", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
303
|
+
const streamedFirst = executionFixture("aex_p6a", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
304
|
+
const second = executionFixture("aex_p6b", ExecutionPhase.EXECUTION_COMPLETED);
|
|
305
|
+
mockGet(async (id) => (id === "aex_p6a" ? first : second));
|
|
306
|
+
mockUseExecutionStream.mockReturnValue({
|
|
307
|
+
...idleStream(),
|
|
308
|
+
execution: streamedFirst,
|
|
309
|
+
isStreaming: true,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const { result, rerender } = renderHook(
|
|
313
|
+
({ id }: { id: string }) => useLiveAgentExecution(id, { live: true }),
|
|
314
|
+
{ initialProps: { id: "aex_p6a" } },
|
|
315
|
+
);
|
|
316
|
+
await waitFor(() => expect(result.current.execution).toBe(streamedFirst));
|
|
317
|
+
|
|
318
|
+
// Switch executions; the stream resets while the new fetch is in flight.
|
|
319
|
+
mockUseExecutionStream.mockReturnValue(idleStream());
|
|
320
|
+
rerender({ id: "aex_p6b" });
|
|
321
|
+
|
|
322
|
+
// The retained snapshot is keyed by id — it must NOT bleed into the
|
|
323
|
+
// new execution's view.
|
|
324
|
+
expect(result.current.execution).not.toBe(streamedFirst);
|
|
325
|
+
await waitFor(() => expect(result.current.execution).toBe(second));
|
|
326
|
+
});
|
|
327
|
+
});
|
package/src/execution/index.ts
CHANGED
|
@@ -13,7 +13,10 @@ export { useExecutionStream } from "./useExecutionStream.js";
|
|
|
13
13
|
export type { UseExecutionStreamReturn } from "./useExecutionStream.js";
|
|
14
14
|
|
|
15
15
|
export { useLiveAgentExecution } from "./useLiveAgentExecution.js";
|
|
16
|
-
export type {
|
|
16
|
+
export type {
|
|
17
|
+
UseLiveAgentExecutionOptions,
|
|
18
|
+
UseLiveAgentExecutionReturn,
|
|
19
|
+
} from "./useLiveAgentExecution.js";
|
|
17
20
|
|
|
18
21
|
export { useAgentExecutionActions } from "./useAgentExecutionActions.js";
|
|
19
22
|
export type {
|
|
@@ -291,12 +294,6 @@ export {
|
|
|
291
294
|
fileChangeTabPath,
|
|
292
295
|
} from "./file-change-document.js";
|
|
293
296
|
|
|
294
|
-
export {
|
|
295
|
-
AGENT_EXECUTION_DOCUMENT_ENTRY_ID,
|
|
296
|
-
agentExecutionTabPath,
|
|
297
|
-
parseAgentExecutionTabPath,
|
|
298
|
-
} from "./agent-execution-document.js";
|
|
299
|
-
|
|
300
297
|
export { ArtifactContentRenderer } from "./ArtifactContentRenderer.js";
|
|
301
298
|
export type { ArtifactContentRendererProps } from "./ArtifactContentRenderer.js";
|
|
302
299
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { useCallback, useMemo, useRef } from "react";
|
|
4
4
|
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
5
5
|
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
6
6
|
import { isNotFound } from "@stigmer/sdk";
|
|
@@ -9,6 +9,23 @@ import { useFetch } from "../internal/useFetch.js";
|
|
|
9
9
|
import { isTerminalPhase } from "./execution-phases.js";
|
|
10
10
|
import { useExecutionStream } from "./useExecutionStream.js";
|
|
11
11
|
|
|
12
|
+
/** Options for {@link useLiveAgentExecution}. */
|
|
13
|
+
export interface UseLiveAgentExecutionOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Whether the live stream may be open. Defaults to `true`.
|
|
16
|
+
*
|
|
17
|
+
* When `false`, the snapshot fetch still runs (so consumers always have
|
|
18
|
+
* something to render) but no subscription is opened — the
|
|
19
|
+
* visibility-gating seam for surfaces that mount many executions at once
|
|
20
|
+
* (e.g. inline agent-call transcripts in the workflow task thread, where
|
|
21
|
+
* only on-screen cards stream). A `false → true` transition attaches the
|
|
22
|
+
* stream in place; a `true → false` transition pauses it while the
|
|
23
|
+
* last-streamed snapshot stays visible (the view never rewinds to the
|
|
24
|
+
* mount-time snapshot).
|
|
25
|
+
*/
|
|
26
|
+
readonly live?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/** Return value of {@link useLiveAgentExecution}. */
|
|
13
30
|
export interface UseLiveAgentExecutionReturn {
|
|
14
31
|
/**
|
|
@@ -67,8 +84,10 @@ export interface UseLiveAgentExecutionReturn {
|
|
|
67
84
|
*/
|
|
68
85
|
export function useLiveAgentExecution(
|
|
69
86
|
executionId: string | null,
|
|
87
|
+
options?: UseLiveAgentExecutionOptions,
|
|
70
88
|
): UseLiveAgentExecutionReturn {
|
|
71
89
|
const stigmer = useStigmer();
|
|
90
|
+
const live = options?.live ?? true;
|
|
72
91
|
|
|
73
92
|
const fetchFn = executionId
|
|
74
93
|
? async () => {
|
|
@@ -94,12 +113,31 @@ export function useLiveAgentExecution(
|
|
|
94
113
|
// snapshot never advances, so a run that terminates mid-stream does not
|
|
95
114
|
// flip this gate and unmount-thrash the subscription — useExecutionStream
|
|
96
115
|
// ends itself on the terminal snapshot (completion is phase-driven).
|
|
116
|
+
// `live` is the consumer's visibility gate layered on top (an off-screen
|
|
117
|
+
// surface pauses its subscription without losing the fetch).
|
|
97
118
|
const fetchedPhase =
|
|
98
119
|
fetched?.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
|
|
99
|
-
const shouldStream =
|
|
120
|
+
const shouldStream =
|
|
121
|
+
live && fetched !== null && !isTerminalPhase(fetchedPhase);
|
|
100
122
|
const stream = useExecutionStream(shouldStream ? executionId : null);
|
|
101
123
|
|
|
102
|
-
|
|
124
|
+
// Retain the freshest snapshot ever shown for THIS execution across a
|
|
125
|
+
// stream pause (`live: true → false`): useExecutionStream resets its
|
|
126
|
+
// store when unsubscribed, so without this a paused surface would rewind
|
|
127
|
+
// to the mount-time fetch — visibly rolling the view backwards. Keyed by
|
|
128
|
+
// id so an execution switch never leaks the previous one's snapshot.
|
|
129
|
+
const lastStreamedRef = useRef<{
|
|
130
|
+
id: string;
|
|
131
|
+
execution: AgentExecution;
|
|
132
|
+
} | null>(null);
|
|
133
|
+
if (executionId && stream.execution) {
|
|
134
|
+
lastStreamedRef.current = { id: executionId, execution: stream.execution };
|
|
135
|
+
} else if (lastStreamedRef.current?.id !== executionId) {
|
|
136
|
+
lastStreamedRef.current = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const execution =
|
|
140
|
+
stream.execution ?? lastStreamedRef.current?.execution ?? fetched;
|
|
103
141
|
const phase =
|
|
104
142
|
execution?.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED;
|
|
105
143
|
|
package/src/index.ts
CHANGED
|
@@ -346,9 +346,6 @@ export {
|
|
|
346
346
|
toFileDiffEntry,
|
|
347
347
|
FILE_CHANGE_DOCUMENT_ENTRY_ID,
|
|
348
348
|
fileChangeTabPath,
|
|
349
|
-
AGENT_EXECUTION_DOCUMENT_ENTRY_ID,
|
|
350
|
-
agentExecutionTabPath,
|
|
351
|
-
parseAgentExecutionTabPath,
|
|
352
349
|
EmptyChangeNotice,
|
|
353
350
|
isTextArtifact,
|
|
354
351
|
formatArtifactSize,
|
|
@@ -363,6 +360,7 @@ export type {
|
|
|
363
360
|
SharedAgentExecutionFields,
|
|
364
361
|
UseCreateAgentExecutionReturn,
|
|
365
362
|
UseExecutionStreamReturn,
|
|
363
|
+
UseLiveAgentExecutionOptions,
|
|
366
364
|
UseLiveAgentExecutionReturn,
|
|
367
365
|
UseAgentExecutionActionsOptions,
|
|
368
366
|
UseAgentExecutionActionsReturn,
|
|
@@ -1176,6 +1174,7 @@ export type {
|
|
|
1176
1174
|
UseImportResourceReturn,
|
|
1177
1175
|
ImportResourceDialogProps,
|
|
1178
1176
|
VisibilitySelectorProps,
|
|
1177
|
+
VisibilityBadgeProps,
|
|
1179
1178
|
VisibilityLevelOption,
|
|
1180
1179
|
BlueprintVisibilityLevelsContext,
|
|
1181
1180
|
VisibilityResourceKind,
|
|
@@ -1485,7 +1484,7 @@ export {
|
|
|
1485
1484
|
WorkflowChangesTab,
|
|
1486
1485
|
WorkflowUsageTab,
|
|
1487
1486
|
WorkflowArtifactDocument,
|
|
1488
|
-
|
|
1487
|
+
WorkflowAgentCallTranscript,
|
|
1489
1488
|
useWorkflowArtifactDownload,
|
|
1490
1489
|
useWorkflowExecutionFileChanges,
|
|
1491
1490
|
enumerateAgentCallChildren,
|
|
@@ -1627,7 +1626,7 @@ export type {
|
|
|
1627
1626
|
UseWorkflowExecutionFileChangesOptions,
|
|
1628
1627
|
UseWorkflowExecutionFileChangesReturn,
|
|
1629
1628
|
WorkflowArtifactDocumentProps,
|
|
1630
|
-
|
|
1629
|
+
WorkflowAgentCallTranscriptProps,
|
|
1631
1630
|
WorkflowAgentExecutionHitl,
|
|
1632
1631
|
UseWorkflowArtifactDownloadReturn,
|
|
1633
1632
|
WorkflowArtifactEntry,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, act, screen, cleanup } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useInViewport, type UseInViewportReturn } from "../useInViewport";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Test harness — renders a real element so the ref gets populated
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let latestResult: UseInViewportReturn;
|
|
11
|
+
|
|
12
|
+
function Harness() {
|
|
13
|
+
const result = useInViewport();
|
|
14
|
+
latestResult = result;
|
|
15
|
+
return <div ref={result.ref} data-testid="target" />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Observer mock (the useAutoScroll.test pattern)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
type IOCallback = IntersectionObserverCallback;
|
|
23
|
+
|
|
24
|
+
let ioCallback: IOCallback;
|
|
25
|
+
let ioDisconnect: ReturnType<typeof vi.fn>;
|
|
26
|
+
let ioObserve: ReturnType<typeof vi.fn>;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
ioDisconnect = vi.fn();
|
|
30
|
+
ioObserve = vi.fn();
|
|
31
|
+
|
|
32
|
+
vi.stubGlobal(
|
|
33
|
+
"IntersectionObserver",
|
|
34
|
+
vi.fn((cb: IOCallback) => {
|
|
35
|
+
ioCallback = cb;
|
|
36
|
+
return {
|
|
37
|
+
observe: ioObserve,
|
|
38
|
+
unobserve: vi.fn(),
|
|
39
|
+
disconnect: ioDisconnect,
|
|
40
|
+
takeRecords: vi.fn(() => []),
|
|
41
|
+
root: null,
|
|
42
|
+
rootMargin: "",
|
|
43
|
+
thresholds: [0],
|
|
44
|
+
};
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
cleanup();
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function fireIO(isIntersecting: boolean) {
|
|
55
|
+
act(() => {
|
|
56
|
+
ioCallback(
|
|
57
|
+
[{ isIntersecting } as IntersectionObserverEntry],
|
|
58
|
+
{} as IntersectionObserver,
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Tests
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
describe("useInViewport", () => {
|
|
68
|
+
it("starts not-visible until the observer's first callback", () => {
|
|
69
|
+
render(<Harness />);
|
|
70
|
+
expect(latestResult.isVisible).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("observes the target element on mount", () => {
|
|
74
|
+
render(<Harness />);
|
|
75
|
+
expect(ioObserve).toHaveBeenCalledWith(screen.getByTestId("target"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("uses the top-level viewport as root with the pre-warm margin", () => {
|
|
79
|
+
render(<Harness />);
|
|
80
|
+
const IOConstructor = vi.mocked(IntersectionObserver);
|
|
81
|
+
const options = IOConstructor.mock.calls[0]?.[1];
|
|
82
|
+
expect(options?.root).toBeNull();
|
|
83
|
+
expect(options?.rootMargin).toBe("200px 0px 200px 0px");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("flips visible on enter and back on leave", () => {
|
|
87
|
+
render(<Harness />);
|
|
88
|
+
|
|
89
|
+
fireIO(true);
|
|
90
|
+
expect(latestResult.isVisible).toBe(true);
|
|
91
|
+
|
|
92
|
+
fireIO(false);
|
|
93
|
+
expect(latestResult.isVisible).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("reads the LATEST entry when a batch carries several", () => {
|
|
97
|
+
render(<Harness />);
|
|
98
|
+
act(() => {
|
|
99
|
+
ioCallback(
|
|
100
|
+
[
|
|
101
|
+
{ isIntersecting: true } as IntersectionObserverEntry,
|
|
102
|
+
{ isIntersecting: false } as IntersectionObserverEntry,
|
|
103
|
+
],
|
|
104
|
+
{} as IntersectionObserver,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
expect(latestResult.isVisible).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("disconnects the observer on unmount", () => {
|
|
111
|
+
const { unmount } = render(<Harness />);
|
|
112
|
+
unmount();
|
|
113
|
+
expect(ioDisconnect).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pre-warm margin in pixels. The element is reported visible while it is
|
|
7
|
+
* within this distance of the viewport, so consumers that attach expensive
|
|
8
|
+
* resources on visibility (e.g. a live stream subscription) connect just
|
|
9
|
+
* BEFORE the element scrolls into view and linger just after it leaves —
|
|
10
|
+
* a fast scroll past a row never thrashes connect/disconnect.
|
|
11
|
+
*/
|
|
12
|
+
const PREWARM_MARGIN_PX = 200;
|
|
13
|
+
|
|
14
|
+
export interface UseInViewportReturn {
|
|
15
|
+
/** Attach to the element whose viewport visibility is tracked. */
|
|
16
|
+
readonly ref: React.RefObject<HTMLDivElement | null>;
|
|
17
|
+
/** True while the element is within the pre-warmed viewport. */
|
|
18
|
+
readonly isVisible: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Headless viewport-visibility hook over `IntersectionObserver`.
|
|
23
|
+
*
|
|
24
|
+
* `root` is deliberately `null` (the top-level viewport): per the
|
|
25
|
+
* IntersectionObserver spec, an element clipped by any intervening
|
|
26
|
+
* scroll container (e.g. the workflow thread's own scroller) does not
|
|
27
|
+
* intersect the viewport either, so nested scroll contexts are handled
|
|
28
|
+
* without naming them. `display: none` subtrees report not-visible — a
|
|
29
|
+
* CSS-hidden center view correctly suspends its visibility-gated work.
|
|
30
|
+
*
|
|
31
|
+
* Starts `false` until the observer's first callback; consumers should
|
|
32
|
+
* treat visibility as an upgrade signal, not a render gate.
|
|
33
|
+
*
|
|
34
|
+
* @internal Not part of the public API (promote if a second consumer
|
|
35
|
+
* appears outside the workflow thread).
|
|
36
|
+
*/
|
|
37
|
+
export function useInViewport(): UseInViewportReturn {
|
|
38
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const el = ref.current;
|
|
43
|
+
if (!el) return;
|
|
44
|
+
|
|
45
|
+
const io = new IntersectionObserver(
|
|
46
|
+
(entries) => {
|
|
47
|
+
const entry = entries[entries.length - 1];
|
|
48
|
+
if (!entry) return;
|
|
49
|
+
setIsVisible(entry.isIntersecting);
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
root: null,
|
|
53
|
+
rootMargin: `${PREWARM_MARGIN_PX}px 0px ${PREWARM_MARGIN_PX}px 0px`,
|
|
54
|
+
threshold: 0,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
io.observe(el);
|
|
59
|
+
return () => io.disconnect();
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return { ref, isVisible };
|
|
63
|
+
}
|
|
@@ -409,28 +409,69 @@ function CaretIcon() {
|
|
|
409
409
|
);
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
+
/** Props for {@link VisibilityBadge}. */
|
|
413
|
+
export interface VisibilityBadgeProps {
|
|
414
|
+
/** The visibility level to display. */
|
|
415
|
+
readonly visibility: ApiResourceVisibility;
|
|
416
|
+
/** Additional CSS classes applied to the chip. */
|
|
417
|
+
readonly className?: string;
|
|
418
|
+
/**
|
|
419
|
+
* When provided, the badge renders as a button and invokes this on
|
|
420
|
+
* activation. Detail headers use it as a shortcut into the Manage access
|
|
421
|
+
* dialog, so the at-a-glance chip is also the entry point to editing —
|
|
422
|
+
* the chip stays navigation-only (the dialog remains the single writer
|
|
423
|
+
* for both access axes). Omit it for a plain, non-interactive badge.
|
|
424
|
+
*/
|
|
425
|
+
readonly onClick?: () => void;
|
|
426
|
+
}
|
|
427
|
+
|
|
412
428
|
/**
|
|
413
|
-
*
|
|
414
|
-
*
|
|
429
|
+
* Visibility indicator with a matching icon, covering all four levels
|
|
430
|
+
* (Private / Organization / Platform / Public).
|
|
415
431
|
*
|
|
416
432
|
* Rendered wherever the interactive {@link VisibilitySelector} is not
|
|
417
433
|
* available — for viewers who lack `can_edit`, and while a permission check
|
|
418
434
|
* is in flight — so a resource's visibility is always legible rather than
|
|
419
435
|
* silently blank. Shares the chip styling with the selector trigger so the
|
|
420
436
|
* read-only and editable states are visually consistent.
|
|
437
|
+
*
|
|
438
|
+
* With {@link VisibilityBadgeProps.onClick} the chip becomes an accessible
|
|
439
|
+
* button (hover + focus affordances, "Manage access" label) that hosts wire
|
|
440
|
+
* to their access-management surface; the badge itself never edits.
|
|
421
441
|
*/
|
|
422
442
|
export function VisibilityBadge({
|
|
423
443
|
visibility,
|
|
424
444
|
className,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
readonly className?: string;
|
|
428
|
-
}) {
|
|
445
|
+
onClick,
|
|
446
|
+
}: VisibilityBadgeProps) {
|
|
429
447
|
const option = visibilityOption(visibility);
|
|
430
|
-
|
|
431
|
-
|
|
448
|
+
const content = (
|
|
449
|
+
<>
|
|
432
450
|
<VisibilityIcon tone={option.tone} className="size-2.5" />
|
|
433
451
|
{option.label}
|
|
434
|
-
|
|
452
|
+
</>
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
if (onClick) {
|
|
456
|
+
return (
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
onClick={onClick}
|
|
460
|
+
title="Manage access"
|
|
461
|
+
aria-label={`${option.label} visibility — manage access`}
|
|
462
|
+
className={cn(
|
|
463
|
+
VISIBILITY_CHIP_CLASS,
|
|
464
|
+
"transition-colors hover:bg-accent-hover hover:text-foreground",
|
|
465
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
466
|
+
className,
|
|
467
|
+
)}
|
|
468
|
+
>
|
|
469
|
+
{content}
|
|
470
|
+
</button>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<span className={cn(VISIBILITY_CHIP_CLASS, className)}>{content}</span>
|
|
435
476
|
);
|
|
436
477
|
}
|