@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.
Files changed (121) hide show
  1. package/agent/AgentDetailView.d.ts.map +1 -1
  2. package/agent/AgentDetailView.js +6 -3
  3. package/agent/AgentDetailView.js.map +1 -1
  4. package/execution/index.d.ts +1 -2
  5. package/execution/index.d.ts.map +1 -1
  6. package/execution/index.js +0 -1
  7. package/execution/index.js.map +1 -1
  8. package/execution/useLiveAgentExecution.d.ts +17 -1
  9. package/execution/useLiveAgentExecution.d.ts.map +1 -1
  10. package/execution/useLiveAgentExecution.js +19 -4
  11. package/execution/useLiveAgentExecution.js.map +1 -1
  12. package/index.d.ts +5 -5
  13. package/index.d.ts.map +1 -1
  14. package/index.js +2 -2
  15. package/index.js.map +1 -1
  16. package/internal/useInViewport.d.ts +24 -0
  17. package/internal/useInViewport.d.ts.map +1 -0
  18. package/internal/useInViewport.js +49 -0
  19. package/internal/useInViewport.js.map +1 -0
  20. package/library/VisibilitySelector.d.ts +22 -6
  21. package/library/VisibilitySelector.d.ts.map +1 -1
  22. package/library/VisibilitySelector.js +12 -4
  23. package/library/VisibilitySelector.js.map +1 -1
  24. package/library/index.d.ts +1 -1
  25. package/library/index.d.ts.map +1 -1
  26. package/library/index.js.map +1 -1
  27. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  28. package/mcp-server/McpServerDetailView.js +4 -3
  29. package/mcp-server/McpServerDetailView.js.map +1 -1
  30. package/package.json +4 -4
  31. package/session/SessionViewer.js +8 -1
  32. package/session/SessionViewer.js.map +1 -1
  33. package/session/audience.d.ts +6 -1
  34. package/session/audience.d.ts.map +1 -1
  35. package/skill/SkillDetailView.d.ts.map +1 -1
  36. package/skill/SkillDetailView.js +4 -3
  37. package/skill/SkillDetailView.js.map +1 -1
  38. package/src/access/__tests__/useManageAccess.test.tsx +104 -0
  39. package/src/agent/AgentDetailView.tsx +9 -3
  40. package/src/execution/__tests__/useLiveAgentExecution.test.tsx +125 -0
  41. package/src/execution/index.ts +4 -7
  42. package/src/execution/useLiveAgentExecution.ts +41 -3
  43. package/src/index.ts +4 -5
  44. package/src/internal/__tests__/useInViewport.test.tsx +115 -0
  45. package/src/internal/useInViewport.ts +63 -0
  46. package/src/library/VisibilitySelector.tsx +50 -9
  47. package/src/library/__tests__/VisibilitySelector.test.tsx +38 -0
  48. package/src/library/index.ts +4 -1
  49. package/src/mcp-server/McpServerDetailView.tsx +7 -3
  50. package/src/session/SessionViewer.tsx +7 -1
  51. package/src/session/__tests__/audienceWiring.test.tsx +31 -1
  52. package/src/session/audience.ts +6 -1
  53. package/src/skill/SkillDetailView.tsx +7 -3
  54. package/src/workflow/WorkflowAgentCallTranscript.tsx +279 -0
  55. package/src/workflow/WorkflowDetailView.tsx +7 -3
  56. package/src/workflow/WorkflowExecutionViewer.tsx +20 -77
  57. package/src/workflow/WorkflowTaskApprovalSummary.tsx +45 -8
  58. package/src/workflow/__tests__/WorkflowAgentCallTranscript.liveregion.test.tsx +124 -0
  59. package/src/workflow/__tests__/{WorkflowAgentExecutionDocument.test.tsx → WorkflowAgentCallTranscript.test.tsx} +146 -121
  60. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +81 -220
  61. package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +17 -16
  62. package/src/workflow/__tests__/WorkflowTaskApprovalSummary.test.tsx +52 -2
  63. package/src/workflow/__tests__/WorkflowTaskThread.test.tsx +168 -185
  64. package/src/workflow/__tests__/agent-call-live-experience.test.ts +4 -4
  65. package/src/workflow/__tests__/task-approval.test.ts +119 -0
  66. package/src/workflow/__tests__/task-presentation.test.ts +35 -1
  67. package/src/workflow/__tests__/useWorkflowExecutionPanel.test.ts +0 -57
  68. package/src/workflow/index.ts +6 -3
  69. package/src/workflow/task-detail/index.ts +3 -0
  70. package/src/workflow/task-detail/task-approval.ts +95 -1
  71. package/src/workflow/thread/WorkflowTaskThread.tsx +96 -252
  72. package/src/workflow/thread/task-presentation.ts +8 -2
  73. package/src/workflow/useWorkflowExecutionPanel.ts +0 -47
  74. package/workflow/WorkflowAgentCallTranscript.d.ts +87 -0
  75. package/workflow/WorkflowAgentCallTranscript.d.ts.map +1 -0
  76. package/workflow/WorkflowAgentCallTranscript.js +98 -0
  77. package/workflow/WorkflowAgentCallTranscript.js.map +1 -0
  78. package/workflow/WorkflowDetailView.d.ts.map +1 -1
  79. package/workflow/WorkflowDetailView.js +4 -3
  80. package/workflow/WorkflowDetailView.js.map +1 -1
  81. package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
  82. package/workflow/WorkflowExecutionViewer.js +23 -52
  83. package/workflow/WorkflowExecutionViewer.js.map +1 -1
  84. package/workflow/WorkflowTaskApprovalSummary.d.ts +1 -1
  85. package/workflow/WorkflowTaskApprovalSummary.d.ts.map +1 -1
  86. package/workflow/WorkflowTaskApprovalSummary.js +18 -1
  87. package/workflow/WorkflowTaskApprovalSummary.js.map +1 -1
  88. package/workflow/index.d.ts +2 -2
  89. package/workflow/index.d.ts.map +1 -1
  90. package/workflow/index.js +2 -2
  91. package/workflow/index.js.map +1 -1
  92. package/workflow/task-detail/index.d.ts +1 -1
  93. package/workflow/task-detail/index.d.ts.map +1 -1
  94. package/workflow/task-detail/index.js +1 -1
  95. package/workflow/task-detail/index.js.map +1 -1
  96. package/workflow/task-detail/task-approval.d.ts +44 -1
  97. package/workflow/task-detail/task-approval.d.ts.map +1 -1
  98. package/workflow/task-detail/task-approval.js +47 -0
  99. package/workflow/task-detail/task-approval.js.map +1 -1
  100. package/workflow/thread/WorkflowTaskThread.d.ts +17 -25
  101. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  102. package/workflow/thread/WorkflowTaskThread.js +60 -105
  103. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  104. package/workflow/thread/task-presentation.d.ts.map +1 -1
  105. package/workflow/thread/task-presentation.js +7 -2
  106. package/workflow/thread/task-presentation.js.map +1 -1
  107. package/workflow/useWorkflowExecutionPanel.d.ts +0 -12
  108. package/workflow/useWorkflowExecutionPanel.d.ts.map +1 -1
  109. package/workflow/useWorkflowExecutionPanel.js +0 -12
  110. package/workflow/useWorkflowExecutionPanel.js.map +1 -1
  111. package/execution/agent-execution-document.d.ts +0 -31
  112. package/execution/agent-execution-document.d.ts.map +0 -1
  113. package/execution/agent-execution-document.js +0 -48
  114. package/execution/agent-execution-document.js.map +0 -1
  115. package/src/execution/__tests__/agent-execution-document.test.ts +0 -45
  116. package/src/execution/agent-execution-document.ts +0 -54
  117. package/src/workflow/WorkflowAgentExecutionDocument.tsx +0 -283
  118. package/workflow/WorkflowAgentExecutionDocument.d.ts +0 -79
  119. package/workflow/WorkflowAgentExecutionDocument.d.ts.map +0 -1
  120. package/workflow/WorkflowAgentExecutionDocument.js +0 -87
  121. 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 read-only (at-a-glance); editing lives in the
364
- // Manage access dialog, the single writer for both access axes.
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 visibility={meta.visibility} />
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
+ });
@@ -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 { UseLiveAgentExecutionReturn } from "./useLiveAgentExecution.js";
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 = fetched !== null && !isTerminalPhase(fetchedPhase);
120
+ const shouldStream =
121
+ live && fetched !== null && !isTerminalPhase(fetchedPhase);
100
122
  const stream = useExecutionStream(shouldStream ? executionId : null);
101
123
 
102
- const execution = stream.execution ?? fetched;
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
- WorkflowAgentExecutionDocument,
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
- WorkflowAgentExecutionDocumentProps,
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
- * Read-only visibility indicator with a matching icon, covering all four
414
- * levels (Private / Organization / Platform / Public).
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
- readonly visibility: ApiResourceVisibility;
427
- readonly className?: string;
428
- }) {
445
+ onClick,
446
+ }: VisibilityBadgeProps) {
429
447
  const option = visibilityOption(visibility);
430
- return (
431
- <span className={cn(VISIBILITY_CHIP_CLASS, className)}>
448
+ const content = (
449
+ <>
432
450
  <VisibilityIcon tone={option.tone} className="size-2.5" />
433
451
  {option.label}
434
- </span>
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
  }