@stigmer/react 3.5.2 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/agent/AgentPicker.d.ts +10 -1
  2. package/agent/AgentPicker.d.ts.map +1 -1
  3. package/agent/AgentPicker.js +2 -2
  4. package/agent/AgentPicker.js.map +1 -1
  5. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  6. package/channel/AgentChannelsPanel.js +22 -5
  7. package/channel/AgentChannelsPanel.js.map +1 -1
  8. package/channel/ChannelTemplatesDialog.d.ts +53 -0
  9. package/channel/ChannelTemplatesDialog.d.ts.map +1 -0
  10. package/channel/ChannelTemplatesDialog.js +150 -0
  11. package/channel/ChannelTemplatesDialog.js.map +1 -0
  12. package/channel/index.d.ts +8 -0
  13. package/channel/index.d.ts.map +1 -1
  14. package/channel/index.js +4 -0
  15. package/channel/index.js.map +1 -1
  16. package/channel/providers.d.ts +12 -0
  17. package/channel/providers.d.ts.map +1 -1
  18. package/channel/providers.js +14 -2
  19. package/channel/providers.js.map +1 -1
  20. package/channel/templatePresentation.d.ts +37 -0
  21. package/channel/templatePresentation.d.ts.map +1 -0
  22. package/channel/templatePresentation.js +69 -0
  23. package/channel/templatePresentation.js.map +1 -0
  24. package/channel/useChannelTemplateList.d.ts +64 -0
  25. package/channel/useChannelTemplateList.d.ts.map +1 -0
  26. package/channel/useChannelTemplateList.js +69 -0
  27. package/channel/useChannelTemplateList.js.map +1 -0
  28. package/channel/useChannelTemplateReadiness.d.ts +53 -0
  29. package/channel/useChannelTemplateReadiness.d.ts.map +1 -0
  30. package/channel/useChannelTemplateReadiness.js +42 -0
  31. package/channel/useChannelTemplateReadiness.js.map +1 -0
  32. package/dashboard/useDashboardFailedRuns.d.ts.map +1 -1
  33. package/dashboard/useDashboardFailedRuns.js +2 -0
  34. package/dashboard/useDashboardFailedRuns.js.map +1 -1
  35. package/index.d.ts +4 -2
  36. package/index.d.ts.map +1 -1
  37. package/index.js +9 -1
  38. package/index.js.map +1 -1
  39. package/library/useExportResource.d.ts +4 -3
  40. package/library/useExportResource.d.ts.map +1 -1
  41. package/library/useExportResource.js +1 -1
  42. package/library/useExportResource.js.map +1 -1
  43. package/organization/OrgProvider.d.ts +11 -4
  44. package/organization/OrgProvider.d.ts.map +1 -1
  45. package/organization/OrgProvider.js +28 -4
  46. package/organization/OrgProvider.js.map +1 -1
  47. package/package.json +4 -4
  48. package/resource-detail/useDeleteResource.d.ts +1 -1
  49. package/resource-detail/useDeleteResource.d.ts.map +1 -1
  50. package/resource-detail/useDeleteResource.js +5 -0
  51. package/resource-detail/useDeleteResource.js.map +1 -1
  52. package/resource-workbench/components/ResourceWorkbench.d.ts +10 -1
  53. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  54. package/resource-workbench/components/ResourceWorkbench.js +6 -2
  55. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  56. package/resource-workbench/hooks/useResourceCollection.d.ts +8 -0
  57. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  58. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  59. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  60. package/schedule/CadenceField.d.ts +31 -0
  61. package/schedule/CadenceField.d.ts.map +1 -0
  62. package/schedule/CadenceField.js +116 -0
  63. package/schedule/CadenceField.js.map +1 -0
  64. package/schedule/ScheduleDetailView.d.ts +58 -0
  65. package/schedule/ScheduleDetailView.d.ts.map +1 -0
  66. package/schedule/ScheduleDetailView.js +236 -0
  67. package/schedule/ScheduleDetailView.js.map +1 -0
  68. package/schedule/ScheduleForm.d.ts +43 -0
  69. package/schedule/ScheduleForm.d.ts.map +1 -0
  70. package/schedule/ScheduleForm.js +129 -0
  71. package/schedule/ScheduleForm.js.map +1 -0
  72. package/schedule/ScheduleRowActions.d.ts +20 -0
  73. package/schedule/ScheduleRowActions.d.ts.map +1 -0
  74. package/schedule/ScheduleRowActions.js +54 -0
  75. package/schedule/ScheduleRowActions.js.map +1 -0
  76. package/schedule/TimeZoneField.d.ts +32 -0
  77. package/schedule/TimeZoneField.d.ts.map +1 -0
  78. package/schedule/TimeZoneField.js +104 -0
  79. package/schedule/TimeZoneField.js.map +1 -0
  80. package/schedule/cadence.d.ts +95 -0
  81. package/schedule/cadence.d.ts.map +1 -0
  82. package/schedule/cadence.js +242 -0
  83. package/schedule/cadence.js.map +1 -0
  84. package/schedule/index.d.ts +31 -0
  85. package/schedule/index.d.ts.map +1 -0
  86. package/schedule/index.js +16 -0
  87. package/schedule/index.js.map +1 -0
  88. package/schedule/scheduleColumns.d.ts +26 -0
  89. package/schedule/scheduleColumns.d.ts.map +1 -0
  90. package/schedule/scheduleColumns.js +77 -0
  91. package/schedule/scheduleColumns.js.map +1 -0
  92. package/schedule/scheduleListFn.d.ts +50 -0
  93. package/schedule/scheduleListFn.d.ts.map +1 -0
  94. package/schedule/scheduleListFn.js +65 -0
  95. package/schedule/scheduleListFn.js.map +1 -0
  96. package/schedule/scheduleState.d.ts +43 -0
  97. package/schedule/scheduleState.d.ts.map +1 -0
  98. package/schedule/scheduleState.js +67 -0
  99. package/schedule/scheduleState.js.map +1 -0
  100. package/schedule/useCreateSchedule.d.ts +57 -0
  101. package/schedule/useCreateSchedule.d.ts.map +1 -0
  102. package/schedule/useCreateSchedule.js +61 -0
  103. package/schedule/useCreateSchedule.js.map +1 -0
  104. package/schedule/useResumeSchedule.d.ts +30 -0
  105. package/schedule/useResumeSchedule.d.ts.map +1 -0
  106. package/schedule/useResumeSchedule.js +45 -0
  107. package/schedule/useResumeSchedule.js.map +1 -0
  108. package/schedule/useSchedule.d.ts +39 -0
  109. package/schedule/useSchedule.d.ts.map +1 -0
  110. package/schedule/useSchedule.js +44 -0
  111. package/schedule/useSchedule.js.map +1 -0
  112. package/schedule/useScheduleCount.d.ts +38 -0
  113. package/schedule/useScheduleCount.d.ts.map +1 -0
  114. package/schedule/useScheduleCount.js +30 -0
  115. package/schedule/useScheduleCount.js.map +1 -0
  116. package/schedule/useScheduleList.d.ts +48 -0
  117. package/schedule/useScheduleList.d.ts.map +1 -0
  118. package/schedule/useScheduleList.js +43 -0
  119. package/schedule/useScheduleList.js.map +1 -0
  120. package/schedule/useSetScheduleEnabled.d.ts +31 -0
  121. package/schedule/useSetScheduleEnabled.d.ts.map +1 -0
  122. package/schedule/useSetScheduleEnabled.js +53 -0
  123. package/schedule/useSetScheduleEnabled.js.map +1 -0
  124. package/schedule/useTriggerSchedule.d.ts +28 -0
  125. package/schedule/useTriggerSchedule.d.ts.map +1 -0
  126. package/schedule/useTriggerSchedule.js +43 -0
  127. package/schedule/useTriggerSchedule.js.map +1 -0
  128. package/search/useResourceCount.d.ts +2 -1
  129. package/search/useResourceCount.d.ts.map +1 -1
  130. package/search/useResourceCount.js +6 -1
  131. package/search/useResourceCount.js.map +1 -1
  132. package/src/agent/AgentPicker.tsx +13 -1
  133. package/src/channel/AgentChannelsPanel.tsx +43 -1
  134. package/src/channel/ChannelTemplatesDialog.tsx +433 -0
  135. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +73 -0
  136. package/src/channel/__tests__/ChannelTemplatesDialog.test.tsx +292 -0
  137. package/src/channel/__tests__/providers.test.ts +9 -0
  138. package/src/channel/__tests__/templatePresentation.test.ts +96 -0
  139. package/src/channel/__tests__/useChannelTemplateList.test.tsx +216 -0
  140. package/src/channel/__tests__/useChannelTemplateReadiness.test.tsx +124 -0
  141. package/src/channel/index.ts +15 -0
  142. package/src/channel/providers.ts +26 -2
  143. package/src/channel/templatePresentation.ts +82 -0
  144. package/src/channel/useChannelTemplateList.ts +123 -0
  145. package/src/channel/useChannelTemplateReadiness.ts +69 -0
  146. package/src/dashboard/__tests__/useDashboardFailedRuns.test.tsx +106 -0
  147. package/src/dashboard/useDashboardFailedRuns.ts +2 -0
  148. package/src/execution/__tests__/PlanArtifactCard.test.tsx +23 -2
  149. package/src/index.ts +65 -0
  150. package/src/library/useExportResource.ts +4 -3
  151. package/src/organization/OrgProvider.tsx +29 -4
  152. package/src/organization/__tests__/OrgProvider.orgSwitch.test.tsx +89 -0
  153. package/src/resource-detail/useDeleteResource.ts +11 -1
  154. package/src/resource-workbench/__tests__/ResourceWorkbench.test.tsx +112 -0
  155. package/src/resource-workbench/components/ResourceWorkbench.tsx +32 -15
  156. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  157. package/src/schedule/CadenceField.tsx +365 -0
  158. package/src/schedule/ScheduleDetailView.tsx +583 -0
  159. package/src/schedule/ScheduleForm.tsx +405 -0
  160. package/src/schedule/ScheduleRowActions.tsx +124 -0
  161. package/src/schedule/TimeZoneField.tsx +199 -0
  162. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +260 -0
  163. package/src/schedule/__tests__/cadence.test.ts +278 -0
  164. package/src/schedule/__tests__/scheduleColumnsRowActions.test.tsx +176 -0
  165. package/src/schedule/__tests__/scheduleCreation.test.tsx +340 -0
  166. package/src/schedule/__tests__/scheduleHooks.test.tsx +312 -0
  167. package/src/schedule/__tests__/scheduleListFn.test.ts +128 -0
  168. package/src/schedule/__tests__/scheduleState.test.ts +133 -0
  169. package/src/schedule/cadence.ts +298 -0
  170. package/src/schedule/index.ts +57 -0
  171. package/src/schedule/scheduleColumns.tsx +118 -0
  172. package/src/schedule/scheduleListFn.ts +108 -0
  173. package/src/schedule/scheduleState.ts +96 -0
  174. package/src/schedule/useCreateSchedule.ts +88 -0
  175. package/src/schedule/useResumeSchedule.ts +67 -0
  176. package/src/schedule/useSchedule.ts +67 -0
  177. package/src/schedule/useScheduleCount.ts +57 -0
  178. package/src/schedule/useScheduleList.ts +83 -0
  179. package/src/schedule/useSetScheduleEnabled.ts +84 -0
  180. package/src/schedule/useTriggerSchedule.ts +65 -0
  181. package/src/search/__tests__/useResourceCount.test.tsx +89 -0
  182. package/src/search/useResourceCount.ts +8 -2
  183. package/src/workflow/WorkflowDashboard.tsx +1 -0
  184. package/src/workflow/useWorkflowExecutionList.ts +13 -2
  185. package/styles.css +1 -1
  186. package/workflow/CanvasTransitionEdge.d.ts +1 -1
  187. package/workflow/WorkflowDashboard.d.ts.map +1 -1
  188. package/workflow/WorkflowDashboard.js +1 -0
  189. package/workflow/WorkflowDashboard.js.map +1 -1
  190. package/workflow/useWorkflowExecutionList.d.ts +10 -0
  191. package/workflow/useWorkflowExecutionList.d.ts.map +1 -1
  192. package/workflow/useWorkflowExecutionList.js +3 -2
  193. package/workflow/useWorkflowExecutionList.js.map +1 -1
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderHook } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
5
+ import { AgentChannelInstallState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/status_pb";
6
+ import { DeploymentModeContext } from "../../deployment-mode";
7
+ import { useChannelTemplateReadiness } from "../useChannelTemplateReadiness";
8
+
9
+ function makeChannel(overrides?: {
10
+ installState?: AgentChannelInstallState;
11
+ enabled?: boolean;
12
+ proactiveMessagingEnabled?: boolean;
13
+ }): AgentChannel {
14
+ return {
15
+ metadata: { id: "ach_1", org: "acme", slug: "wa-main" },
16
+ spec: {
17
+ enabled: overrides?.enabled ?? true,
18
+ proactiveMessagingEnabled: overrides?.proactiveMessagingEnabled ?? true,
19
+ },
20
+ status: {
21
+ installState:
22
+ overrides?.installState ?? AgentChannelInstallState.installed,
23
+ },
24
+ } as unknown as AgentChannel;
25
+ }
26
+
27
+ function wrapper(mode: "cloud" | "local" = "cloud") {
28
+ return function Wrapper({ children }: { children: ReactNode }) {
29
+ return (
30
+ <DeploymentModeContext.Provider value={mode}>
31
+ {children}
32
+ </DeploymentModeContext.Provider>
33
+ );
34
+ };
35
+ }
36
+
37
+ function readinessOf(
38
+ channel: AgentChannel,
39
+ mode: "cloud" | "local" = "cloud",
40
+ ) {
41
+ const { result } = renderHook(() => useChannelTemplateReadiness(channel), {
42
+ wrapper: wrapper(mode),
43
+ });
44
+ return result.current;
45
+ }
46
+
47
+ describe("useChannelTemplateReadiness", () => {
48
+ it("is ready for an installed, enabled, proactive channel on cloud", () => {
49
+ expect(readinessOf(makeChannel())).toEqual({ status: "ready" });
50
+ });
51
+
52
+ it("is cloud-only on a local deployment — the template registry is cloud runtime", () => {
53
+ expect(readinessOf(makeChannel(), "local")).toEqual({
54
+ status: "cloud-only",
55
+ });
56
+ });
57
+
58
+ it("is not-installed for a pending install", () => {
59
+ expect(
60
+ readinessOf(
61
+ makeChannel({
62
+ installState: AgentChannelInstallState.pending_install,
63
+ }),
64
+ ),
65
+ ).toEqual({ status: "not-installed" });
66
+ });
67
+
68
+ it("is not-installed for a revoked install — coarse on purpose, describeChannel carries the distinction", () => {
69
+ expect(
70
+ readinessOf(
71
+ makeChannel({ installState: AgentChannelInstallState.revoked }),
72
+ ),
73
+ ).toEqual({ status: "not-installed" });
74
+ });
75
+
76
+ it("is channel-off when the serving switch is off", () => {
77
+ expect(readinessOf(makeChannel({ enabled: false }))).toEqual({
78
+ status: "channel-off",
79
+ });
80
+ });
81
+
82
+ it("is not-proactive when the grant is missing", () => {
83
+ expect(
84
+ readinessOf(makeChannel({ proactiveMessagingEnabled: false })),
85
+ ).toEqual({ status: "not-proactive" });
86
+ });
87
+
88
+ // The gate order is the server's (ChannelMessagingReach.resolveDirect:
89
+ // installed → enabled → proactive). A channel failing several gates
90
+ // must report the FIRST, so the teaching state names the next fix.
91
+ it("resolves gates in the server's order — first failure wins", () => {
92
+ expect(
93
+ readinessOf(
94
+ makeChannel({
95
+ installState: AgentChannelInstallState.pending_install,
96
+ enabled: false,
97
+ proactiveMessagingEnabled: false,
98
+ }),
99
+ "local",
100
+ ),
101
+ ).toEqual({ status: "cloud-only" });
102
+
103
+ expect(
104
+ readinessOf(
105
+ makeChannel({
106
+ installState: AgentChannelInstallState.pending_install,
107
+ enabled: false,
108
+ proactiveMessagingEnabled: false,
109
+ }),
110
+ ),
111
+ ).toEqual({ status: "not-installed" });
112
+
113
+ expect(
114
+ readinessOf(
115
+ makeChannel({ enabled: false, proactiveMessagingEnabled: false }),
116
+ ),
117
+ ).toEqual({ status: "channel-off" });
118
+ });
119
+
120
+ it("treats a channel with no status/spec as not yet installed", () => {
121
+ const bare = { metadata: { id: "ach_2" } } as unknown as AgentChannel;
122
+ expect(readinessOf(bare)).toEqual({ status: "not-installed" });
123
+ });
124
+ });
@@ -17,6 +17,9 @@ export {
17
17
  } from "./providerPresentation.js";
18
18
  export type { ChannelProviderPresentation } from "./providerPresentation.js";
19
19
 
20
+ export { templateStatusPhase, splitTemplateBody } from "./templatePresentation.js";
21
+ export type { TemplateBodySegment } from "./templatePresentation.js";
22
+
20
23
  export { useAgentChannel } from "./useAgentChannel.js";
21
24
  export type { UseAgentChannelReturn } from "./useAgentChannel.js";
22
25
 
@@ -43,9 +46,18 @@ export type {
43
46
 
44
47
  export { useChannelToolReadiness } from "./useChannelToolReadiness.js";
45
48
 
49
+ export { useChannelTemplateReadiness } from "./useChannelTemplateReadiness.js";
50
+ export type { ChannelTemplateReadiness } from "./useChannelTemplateReadiness.js";
51
+
46
52
  export { useChannelSessions } from "./useChannelSessions.js";
47
53
  export type { UseChannelSessionsReturn } from "./useChannelSessions.js";
48
54
 
55
+ export { useChannelTemplateList } from "./useChannelTemplateList.js";
56
+ export type {
57
+ UseChannelTemplateListOptions,
58
+ UseChannelTemplateListReturn,
59
+ } from "./useChannelTemplateList.js";
60
+
49
61
  export { AgentChannelsPanel } from "./AgentChannelsPanel.js";
50
62
  export type { AgentChannelsPanelProps } from "./AgentChannelsPanel.js";
51
63
 
@@ -63,3 +75,6 @@ export type { ChannelToolCredentialsProps } from "./ChannelToolCredentials.js";
63
75
 
64
76
  export { ChannelConversationsDialog } from "./ChannelConversationsDialog.js";
65
77
  export type { ChannelConversationsDialogProps } from "./ChannelConversationsDialog.js";
78
+
79
+ export { ChannelTemplatesDialog } from "./ChannelTemplatesDialog.js";
80
+ export type { ChannelTemplatesDialogProps } from "./ChannelTemplatesDialog.js";
@@ -43,6 +43,18 @@ export interface ChannelProviderDescriptor {
43
43
  readonly Icon: ComponentType<{ readonly className?: string }>;
44
44
  /** How this provider's install flow runs. */
45
45
  readonly installStyle: ChannelInstallStyle;
46
+ /**
47
+ * Whether this provider has a message-template registry the console
48
+ * can list (`listTemplates`). Mirrors the server's proactive-sender
49
+ * registry, which is the de-facto provider filter behind that RPC —
50
+ * a provider with no registered sender answers `PROVIDER_UNSUPPORTED`.
51
+ *
52
+ * `false` for Slack is not a gap: Slack has no template concept at
53
+ * all, so consumers hide the Templates affordance entirely rather
54
+ * than disable it — a disabled item would falsely imply it may work
55
+ * there one day.
56
+ */
57
+ readonly supportsMessageTemplates: boolean;
46
58
  }
47
59
 
48
60
  /**
@@ -55,8 +67,20 @@ export interface ChannelProviderDescriptor {
55
67
  * demos. A third entry is the signal to fold these into a provider menu.
56
68
  */
57
69
  export const CHANNEL_PROVIDERS: readonly ChannelProviderDescriptor[] = [
58
- { id: "slack", label: "Slack", Icon: SlackMarkIcon, installStyle: "redirect" },
59
- { id: "whatsapp", label: "WhatsApp", Icon: WhatsAppMarkIcon, installStyle: "direct" },
70
+ {
71
+ id: "slack",
72
+ label: "Slack",
73
+ Icon: SlackMarkIcon,
74
+ installStyle: "redirect",
75
+ supportsMessageTemplates: false,
76
+ },
77
+ {
78
+ id: "whatsapp",
79
+ label: "WhatsApp",
80
+ Icon: WhatsAppMarkIcon,
81
+ installStyle: "direct",
82
+ supportsMessageTemplates: true,
83
+ },
60
84
  ];
61
85
 
62
86
  /**
@@ -0,0 +1,82 @@
1
+ import type { StatusPhase } from "../resource-workbench/types.js";
2
+
3
+ /**
4
+ * Display helpers for provider message templates — the template
5
+ * counterpart of {@link ChannelProviderPresentation}'s module split:
6
+ * `ChannelTemplatesDialog` renders, this module decides how a
7
+ * provider-verbatim template projects onto the SDK's display
8
+ * vocabulary. Pure functions, one writer each.
9
+ */
10
+
11
+ /**
12
+ * Map a provider-verbatim template status onto a {@link StatusPhase}
13
+ * for `StatusBadge` coloring.
14
+ *
15
+ * The wire carries the provider's own vocabulary, never a Stigmer enum
16
+ * (DD-003 D6) — so the caller renders the verbatim status string as the
17
+ * badge label and uses this phase only for the color. WhatsApp's
18
+ * observed set exceeds the documented five (IN_APPEAL, LIMIT_EXCEEDED,
19
+ * PENDING_DELETION, DELETED occur, and Meta can add more), so unknown
20
+ * statuses deliberately land on `draft` — a neutral tone that renders
21
+ * the provider's word without claiming to understand it.
22
+ */
23
+ export function templateStatusPhase(status: string): StatusPhase {
24
+ switch (status) {
25
+ case "APPROVED":
26
+ return "ready";
27
+ case "PENDING":
28
+ case "IN_APPEAL":
29
+ return "pending";
30
+ case "PAUSED":
31
+ case "LIMIT_EXCEEDED":
32
+ return "degraded";
33
+ case "REJECTED":
34
+ case "DISABLED":
35
+ return "failed";
36
+ case "PENDING_DELETION":
37
+ case "DELETED":
38
+ return "disabled";
39
+ default:
40
+ return "draft";
41
+ }
42
+ }
43
+
44
+ /** One segment of a template body: literal text or a `{{...}}` placeholder. */
45
+ export interface TemplateBodySegment {
46
+ readonly kind: "text" | "placeholder";
47
+ /** The verbatim slice, braces included for placeholders (`"{{1}}"`). */
48
+ readonly value: string;
49
+ }
50
+
51
+ /**
52
+ * The provider's placeholder shape: `{{1}}` (positional) or
53
+ * `{{customer_name}}` (named). Single braces and unbalanced braces are
54
+ * literal text — the provider would not have accepted them either.
55
+ */
56
+ const PLACEHOLDER = /\{\{\s*[A-Za-z0-9_]+\s*\}\}/g;
57
+
58
+ /**
59
+ * Split a template body into text and placeholder segments so the
60
+ * placeholders — the parts the agent fills at send time — can render
61
+ * visually distinct from the literal copy.
62
+ *
63
+ * The concatenation of all segment values is always the input,
64
+ * verbatim: this function classifies, it never rewrites.
65
+ */
66
+ export function splitTemplateBody(
67
+ body: string,
68
+ ): readonly TemplateBodySegment[] {
69
+ const segments: TemplateBodySegment[] = [];
70
+ let lastIndex = 0;
71
+ for (const match of body.matchAll(PLACEHOLDER)) {
72
+ if (match.index > lastIndex) {
73
+ segments.push({ kind: "text", value: body.slice(lastIndex, match.index) });
74
+ }
75
+ segments.push({ kind: "placeholder", value: match[0] });
76
+ lastIndex = match.index + match[0].length;
77
+ }
78
+ if (lastIndex < body.length) {
79
+ segments.push({ kind: "text", value: body.slice(lastIndex) });
80
+ }
81
+ return segments;
82
+ }
@@ -0,0 +1,123 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import type { ChannelTemplate } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
6
+ import { ListChannelTemplatesInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
7
+ import { useStigmer } from "../hooks.js";
8
+ import { useFetch } from "../internal/useFetch.js";
9
+
10
+ /** Options for {@link useChannelTemplateList}. */
11
+ export interface UseChannelTemplateListOptions {
12
+ /**
13
+ * When `true`, only templates the provider will accept for sending
14
+ * are returned (WhatsApp: status APPROVED). The console leaves it
15
+ * unset to render every status with its badge; agent-composition
16
+ * surfaces set it so a model never drafts against a paused or
17
+ * pending template.
18
+ */
19
+ readonly approvedOnly?: boolean;
20
+ }
21
+
22
+ /** Return value of {@link useChannelTemplateList}. */
23
+ export interface UseChannelTemplateListReturn {
24
+ /**
25
+ * The channel provider's templates, verbatim, in provider order.
26
+ * Empty while loading, on error, and when the registry answered with
27
+ * no templates — `templates.length === 0 && !isLoading && !error`
28
+ * means the provider reported none, which callers should phrase as
29
+ * "none found" rather than "none exist" (an empty success is
30
+ * indistinguishable from a filtered or truncated one).
31
+ */
32
+ readonly templates: readonly ChannelTemplate[];
33
+ /** `true` while the initial fetch is in flight and no data is shown. */
34
+ readonly isLoading: boolean;
35
+ /** `true` while a background refetch is in flight and stale data is shown. */
36
+ readonly isRefetching: boolean;
37
+ /** Error from the last failed request, or `null` when healthy. */
38
+ readonly error: Error | null;
39
+ /** Re-fetch the templates from the server. */
40
+ readonly refetch: () => void;
41
+ }
42
+
43
+ /**
44
+ * How often to re-read the registry while any template is PENDING.
45
+ * Matches the server's template-cache TTL (60s) — polling faster only
46
+ * re-reads a cached answer.
47
+ */
48
+ const PENDING_POLL_INTERVAL_MS = 60_000;
49
+
50
+ /**
51
+ * Data hook that lists a channel's message templates as the channel's
52
+ * provider registry reports them (`listTemplates`) — every field
53
+ * provider-verbatim, including approval `status`, the provider's
54
+ * `rejectionReason`, and Stigmer's own `unsupportedReason` (why this
55
+ * platform version cannot send an otherwise-approved template).
56
+ *
57
+ * The channel is addressed by `slug` + `org` (not id), because that is
58
+ * the RPC's key — the provider registry is derived from the channel's
59
+ * phone number, not stored on any Stigmer resource. Pass an empty
60
+ * `channelSlug` or `org` to skip fetching (stable no-op).
61
+ *
62
+ * While any entry is PENDING the hook polls every 60 seconds — the
63
+ * server's registry-cache TTL, so a faster interval would only re-read
64
+ * a cached answer. There is deliberately no `refetchInterval` option:
65
+ * the pending-only conditional poll is the hook's policy, and
66
+ * `approvedOnly` (a wire field) is the only knob exposed.
67
+ *
68
+ * Results are never cached across mounts: a template's approval status
69
+ * can change on the provider's side at any time, and rendering a stale
70
+ * "APPROVED" is exactly what this surface must not do.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * const { templates, isLoading, error } = useChannelTemplateList(
75
+ * channel.metadata?.slug ?? "",
76
+ * channel.metadata?.org ?? "",
77
+ * );
78
+ * ```
79
+ */
80
+ export function useChannelTemplateList(
81
+ channelSlug: string,
82
+ org: string,
83
+ options?: UseChannelTemplateListOptions,
84
+ ): UseChannelTemplateListReturn {
85
+ const stigmer = useStigmer();
86
+ const approvedOnly = options?.approvedOnly ?? false;
87
+
88
+ // The pending-only poll rides useFetch's own guarded interval (the
89
+ // ONE polling implementation) rather than a second setInterval here.
90
+ // The interval cannot be derived inline — it depends on the data the
91
+ // same useFetch call returns — so it round-trips through state: data
92
+ // arrives, the effect below flips the interval, useFetch re-arms.
93
+ const [pollInterval, setPollInterval] = useState<number | false>(false);
94
+
95
+ const fetchFn =
96
+ channelSlug && org
97
+ ? async () => {
98
+ const result = await stigmer.agentChannel.listTemplates(
99
+ create(ListChannelTemplatesInputSchema, {
100
+ channel: channelSlug,
101
+ org,
102
+ approvedOnly,
103
+ }),
104
+ );
105
+ return result.entries;
106
+ }
107
+ : null;
108
+
109
+ const { data: templates, isLoading, isRefetching, error, refetch } =
110
+ useFetch(
111
+ fetchFn,
112
+ [channelSlug, org, approvedOnly, stigmer],
113
+ [] as ChannelTemplate[],
114
+ { refetchInterval: pollInterval },
115
+ );
116
+
117
+ const hasPending = templates.some((t) => t.status === "PENDING");
118
+ useEffect(() => {
119
+ setPollInterval(hasPending ? PENDING_POLL_INTERVAL_MS : false);
120
+ }, [hasPending]);
121
+
122
+ return { templates, isLoading, isRefetching, error, refetch };
123
+ }
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
4
+ import { AgentChannelInstallState } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/status_pb";
5
+ import { useDeploymentMode } from "../deployment-mode.js";
6
+
7
+ /**
8
+ * Whether a channel can answer a template-registry read
9
+ * (`listTemplates`), and if not, which precondition fails first.
10
+ *
11
+ * - `cloud-only` — the deployment is not cloud; the template registry
12
+ * is part of the cloud channel runtime.
13
+ * - `not-installed` — the provider install has not completed (covers
14
+ * both pending and revoked; render the per-provider
15
+ * `describeChannel` sentence, which already distinguishes them).
16
+ * - `channel-off` — the owner's serving switch (`spec.enabled`) is off.
17
+ * - `not-proactive` — `spec.proactive_messaging_enabled` is not set;
18
+ * business-initiated messaging (and with it the template surface)
19
+ * is not granted on this channel.
20
+ * - `ready` — the read can be made.
21
+ */
22
+ export type ChannelTemplateReadiness =
23
+ | { readonly status: "cloud-only" }
24
+ | { readonly status: "not-installed" }
25
+ | { readonly status: "channel-off" }
26
+ | { readonly status: "not-proactive" }
27
+ | { readonly status: "ready" };
28
+
29
+ /**
30
+ * Resolve whether a channel's template registry can be read, without a
31
+ * network call — the courtesy pre-check for the Templates surface.
32
+ *
33
+ * Mirrors the server's own gate order for `listTemplates`
34
+ * (`ChannelMessagingReach.resolveDirect`: installed → enabled →
35
+ * proactive grant), answering from fields already on the fetched
36
+ * {@link AgentChannel}. The server stays authoritative: a `ready`
37
+ * verdict here only means the call is worth making, and whatever the
38
+ * server then says wins.
39
+ *
40
+ * Two shape choices are deliberate. `channel-off` and `not-proactive`
41
+ * are split although the server collapses them into one refusal —
42
+ * their fixes differ (flip the card's serving switch vs. grant
43
+ * `proactive_messaging_enabled`). And `not-installed` stays coarse
44
+ * across pending/revoked, because `providerPresentation`'s
45
+ * `describeChannel` already renders the right sentence for each.
46
+ *
47
+ * @example
48
+ * ```tsx
49
+ * const readiness = useChannelTemplateReadiness(channel);
50
+ * if (readiness.status !== "ready") return <TeachingState ... />;
51
+ * ```
52
+ */
53
+ export function useChannelTemplateReadiness(
54
+ channel: AgentChannel,
55
+ ): ChannelTemplateReadiness {
56
+ const deploymentMode = useDeploymentMode();
57
+
58
+ if (deploymentMode !== "cloud") return { status: "cloud-only" };
59
+ if (
60
+ channel.status?.installState !== AgentChannelInstallState.installed
61
+ ) {
62
+ return { status: "not-installed" };
63
+ }
64
+ if (!channel.spec?.enabled) return { status: "channel-off" };
65
+ if (!channel.spec.proactiveMessagingEnabled) {
66
+ return { status: "not-proactive" };
67
+ }
68
+ return { status: "ready" };
69
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { ExecutionPhase as AgentPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
5
+ import { ExecutionPhase as WorkflowPhase } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/enum_pb";
6
+ import { StigmerContext } from "../../context";
7
+ import { FetchCacheContext } from "../../internal/FetchCacheProvider";
8
+ import { useDashboardFailedRuns } from "../useDashboardFailedRuns";
9
+
10
+ function createMockStigmer(overrides: {
11
+ agentList?: (...args: unknown[]) => Promise<unknown>;
12
+ workflowList?: (...args: unknown[]) => Promise<unknown>;
13
+ } = {}) {
14
+ return {
15
+ agentExecution: {
16
+ list: overrides.agentList ?? vi.fn().mockResolvedValue({ entries: [] }),
17
+ },
18
+ workflowExecution: {
19
+ list: overrides.workflowList ?? vi.fn().mockResolvedValue({ entries: [] }),
20
+ },
21
+ } as never;
22
+ }
23
+
24
+ function wrapper(client: unknown) {
25
+ return function Wrapper({ children }: { children: ReactNode }) {
26
+ return (
27
+ <FetchCacheContext.Provider value={null}>
28
+ <StigmerContext.Provider value={client as never}>
29
+ {children}
30
+ </StigmerContext.Provider>
31
+ </FetchCacheContext.Provider>
32
+ );
33
+ };
34
+ }
35
+
36
+ describe("useDashboardFailedRuns", () => {
37
+ it("scopes both list requests to the active org and the FAILED phase", async () => {
38
+ const agentList = vi.fn().mockResolvedValue({ entries: [] });
39
+ const workflowList = vi.fn().mockResolvedValue({ entries: [] });
40
+ const client = createMockStigmer({ agentList, workflowList });
41
+
42
+ const { result } = renderHook(() => useDashboardFailedRuns("acme"), {
43
+ wrapper: wrapper(client),
44
+ });
45
+
46
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
47
+
48
+ // Regression guard: the pre-fix hook used org only as a fetch gate and
49
+ // sent no org on the wire, so a brand-new org's dashboard showed other
50
+ // orgs' failures.
51
+ const agentRequest = agentList.mock.calls[0][0];
52
+ expect(agentRequest.org).toBe("acme");
53
+ expect(agentRequest.phase).toBe(AgentPhase.EXECUTION_FAILED);
54
+
55
+ const workflowRequest = workflowList.mock.calls[0][0];
56
+ expect(workflowRequest.org).toBe("acme");
57
+ expect(workflowRequest.phase).toBe(WorkflowPhase.EXECUTION_FAILED);
58
+ });
59
+
60
+ it("does not fetch until an org is available", async () => {
61
+ const agentList = vi.fn().mockResolvedValue({ entries: [] });
62
+ const workflowList = vi.fn().mockResolvedValue({ entries: [] });
63
+ const client = createMockStigmer({ agentList, workflowList });
64
+
65
+ renderHook(() => useDashboardFailedRuns(null), {
66
+ wrapper: wrapper(client),
67
+ });
68
+
69
+ // Give any wrongly-armed fetch a tick to fire.
70
+ await new Promise((resolve) => setTimeout(resolve, 0));
71
+
72
+ expect(agentList).not.toHaveBeenCalled();
73
+ expect(workflowList).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("interleaves agent and workflow failures newest-first, capped at 5", async () => {
77
+ const ts = (secs: number) => ({ seconds: BigInt(secs), nanos: 0 });
78
+ const agentEntries = [1, 3, 5].map((n) => ({
79
+ metadata: { id: `aex_${n}`, name: `agent ${n}` },
80
+ spec: { agentId: `agt_${n}` },
81
+ status: { error: "boom", audit: { specAudit: { createdAt: ts(n) } } },
82
+ }));
83
+ const workflowEntries = [2, 4, 6].map((n) => ({
84
+ metadata: { id: `wex_${n}`, name: `workflow ${n}`, slug: `wf-${n}` },
85
+ status: { error: "boom", audit: { specAudit: { createdAt: ts(n) } } },
86
+ }));
87
+ const client = createMockStigmer({
88
+ agentList: vi.fn().mockResolvedValue({ entries: agentEntries }),
89
+ workflowList: vi.fn().mockResolvedValue({ entries: workflowEntries }),
90
+ });
91
+
92
+ const { result } = renderHook(() => useDashboardFailedRuns("acme"), {
93
+ wrapper: wrapper(client),
94
+ });
95
+
96
+ await waitFor(() => expect(result.current.failedRuns).toHaveLength(5));
97
+
98
+ expect(result.current.failedRuns.map((run) => run.id)).toEqual([
99
+ "wex_6",
100
+ "aex_5",
101
+ "wex_4",
102
+ "aex_3",
103
+ "wex_2",
104
+ ]);
105
+ });
106
+ });
@@ -48,6 +48,7 @@ export function useDashboardFailedRuns(
48
48
  create(ListAgentExecutionsRequestSchema, {
49
49
  pageSize: PAGE_SIZE,
50
50
  phase: AgentPhase.EXECUTION_FAILED,
51
+ org: orgVal,
51
52
  }),
52
53
  );
53
54
  return [...resp.entries] as readonly AgentExecution[];
@@ -64,6 +65,7 @@ export function useDashboardFailedRuns(
64
65
  create(ListWorkflowExecutionsRequestSchema, {
65
66
  pageSize: PAGE_SIZE,
66
67
  phase: WorkflowPhase.EXECUTION_FAILED,
68
+ org: orgVal,
67
69
  }),
68
70
  );
69
71
  return [...resp.entries] as readonly WorkflowExecution[];
@@ -1,5 +1,11 @@
1
- import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { render, screen, fireEvent, cleanup } from "@testing-library/react";
1
+ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
2
+ import {
3
+ render,
4
+ screen,
5
+ fireEvent,
6
+ cleanup,
7
+ waitFor,
8
+ } from "@testing-library/react";
3
9
  import type { ReactNode } from "react";
4
10
  import { create } from "@bufbuild/protobuf";
5
11
  import type { Stigmer } from "@stigmer/sdk";
@@ -47,6 +53,17 @@ function renderCard(ui: ReactNode) {
47
53
 
48
54
  afterEach(cleanup);
49
55
 
56
+ // The download flow ends in a transient-anchor click with target="_blank".
57
+ // Left unstubbed, happy-dom treats that click as a real popup navigation and
58
+ // fetches the fixture URL over the actual network — after this test has
59
+ // already returned, so the DNS failure gets attributed to whichever test the
60
+ // worker is running by then (the flake in issue #334).
61
+ beforeEach(() => {
62
+ vi.spyOn(HTMLAnchorElement.prototype, "click")
63
+ .mockImplementation(() => {})
64
+ .mockClear();
65
+ });
66
+
50
67
  describe("PlanArtifactCard — compact document card", () => {
51
68
  it("is an accessible region with the plan's title, filename, and size", () => {
52
69
  renderCard(
@@ -178,6 +195,7 @@ describe("PlanArtifactCard — compact document card", () => {
178
195
 
179
196
  it("downloads the artifact on demand via a freshly minted URL", async () => {
180
197
  const { stigmer, getArtifactDownloadUrl } = createStigmerMock();
198
+ const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click");
181
199
  render(
182
200
  withStigmer(
183
201
  <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
@@ -191,6 +209,9 @@ describe("PlanArtifactCard — compact document card", () => {
191
209
  const req = getArtifactDownloadUrl.mock.calls[0][0];
192
210
  expect(req.executionId).toBe("aex_1");
193
211
  expect(req.storageKey).toBe("artifacts/aex_1/plan.md");
212
+ // Wait for the mint-URL → anchor-click continuation INSIDE the test body;
213
+ // returning while it is still in flight is what let it escape.
214
+ await waitFor(() => expect(anchorClick).toHaveBeenCalledTimes(1));
194
215
  });
195
216
  });
196
217