@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.
- package/activity/entry-status-badge.d.ts +23 -0
- package/activity/entry-status-badge.d.ts.map +1 -0
- package/activity/entry-status-badge.js +18 -0
- package/activity/entry-status-badge.js.map +1 -0
- package/activity/format-relative-time.d.ts +16 -0
- package/activity/format-relative-time.d.ts.map +1 -0
- package/activity/format-relative-time.js +38 -0
- package/activity/format-relative-time.js.map +1 -0
- package/activity/index.d.ts +2 -0
- package/activity/index.d.ts.map +1 -1
- package/activity/index.js +2 -0
- package/activity/index.js.map +1 -1
- 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 +7 -7
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- 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/activity/__tests__/format-relative-time.test.ts +40 -0
- package/src/activity/entry-status-badge.ts +39 -0
- package/src/activity/format-relative-time.ts +38 -0
- package/src/activity/index.ts +7 -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 +7 -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
|
+
});
|
|
@@ -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
|
+
}
|
package/src/activity/index.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|