@stigmer/react 3.2.1 → 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 (137) hide show
  1. package/activity/entry-status-badge.d.ts +23 -0
  2. package/activity/entry-status-badge.d.ts.map +1 -0
  3. package/activity/entry-status-badge.js +18 -0
  4. package/activity/entry-status-badge.js.map +1 -0
  5. package/activity/format-relative-time.d.ts +16 -0
  6. package/activity/format-relative-time.d.ts.map +1 -0
  7. package/activity/format-relative-time.js +38 -0
  8. package/activity/format-relative-time.js.map +1 -0
  9. package/activity/index.d.ts +2 -0
  10. package/activity/index.d.ts.map +1 -1
  11. package/activity/index.js +2 -0
  12. package/activity/index.js.map +1 -1
  13. package/agent/AgentDetailView.d.ts.map +1 -1
  14. package/agent/AgentDetailView.js +6 -3
  15. package/agent/AgentDetailView.js.map +1 -1
  16. package/execution/index.d.ts +1 -2
  17. package/execution/index.d.ts.map +1 -1
  18. package/execution/index.js +0 -1
  19. package/execution/index.js.map +1 -1
  20. package/execution/useLiveAgentExecution.d.ts +17 -1
  21. package/execution/useLiveAgentExecution.d.ts.map +1 -1
  22. package/execution/useLiveAgentExecution.js +19 -4
  23. package/execution/useLiveAgentExecution.js.map +1 -1
  24. package/index.d.ts +7 -7
  25. package/index.d.ts.map +1 -1
  26. package/index.js +3 -3
  27. package/index.js.map +1 -1
  28. package/internal/useInViewport.d.ts +24 -0
  29. package/internal/useInViewport.d.ts.map +1 -0
  30. package/internal/useInViewport.js +49 -0
  31. package/internal/useInViewport.js.map +1 -0
  32. package/library/VisibilitySelector.d.ts +22 -6
  33. package/library/VisibilitySelector.d.ts.map +1 -1
  34. package/library/VisibilitySelector.js +12 -4
  35. package/library/VisibilitySelector.js.map +1 -1
  36. package/library/index.d.ts +1 -1
  37. package/library/index.d.ts.map +1 -1
  38. package/library/index.js.map +1 -1
  39. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  40. package/mcp-server/McpServerDetailView.js +4 -3
  41. package/mcp-server/McpServerDetailView.js.map +1 -1
  42. package/package.json +4 -4
  43. package/session/SessionViewer.js +8 -1
  44. package/session/SessionViewer.js.map +1 -1
  45. package/session/audience.d.ts +6 -1
  46. package/session/audience.d.ts.map +1 -1
  47. package/skill/SkillDetailView.d.ts.map +1 -1
  48. package/skill/SkillDetailView.js +4 -3
  49. package/skill/SkillDetailView.js.map +1 -1
  50. package/src/access/__tests__/useManageAccess.test.tsx +104 -0
  51. package/src/activity/__tests__/format-relative-time.test.ts +40 -0
  52. package/src/activity/entry-status-badge.ts +39 -0
  53. package/src/activity/format-relative-time.ts +38 -0
  54. package/src/activity/index.ts +7 -0
  55. package/src/agent/AgentDetailView.tsx +9 -3
  56. package/src/execution/__tests__/useLiveAgentExecution.test.tsx +125 -0
  57. package/src/execution/index.ts +4 -7
  58. package/src/execution/useLiveAgentExecution.ts +41 -3
  59. package/src/index.ts +7 -5
  60. package/src/internal/__tests__/useInViewport.test.tsx +115 -0
  61. package/src/internal/useInViewport.ts +63 -0
  62. package/src/library/VisibilitySelector.tsx +50 -9
  63. package/src/library/__tests__/VisibilitySelector.test.tsx +38 -0
  64. package/src/library/index.ts +4 -1
  65. package/src/mcp-server/McpServerDetailView.tsx +7 -3
  66. package/src/session/SessionViewer.tsx +7 -1
  67. package/src/session/__tests__/audienceWiring.test.tsx +31 -1
  68. package/src/session/audience.ts +6 -1
  69. package/src/skill/SkillDetailView.tsx +7 -3
  70. package/src/workflow/WorkflowAgentCallTranscript.tsx +279 -0
  71. package/src/workflow/WorkflowDetailView.tsx +7 -3
  72. package/src/workflow/WorkflowExecutionViewer.tsx +20 -77
  73. package/src/workflow/WorkflowTaskApprovalSummary.tsx +45 -8
  74. package/src/workflow/__tests__/WorkflowAgentCallTranscript.liveregion.test.tsx +124 -0
  75. package/src/workflow/__tests__/{WorkflowAgentExecutionDocument.test.tsx → WorkflowAgentCallTranscript.test.tsx} +146 -121
  76. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +81 -220
  77. package/src/workflow/__tests__/WorkflowExecutionViewer.reconciliation.test.tsx +17 -16
  78. package/src/workflow/__tests__/WorkflowTaskApprovalSummary.test.tsx +52 -2
  79. package/src/workflow/__tests__/WorkflowTaskThread.test.tsx +168 -185
  80. package/src/workflow/__tests__/agent-call-live-experience.test.ts +4 -4
  81. package/src/workflow/__tests__/task-approval.test.ts +119 -0
  82. package/src/workflow/__tests__/task-presentation.test.ts +35 -1
  83. package/src/workflow/__tests__/useWorkflowExecutionPanel.test.ts +0 -57
  84. package/src/workflow/index.ts +6 -3
  85. package/src/workflow/task-detail/index.ts +3 -0
  86. package/src/workflow/task-detail/task-approval.ts +95 -1
  87. package/src/workflow/thread/WorkflowTaskThread.tsx +96 -252
  88. package/src/workflow/thread/task-presentation.ts +8 -2
  89. package/src/workflow/useWorkflowExecutionPanel.ts +0 -47
  90. package/workflow/WorkflowAgentCallTranscript.d.ts +87 -0
  91. package/workflow/WorkflowAgentCallTranscript.d.ts.map +1 -0
  92. package/workflow/WorkflowAgentCallTranscript.js +98 -0
  93. package/workflow/WorkflowAgentCallTranscript.js.map +1 -0
  94. package/workflow/WorkflowDetailView.d.ts.map +1 -1
  95. package/workflow/WorkflowDetailView.js +4 -3
  96. package/workflow/WorkflowDetailView.js.map +1 -1
  97. package/workflow/WorkflowExecutionViewer.d.ts.map +1 -1
  98. package/workflow/WorkflowExecutionViewer.js +23 -52
  99. package/workflow/WorkflowExecutionViewer.js.map +1 -1
  100. package/workflow/WorkflowTaskApprovalSummary.d.ts +1 -1
  101. package/workflow/WorkflowTaskApprovalSummary.d.ts.map +1 -1
  102. package/workflow/WorkflowTaskApprovalSummary.js +18 -1
  103. package/workflow/WorkflowTaskApprovalSummary.js.map +1 -1
  104. package/workflow/index.d.ts +2 -2
  105. package/workflow/index.d.ts.map +1 -1
  106. package/workflow/index.js +2 -2
  107. package/workflow/index.js.map +1 -1
  108. package/workflow/task-detail/index.d.ts +1 -1
  109. package/workflow/task-detail/index.d.ts.map +1 -1
  110. package/workflow/task-detail/index.js +1 -1
  111. package/workflow/task-detail/index.js.map +1 -1
  112. package/workflow/task-detail/task-approval.d.ts +44 -1
  113. package/workflow/task-detail/task-approval.d.ts.map +1 -1
  114. package/workflow/task-detail/task-approval.js +47 -0
  115. package/workflow/task-detail/task-approval.js.map +1 -1
  116. package/workflow/thread/WorkflowTaskThread.d.ts +17 -25
  117. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  118. package/workflow/thread/WorkflowTaskThread.js +60 -105
  119. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  120. package/workflow/thread/task-presentation.d.ts.map +1 -1
  121. package/workflow/thread/task-presentation.js +7 -2
  122. package/workflow/thread/task-presentation.js.map +1 -1
  123. package/workflow/useWorkflowExecutionPanel.d.ts +0 -12
  124. package/workflow/useWorkflowExecutionPanel.d.ts.map +1 -1
  125. package/workflow/useWorkflowExecutionPanel.js +0 -12
  126. package/workflow/useWorkflowExecutionPanel.js.map +1 -1
  127. package/execution/agent-execution-document.d.ts +0 -31
  128. package/execution/agent-execution-document.d.ts.map +0 -1
  129. package/execution/agent-execution-document.js +0 -48
  130. package/execution/agent-execution-document.js.map +0 -1
  131. package/src/execution/__tests__/agent-execution-document.test.ts +0 -45
  132. package/src/execution/agent-execution-document.ts +0 -54
  133. package/src/workflow/WorkflowAgentExecutionDocument.tsx +0 -283
  134. package/workflow/WorkflowAgentExecutionDocument.d.ts +0 -79
  135. package/workflow/WorkflowAgentExecutionDocument.d.ts.map +0 -1
  136. package/workflow/WorkflowAgentExecutionDocument.js +0 -87
  137. 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
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatRelativeTime } from "../format-relative-time";
3
+
4
+ // The recents rows' "why is this here" stamp: compact single-unit ages,
5
+ // then short dates. See format-relative-time.ts for the design rationale.
6
+
7
+ describe("formatRelativeTime", () => {
8
+ const now = new Date("2026-07-18T12:00:00Z");
9
+
10
+ it("renders sub-minute ages as 'now'", () => {
11
+ expect(formatRelativeTime(new Date("2026-07-18T11:59:30Z"), now)).toBe("now");
12
+ });
13
+
14
+ it("clamps future stamps (writer clock skew) to 'now'", () => {
15
+ expect(formatRelativeTime(new Date("2026-07-18T12:00:45Z"), now)).toBe("now");
16
+ });
17
+
18
+ it("renders minutes under an hour", () => {
19
+ expect(formatRelativeTime(new Date("2026-07-18T11:15:00Z"), now)).toBe("45m");
20
+ });
21
+
22
+ it("renders hours under a day", () => {
23
+ expect(formatRelativeTime(new Date("2026-07-18T04:30:00Z"), now)).toBe("7h");
24
+ });
25
+
26
+ it("renders days under a week", () => {
27
+ expect(formatRelativeTime(new Date("2026-07-15T12:00:00Z"), now)).toBe("3d");
28
+ });
29
+
30
+ it("renders a short date beyond a week, same year", () => {
31
+ const result = formatRelativeTime(new Date("2026-06-01T12:00:00Z"), now);
32
+ expect(result).toMatch(/Jun/);
33
+ expect(result).not.toMatch(/2026/);
34
+ });
35
+
36
+ it("includes the year for prior-year dates", () => {
37
+ const result = formatRelativeTime(new Date("2025-06-01T12:00:00Z"), now);
38
+ expect(result).toMatch(/2025/);
39
+ });
40
+ });
@@ -0,0 +1,39 @@
1
+ import type { RecentActivityEntry } from "./types.js";
2
+
3
+ /**
4
+ * A tiny status annotation for a recents row, or `null` when the row
5
+ * should stay clean.
6
+ *
7
+ * Only NOTEWORTHY execution states earn a badge: a completed run is the
8
+ * expected outcome (annotating every row would be noise), and sessions
9
+ * carry no phase at all. Paired with the relative-time stamp, the badge
10
+ * explains why an old-named execution sits high in a last-activity-sorted
11
+ * list ("failed · 2h" — it just failed, that's the activity).
12
+ */
13
+ export interface RecentActivityStatusBadge {
14
+ /** Lowercase phase word, rendered verbatim (e.g. "failed"). */
15
+ readonly label: string;
16
+ /** Rendering tone: `"destructive"` for terminal failures, else `"muted"`. */
17
+ readonly tone: "destructive" | "muted";
18
+ }
19
+
20
+ const BADGED_STATUSES: ReadonlyMap<string, RecentActivityStatusBadge["tone"]> =
21
+ new Map([
22
+ ["failed", "destructive"],
23
+ ["terminated", "destructive"],
24
+ ["cancelled", "muted"],
25
+ ["running", "muted"],
26
+ ["pending", "muted"],
27
+ ]);
28
+
29
+ /**
30
+ * Resolves the status badge for a recent-activity entry. Returns `null`
31
+ * for sessions, completed executions, and unknown phases.
32
+ */
33
+ export function recentActivityStatusBadge(
34
+ entry: RecentActivityEntry,
35
+ ): RecentActivityStatusBadge | null {
36
+ if (entry.type !== "workflow_execution" || !entry.status) return null;
37
+ const tone = BADGED_STATUSES.get(entry.status);
38
+ return tone ? { label: entry.status, tone } : null;
39
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Compact relative-time formatter for recent-activity rows.
3
+ *
4
+ * The recents list sorts by LAST ACTIVITY (`statusAudit.updatedAt`), while
5
+ * execution names embed their CREATION time — so an old run legitimately
6
+ * re-bumped by a status change (a cancel, a late failure) sorts above
7
+ * newer-named items and reads as "wrong order" unless the row says why it
8
+ * is there. This stamp is that explanation: it renders the sort key the
9
+ * user cannot otherwise see.
10
+ *
11
+ * Compact single-unit output — a sidebar row suffix, not prose:
12
+ * `now`, `5m`, `3h`, `6d`, then a short date (`Jul 12`, `Jul 12, 2025`)
13
+ * once "days ago" stops being how people think about it.
14
+ */
15
+ export function formatRelativeTime(date: Date, now?: Date): string {
16
+ const ref = now ?? new Date();
17
+ const deltaMs = ref.getTime() - date.getTime();
18
+
19
+ // Clock skew between writers can put a stamp slightly in the future;
20
+ // render it as "now" rather than a nonsense negative age.
21
+ if (deltaMs < 60_000) return "now";
22
+
23
+ const minutes = Math.floor(deltaMs / 60_000);
24
+ if (minutes < 60) return `${minutes}m`;
25
+
26
+ const hours = Math.floor(minutes / 60);
27
+ if (hours < 24) return `${hours}h`;
28
+
29
+ const days = Math.floor(hours / 24);
30
+ if (days < 7) return `${days}d`;
31
+
32
+ const sameYear = date.getFullYear() === ref.getFullYear();
33
+ return date.toLocaleDateString(undefined, {
34
+ month: "short",
35
+ day: "numeric",
36
+ ...(sameYear ? {} : { year: "numeric" }),
37
+ });
38
+ }
@@ -12,3 +12,10 @@ export {
12
12
  } from "./useRecentActivity.js";
13
13
 
14
14
  export { groupRecentActivityByTime } from "./group-activity.js";
15
+
16
+ export { formatRelativeTime } from "./format-relative-time.js";
17
+
18
+ export {
19
+ recentActivityStatusBadge,
20
+ type RecentActivityStatusBadge,
21
+ } from "./entry-status-badge.js";
@@ -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
@@ -217,11 +217,14 @@ export type {
217
217
  export {
218
218
  useRecentActivity,
219
219
  groupRecentActivityByTime,
220
+ formatRelativeTime,
221
+ recentActivityStatusBadge,
220
222
  } from "./activity/index.js";
221
223
  export type {
222
224
  RecentActivityType,
223
225
  RecentActivityEntry,
224
226
  RecentActivityGroup,
227
+ RecentActivityStatusBadge,
225
228
  UseRecentActivityOptions,
226
229
  UseRecentActivityReturn,
227
230
  } from "./activity/index.js";
@@ -343,9 +346,6 @@ export {
343
346
  toFileDiffEntry,
344
347
  FILE_CHANGE_DOCUMENT_ENTRY_ID,
345
348
  fileChangeTabPath,
346
- AGENT_EXECUTION_DOCUMENT_ENTRY_ID,
347
- agentExecutionTabPath,
348
- parseAgentExecutionTabPath,
349
349
  EmptyChangeNotice,
350
350
  isTextArtifact,
351
351
  formatArtifactSize,
@@ -360,6 +360,7 @@ export type {
360
360
  SharedAgentExecutionFields,
361
361
  UseCreateAgentExecutionReturn,
362
362
  UseExecutionStreamReturn,
363
+ UseLiveAgentExecutionOptions,
363
364
  UseLiveAgentExecutionReturn,
364
365
  UseAgentExecutionActionsOptions,
365
366
  UseAgentExecutionActionsReturn,
@@ -1173,6 +1174,7 @@ export type {
1173
1174
  UseImportResourceReturn,
1174
1175
  ImportResourceDialogProps,
1175
1176
  VisibilitySelectorProps,
1177
+ VisibilityBadgeProps,
1176
1178
  VisibilityLevelOption,
1177
1179
  BlueprintVisibilityLevelsContext,
1178
1180
  VisibilityResourceKind,
@@ -1482,7 +1484,7 @@ export {
1482
1484
  WorkflowChangesTab,
1483
1485
  WorkflowUsageTab,
1484
1486
  WorkflowArtifactDocument,
1485
- WorkflowAgentExecutionDocument,
1487
+ WorkflowAgentCallTranscript,
1486
1488
  useWorkflowArtifactDownload,
1487
1489
  useWorkflowExecutionFileChanges,
1488
1490
  enumerateAgentCallChildren,
@@ -1624,7 +1626,7 @@ export type {
1624
1626
  UseWorkflowExecutionFileChangesOptions,
1625
1627
  UseWorkflowExecutionFileChangesReturn,
1626
1628
  WorkflowArtifactDocumentProps,
1627
- WorkflowAgentExecutionDocumentProps,
1629
+ WorkflowAgentCallTranscriptProps,
1628
1630
  WorkflowAgentExecutionHitl,
1629
1631
  UseWorkflowArtifactDownloadReturn,
1630
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
+ });