@stigmer/react 3.6.0 → 3.8.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 (260) hide show
  1. package/billing/CreditLedgerTable.d.ts.map +1 -1
  2. package/billing/CreditLedgerTable.js +2 -7
  3. package/billing/CreditLedgerTable.js.map +1 -1
  4. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  5. package/channel/AgentChannelsPanel.js +21 -2
  6. package/channel/AgentChannelsPanel.js.map +1 -1
  7. package/channel/ChannelConversationsDialog.d.ts +12 -4
  8. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  9. package/channel/ChannelConversationsDialog.js +18 -24
  10. package/channel/ChannelConversationsDialog.js.map +1 -1
  11. package/channel/providers.d.ts +23 -0
  12. package/channel/providers.d.ts.map +1 -1
  13. package/channel/providers.js +4 -0
  14. package/channel/providers.js.map +1 -1
  15. package/channel/useSaveAgentChannel.d.ts +5 -4
  16. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  17. package/channel/useSaveAgentChannel.js +18 -4
  18. package/channel/useSaveAgentChannel.js.map +1 -1
  19. package/composer/ComposerToolbar.d.ts +6 -1
  20. package/composer/ComposerToolbar.d.ts.map +1 -1
  21. package/composer/ComposerToolbar.js +2 -2
  22. package/composer/ComposerToolbar.js.map +1 -1
  23. package/composer/SessionComposer.d.ts +9 -0
  24. package/composer/SessionComposer.d.ts.map +1 -1
  25. package/composer/SessionComposer.js +13 -2
  26. package/composer/SessionComposer.js.map +1 -1
  27. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  28. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  29. package/conversation/ConversationAttentionBanner.js +25 -0
  30. package/conversation/ConversationAttentionBanner.js.map +1 -0
  31. package/conversation/ConversationComposer.d.ts +41 -0
  32. package/conversation/ConversationComposer.d.ts.map +1 -0
  33. package/conversation/ConversationComposer.js +57 -0
  34. package/conversation/ConversationComposer.js.map +1 -0
  35. package/conversation/ConversationControlBanner.d.ts +52 -0
  36. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  37. package/conversation/ConversationControlBanner.js +59 -0
  38. package/conversation/ConversationControlBanner.js.map +1 -0
  39. package/conversation/ConversationListPane.d.ts +68 -0
  40. package/conversation/ConversationListPane.d.ts.map +1 -0
  41. package/conversation/ConversationListPane.js +120 -0
  42. package/conversation/ConversationListPane.js.map +1 -0
  43. package/conversation/ConversationTimelineView.d.ts +46 -0
  44. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  45. package/conversation/ConversationTimelineView.js +166 -0
  46. package/conversation/ConversationTimelineView.js.map +1 -0
  47. package/conversation/ConversationsWorkbench.d.ts +63 -0
  48. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  49. package/conversation/ConversationsWorkbench.js +165 -0
  50. package/conversation/ConversationsWorkbench.js.map +1 -0
  51. package/conversation/conversationPresentation.d.ts +137 -0
  52. package/conversation/conversationPresentation.d.ts.map +1 -0
  53. package/conversation/conversationPresentation.js +230 -0
  54. package/conversation/conversationPresentation.js.map +1 -0
  55. package/conversation/index.d.ts +26 -0
  56. package/conversation/index.d.ts.map +1 -0
  57. package/conversation/index.js +14 -0
  58. package/conversation/index.js.map +1 -0
  59. package/conversation/polling.d.ts +39 -0
  60. package/conversation/polling.d.ts.map +1 -0
  61. package/conversation/polling.js +39 -0
  62. package/conversation/polling.js.map +1 -0
  63. package/conversation/useConversation.d.ts +61 -0
  64. package/conversation/useConversation.d.ts.map +1 -0
  65. package/conversation/useConversation.js +153 -0
  66. package/conversation/useConversation.js.map +1 -0
  67. package/conversation/useConversationList.d.ts +98 -0
  68. package/conversation/useConversationList.d.ts.map +1 -0
  69. package/conversation/useConversationList.js +200 -0
  70. package/conversation/useConversationList.js.map +1 -0
  71. package/conversation/useConversationParticipation.d.ts +63 -0
  72. package/conversation/useConversationParticipation.d.ts.map +1 -0
  73. package/conversation/useConversationParticipation.js +84 -0
  74. package/conversation/useConversationParticipation.js.map +1 -0
  75. package/conversation/useConversationTimeline.d.ts +71 -0
  76. package/conversation/useConversationTimeline.d.ts.map +1 -0
  77. package/conversation/useConversationTimeline.js +152 -0
  78. package/conversation/useConversationTimeline.js.map +1 -0
  79. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  80. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  81. package/conversation/useConversationsWantsHumanCount.js +40 -0
  82. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  83. package/execution/useCreateAgentExecution.d.ts +14 -0
  84. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  85. package/execution/useCreateAgentExecution.js +5 -0
  86. package/execution/useCreateAgentExecution.js.map +1 -1
  87. package/index.d.ts +6 -2
  88. package/index.d.ts.map +1 -1
  89. package/index.js +14 -5
  90. package/index.js.map +1 -1
  91. package/internal/Pagination.d.ts +15 -0
  92. package/internal/Pagination.d.ts.map +1 -0
  93. package/internal/Pagination.js +20 -0
  94. package/internal/Pagination.js.map +1 -0
  95. package/internal/tooltip.d.ts.map +1 -1
  96. package/internal/tooltip.js +7 -1
  97. package/internal/tooltip.js.map +1 -1
  98. package/internal/useAutoScroll.d.ts +25 -4
  99. package/internal/useAutoScroll.d.ts.map +1 -1
  100. package/internal/useAutoScroll.js +55 -15
  101. package/internal/useAutoScroll.js.map +1 -1
  102. package/internal/useFetch.d.ts.map +1 -1
  103. package/internal/useFetch.js +14 -4
  104. package/internal/useFetch.js.map +1 -1
  105. package/models/ModelSelector.d.ts +45 -1
  106. package/models/ModelSelector.d.ts.map +1 -1
  107. package/models/ModelSelector.js +49 -6
  108. package/models/ModelSelector.js.map +1 -1
  109. package/models/index.d.ts +2 -0
  110. package/models/index.d.ts.map +1 -1
  111. package/models/index.js +1 -0
  112. package/models/index.js.map +1 -1
  113. package/models/registry.d.ts +11 -0
  114. package/models/registry.d.ts.map +1 -1
  115. package/models/registry.js +3 -0
  116. package/models/registry.js.map +1 -1
  117. package/models/service-tier.d.ts +27 -0
  118. package/models/service-tier.d.ts.map +1 -0
  119. package/models/service-tier.js +34 -0
  120. package/models/service-tier.js.map +1 -0
  121. package/package.json +4 -4
  122. package/schedule/ScheduleDetailView.d.ts +31 -8
  123. package/schedule/ScheduleDetailView.d.ts.map +1 -1
  124. package/schedule/ScheduleDetailView.js +441 -29
  125. package/schedule/ScheduleDetailView.js.map +1 -1
  126. package/schedule/ScheduleForm.d.ts +7 -4
  127. package/schedule/ScheduleForm.d.ts.map +1 -1
  128. package/schedule/ScheduleForm.js +94 -8
  129. package/schedule/ScheduleForm.js.map +1 -1
  130. package/schedule/ScheduleRunsTable.d.ts +72 -0
  131. package/schedule/ScheduleRunsTable.d.ts.map +1 -0
  132. package/schedule/ScheduleRunsTable.js +161 -0
  133. package/schedule/ScheduleRunsTable.js.map +1 -0
  134. package/schedule/index.d.ts +6 -0
  135. package/schedule/index.d.ts.map +1 -1
  136. package/schedule/index.js +3 -0
  137. package/schedule/index.js.map +1 -1
  138. package/schedule/useScheduleRuns.d.ts +44 -0
  139. package/schedule/useScheduleRuns.d.ts.map +1 -0
  140. package/schedule/useScheduleRuns.js +48 -0
  141. package/schedule/useScheduleRuns.js.map +1 -0
  142. package/schedule/useTriggerSchedule.d.ts +23 -10
  143. package/schedule/useTriggerSchedule.d.ts.map +1 -1
  144. package/schedule/useTriggerSchedule.js +28 -9
  145. package/schedule/useTriggerSchedule.js.map +1 -1
  146. package/schedule/useUpdateScheduleSpec.d.ts +49 -0
  147. package/schedule/useUpdateScheduleSpec.d.ts.map +1 -0
  148. package/schedule/useUpdateScheduleSpec.js +70 -0
  149. package/schedule/useUpdateScheduleSpec.js.map +1 -0
  150. package/session/useNewSessionFlow.d.ts.map +1 -1
  151. package/session/useNewSessionFlow.js +1 -0
  152. package/session/useNewSessionFlow.js.map +1 -1
  153. package/session/useSessionConversation.d.ts +11 -0
  154. package/session/useSessionConversation.d.ts.map +1 -1
  155. package/session/useSessionConversation.js +1 -0
  156. package/session/useSessionConversation.js.map +1 -1
  157. package/session/useSessionPageFlow.d.ts.map +1 -1
  158. package/session/useSessionPageFlow.js +1 -0
  159. package/session/useSessionPageFlow.js.map +1 -1
  160. package/sharing/useSaveAgentShare.d.ts +10 -1
  161. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  162. package/sharing/useSaveAgentShare.js +9 -0
  163. package/sharing/useSaveAgentShare.js.map +1 -1
  164. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  165. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  166. package/sidebar/WorkspaceSidebar.js +19 -7
  167. package/sidebar/WorkspaceSidebar.js.map +1 -1
  168. package/src/billing/CreditLedgerTable.tsx +3 -42
  169. package/src/channel/AgentChannelsPanel.tsx +33 -2
  170. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  171. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  172. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  173. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  174. package/src/channel/providers.ts +27 -0
  175. package/src/channel/useSaveAgentChannel.ts +18 -4
  176. package/src/composer/ComposerToolbar.tsx +9 -0
  177. package/src/composer/SessionComposer.tsx +24 -1
  178. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +157 -0
  179. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  180. package/src/conversation/ConversationComposer.tsx +156 -0
  181. package/src/conversation/ConversationControlBanner.tsx +193 -0
  182. package/src/conversation/ConversationListPane.tsx +459 -0
  183. package/src/conversation/ConversationTimelineView.tsx +435 -0
  184. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  185. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  186. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  187. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  188. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  189. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  190. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  191. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  192. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  193. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  194. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  195. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  196. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  197. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  198. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  199. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  200. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  201. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  202. package/src/conversation/conversationPresentation.ts +305 -0
  203. package/src/conversation/index.ts +80 -0
  204. package/src/conversation/polling.ts +41 -0
  205. package/src/conversation/useConversation.ts +221 -0
  206. package/src/conversation/useConversationList.ts +328 -0
  207. package/src/conversation/useConversationParticipation.ts +178 -0
  208. package/src/conversation/useConversationTimeline.ts +244 -0
  209. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  210. package/src/execution/useCreateAgentExecution.ts +18 -0
  211. package/src/index.ts +76 -4
  212. package/src/internal/Pagination.tsx +72 -0
  213. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  214. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  215. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  216. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  217. package/src/internal/tooltip.tsx +6 -1
  218. package/src/internal/useAutoScroll.ts +66 -20
  219. package/src/internal/useFetch.ts +15 -4
  220. package/src/models/ModelSelector.tsx +133 -4
  221. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +256 -0
  222. package/src/models/__tests__/useModelRegistry.test.tsx +15 -0
  223. package/src/models/index.ts +2 -0
  224. package/src/models/registry.ts +17 -0
  225. package/src/models/service-tier.ts +49 -0
  226. package/src/schedule/ScheduleDetailView.tsx +1179 -111
  227. package/src/schedule/ScheduleForm.tsx +218 -7
  228. package/src/schedule/ScheduleRunsTable.tsx +424 -0
  229. package/src/schedule/__tests__/ScheduleDetailView.test.tsx +431 -8
  230. package/src/schedule/__tests__/scheduleCreation.test.tsx +197 -0
  231. package/src/schedule/__tests__/scheduleHooks.test.tsx +94 -7
  232. package/src/schedule/index.ts +12 -0
  233. package/src/schedule/useScheduleRuns.ts +93 -0
  234. package/src/schedule/useTriggerSchedule.ts +39 -14
  235. package/src/schedule/useUpdateScheduleSpec.ts +108 -0
  236. package/src/session/useNewSessionFlow.ts +1 -0
  237. package/src/session/useSessionConversation.ts +12 -0
  238. package/src/session/useSessionPageFlow.ts +1 -0
  239. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  240. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  241. package/src/sharing/useSaveAgentShare.ts +19 -1
  242. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  243. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  244. package/src/workflow/__tests__/inspector-forms.test.tsx +119 -2
  245. package/src/workflow/inspector/forms/AgentCallForm.tsx +260 -37
  246. package/src/workflow/inspector/tabs/RuntimeTab.tsx +72 -33
  247. package/src/workflow/starter-workflow-yaml.ts +2 -1
  248. package/styles.css +1 -1
  249. package/workflow/inspector/forms/AgentCallForm.d.ts +18 -2
  250. package/workflow/inspector/forms/AgentCallForm.d.ts.map +1 -1
  251. package/workflow/inspector/forms/AgentCallForm.js +87 -9
  252. package/workflow/inspector/forms/AgentCallForm.js.map +1 -1
  253. package/workflow/inspector/tabs/RuntimeTab.d.ts +2 -1
  254. package/workflow/inspector/tabs/RuntimeTab.d.ts.map +1 -1
  255. package/workflow/inspector/tabs/RuntimeTab.js +31 -5
  256. package/workflow/inspector/tabs/RuntimeTab.js.map +1 -1
  257. package/workflow/starter-workflow-yaml.d.ts +1 -1
  258. package/workflow/starter-workflow-yaml.d.ts.map +1 -1
  259. package/workflow/starter-workflow-yaml.js +2 -1
  260. package/workflow/starter-workflow-yaml.js.map +1 -1
@@ -419,16 +419,47 @@ describe("AgentChannelsPanel", () => {
419
419
  expect(screen.queryByRole("switch")).toBeNull();
420
420
  expect(screen.queryByRole("button", { name: /connect to slack/i })).toBeNull();
421
421
 
422
- // The card menu still renders: Conversations is a viewer-level action
422
+ // The card menu still renders: Sessions is a viewer-level action
423
423
  // (everyone who sees the card holds can_view on the channel — the same
424
- // bar as viewing its conversations, DD-012). Mutation items stay
424
+ // bar as viewing its sessions, DD-012). Mutation items stay
425
425
  // permission-gated and absent.
426
426
  fireEvent.click(screen.getByRole("button", { name: /actions for/i }));
427
- expect(screen.getByRole("menuitem", { name: /conversations/i })).toBeTruthy();
427
+ expect(screen.getByRole("menuitem", { name: /sessions/i })).toBeTruthy();
428
428
  expect(screen.queryByRole("menuitem", { name: /tool credentials/i })).toBeNull();
429
429
  expect(screen.queryByRole("menuitem", { name: /disconnect/i })).toBeNull();
430
430
  });
431
431
 
432
+ it("offers Manage access from the card menu — the channel's canonical access home (F-11)", async () => {
433
+ const client = createMockStigmer({
434
+ channels: [makeChannel({ teamName: "Acme HQ" })],
435
+ });
436
+ // The dialog's People section lists existing grants on open.
437
+ (
438
+ client as { iamPolicy: Record<string, unknown> }
439
+ ).iamPolicy.listResourceAccessByPrincipal = vi
440
+ .fn()
441
+ .mockResolvedValue({ entries: [] });
442
+ render(
443
+ <Providers client={client}>
444
+ <AgentChannelsPanel agent={makeAgent()} />
445
+ </Providers>,
446
+ );
447
+ await waitFor(() => expect(screen.getByText("Support Slack")).toBeTruthy());
448
+
449
+ fireEvent.click(screen.getByRole("button", { name: /actions for/i }));
450
+ fireEvent.click(screen.getByRole("menuitem", { name: /manage access/i }));
451
+
452
+ // The one canonical dialog opens, and its subtitle names the channel
453
+ // — the scope every grant covers (a participant grant is per
454
+ // channel, never per conversation).
455
+ await waitFor(() =>
456
+ expect(screen.getByRole("heading", { name: "Manage access" })).toBeTruthy(),
457
+ );
458
+ const dialog = document.querySelector("dialog");
459
+ expect(dialog).not.toBeNull();
460
+ expect(within(dialog as HTMLElement).getByText("Support Slack")).toBeTruthy();
461
+ });
462
+
432
463
  it("warns on an installed card when a tool-using agent has no credentials bound", async () => {
433
464
  const client = createMockStigmer({
434
465
  channels: [makeChannel({ teamName: "Acme HQ" })],
@@ -655,7 +686,7 @@ describe("AgentChannelsPanel", () => {
655
686
  // Slack has no template concept: hidden, not disabled — a disabled
656
687
  // item would imply the capability might one day exist there.
657
688
  expect(
658
- await screen.findByRole("menuitem", { name: /conversations/i }),
689
+ await screen.findByRole("menuitem", { name: /sessions/i }),
659
690
  ).toBeTruthy();
660
691
  expect(screen.queryByRole("menuitem", { name: "Templates" })).toBeNull();
661
692
  });
@@ -681,7 +712,7 @@ describe("AgentChannelsPanel", () => {
681
712
  fireEvent.click(screen.getByRole("button", { name: /actions for/i }));
682
713
 
683
714
  expect(
684
- await screen.findByRole("menuitem", { name: /conversations/i }),
715
+ await screen.findByRole("menuitem", { name: /sessions/i }),
685
716
  ).toBeTruthy();
686
717
  expect(screen.queryByRole("menuitem", { name: "Templates" })).toBeNull();
687
718
  });
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { render, screen, cleanup, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
4
5
  import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
5
6
  import { StigmerContext } from "../../context";
6
7
  import { FetchCacheContext } from "../../internal/FetchCacheProvider";
@@ -92,11 +93,47 @@ describe("ChannelConversationsDialog", () => {
92
93
  expect(screen.queryAllByRole("link")).toHaveLength(0);
93
94
  });
94
95
 
96
+ it("titles itself Sessions and falls back to Untitled session (F-04, DD-004 D-g)", async () => {
97
+ // The word "conversation" belongs to the customer-facing surface;
98
+ // this dialog is the session-level forensics view.
99
+ const listByChannel = vi.fn().mockResolvedValue({
100
+ entries: [makeChannelSession("ses_1", "")],
101
+ });
102
+
103
+ renderDialog(listByChannel);
104
+
105
+ expect(screen.getByRole("heading", { name: "Sessions" })).toBeDefined();
106
+ await waitFor(() => expect(screen.getByText("Untitled session")).toBeDefined());
107
+ expect(screen.queryByText("Untitled conversation")).toBeNull();
108
+ });
109
+
110
+ it("formats activity in the shared compact style, matching the inbox (F-04)", async () => {
111
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000);
112
+ const listByChannel = vi.fn().mockResolvedValue({
113
+ entries: [
114
+ {
115
+ ...makeChannelSession("ses_1", "Deploy help"),
116
+ status: {
117
+ audit: { statusAudit: { updatedAt: timestampFromDate(fiveMinutesAgo) } },
118
+ },
119
+ },
120
+ ],
121
+ });
122
+
123
+ renderDialog(listByChannel);
124
+
125
+ // The shared helper's compact single-unit style ("5m"), not the
126
+ // retired local formatter's "5m ago" — one time vocabulary across
127
+ // the channel dialog and the conversations inbox.
128
+ await waitFor(() => expect(screen.getByText("5m")).toBeDefined());
129
+ expect(screen.queryByText("5m ago")).toBeNull();
130
+ });
131
+
95
132
  it("shows the empty state when nobody has messaged the channel", async () => {
96
133
  renderDialog(vi.fn().mockResolvedValue({ entries: [] }));
97
134
 
98
135
  await waitFor(() =>
99
- expect(screen.getByText("No conversations yet")).toBeDefined(),
136
+ expect(screen.getByText("No sessions yet")).toBeDefined(),
100
137
  );
101
138
  });
102
139
 
@@ -124,6 +124,32 @@ describe("agentChannelToInput", () => {
124
124
  expect(input).not.toHaveProperty("appRef");
125
125
  });
126
126
 
127
+ it("carries run_config — the toggle must never wipe an API-set execution override", () => {
128
+ const channel = makeChannel({
129
+ spec: {
130
+ agentRef: { org: "acme", slug: "support-agent" },
131
+ enabled: true,
132
+ providerConfig: { case: "slack", value: {} },
133
+ // Set via CLI/API (stigmer/stigmer#360); no console surface edits
134
+ // it, so it must ride through the full-input rebuild opaquely.
135
+ runConfig: { modelName: "gpt-5-mini", maxCostUsd: 0.25, maxToolRounds: 8 },
136
+ },
137
+ });
138
+
139
+ const input = agentChannelToInput(channel);
140
+ expect(input.runConfig).toEqual({
141
+ modelName: "gpt-5-mini",
142
+ maxCostUsd: 0.25,
143
+ maxToolRounds: 8,
144
+ serviceTier: undefined,
145
+ });
146
+ });
147
+
148
+ it("omits runConfig entirely when the channel carries none", () => {
149
+ const input = agentChannelToInput(makeChannel());
150
+ expect(input).not.toHaveProperty("runConfig");
151
+ });
152
+
127
153
  it("omits the slack marker when the provider config is absent", () => {
128
154
  const channel = makeChannel({
129
155
  spec: {
@@ -55,6 +55,29 @@ export interface ChannelProviderDescriptor {
55
55
  * there one day.
56
56
  */
57
57
  readonly supportsMessageTemplates: boolean;
58
+ /**
59
+ * Whether staff can reply into and take over live conversations on
60
+ * this provider. Mirrors the server's `ProactiveMessageSender`
61
+ * registry — the same registry behind `reply`/`takeOver` refusing
62
+ * senderless providers with FAILED_PRECONDITION ("this channel's
63
+ * provider has no send lane for staff messages").
64
+ *
65
+ * Unlike templates, `false` here renders as DISABLED-with-explanation
66
+ * rather than hidden: the send lane is a planned capability (the
67
+ * suppression doors are already wired provider-blind server-side), so
68
+ * the affordance should say why it is off, not pretend it cannot
69
+ * exist. The server refusal stays the authoritative backstop.
70
+ */
71
+ readonly supportsStaffReplies: boolean;
72
+ /**
73
+ * Whether the conversation timeline includes the CUSTOMER's own
74
+ * messages on this provider. Mirrors the cloud stitcher's inbound
75
+ * sources: WhatsApp inbound rides a per-conversation event store;
76
+ * Slack's inbound lane has no timeline source yet, so its timeline
77
+ * shows only replies, sends, and internal events. Consumers render an
78
+ * honest notice instead of a silently one-sided thread.
79
+ */
80
+ readonly timelineIncludesCustomerMessages: boolean;
58
81
  }
59
82
 
60
83
  /**
@@ -73,6 +96,8 @@ export const CHANNEL_PROVIDERS: readonly ChannelProviderDescriptor[] = [
73
96
  Icon: SlackMarkIcon,
74
97
  installStyle: "redirect",
75
98
  supportsMessageTemplates: false,
99
+ supportsStaffReplies: false,
100
+ timelineIncludesCustomerMessages: false,
76
101
  },
77
102
  {
78
103
  id: "whatsapp",
@@ -80,6 +105,8 @@ export const CHANNEL_PROVIDERS: readonly ChannelProviderDescriptor[] = [
80
105
  Icon: WhatsAppMarkIcon,
81
106
  installStyle: "direct",
82
107
  supportsMessageTemplates: true,
108
+ supportsStaffReplies: true,
109
+ timelineIncludesCustomerMessages: true,
83
110
  },
84
111
  ];
85
112
 
@@ -23,10 +23,11 @@ import { toError } from "../internal/toError.js";
23
23
  * carries its declared `phone_number_id`. Dropping the arm is never an
24
24
  * option — the provider oneof is immutable server-side, so a save
25
25
  * without it would be refused outright. The channel's bound tool
26
- * credentials (`environment_refs`) and its channel-app binding
27
- * (`app_ref`) carry over too apply semantics would otherwise silently
28
- * unbind them on every toggle (and an installed channel's app_ref is
29
- * frozen server-side, so dropping it would refuse the save outright).
26
+ * credentials (`environment_refs`), its channel-app binding
27
+ * (`app_ref`), and its per-channel execution override (`run_config`)
28
+ * carry over too apply semantics would otherwise silently unbind
29
+ * them on every toggle (and an installed channel's app_ref is frozen
30
+ * server-side, so dropping it would refuse the save outright).
30
31
  */
31
32
  export function agentChannelToInput(channel: AgentChannel): AgentChannelInput {
32
33
  const metadata = channel.metadata;
@@ -65,6 +66,19 @@ export function agentChannelToInput(channel: AgentChannel): AgentChannelInput {
65
66
  })),
66
67
  }
67
68
  : {}),
69
+ // The console does not edit run_config (yet) — it rides through
70
+ // opaquely so a settings save or pause/resume toggle never wipes an
71
+ // override set via the CLI or API.
72
+ ...(spec?.runConfig
73
+ ? {
74
+ runConfig: {
75
+ modelName: spec.runConfig.modelName,
76
+ maxCostUsd: spec.runConfig.maxCostUsd,
77
+ maxToolRounds: spec.runConfig.maxToolRounds,
78
+ serviceTier: spec.runConfig.serviceTier,
79
+ },
80
+ }
81
+ : {}),
68
82
  };
69
83
  }
70
84
 
@@ -6,6 +6,7 @@ import { ConfigureMenu, type ConfigureMenuItem } from "./ConfigureMenu.js";
6
6
  import { ModelSelector } from "../models/ModelSelector.js";
7
7
  import { HarnessSelector } from "../models/HarnessSelector.js";
8
8
  import type { HarnessOption } from "../models/harness.js";
9
+ import type { ServiceTierOption } from "../models/service-tier.js";
9
10
  import { InteractionModePicker, type InteractionModeOption } from "./InteractionModePicker.js";
10
11
  import {
11
12
  PaperclipIcon,
@@ -42,6 +43,10 @@ export interface ComposerToolbarProps {
42
43
  readonly showModelSelector: boolean;
43
44
  readonly modelId?: string;
44
45
  readonly onModelChange: (id: string) => void;
46
+ /** Current service tier for the selected model (#357). */
47
+ readonly serviceTier?: ServiceTierOption;
48
+ /** Enables the fast-tier switch inside the model selector's options area. */
49
+ readonly onServiceTierChange?: (tier: ServiceTierOption) => void;
45
50
 
46
51
  // -- Right group: Secondary actions (icon-only) ---------------------------
47
52
 
@@ -115,6 +120,8 @@ export function ComposerToolbar({
115
120
  showModelSelector,
116
121
  modelId,
117
122
  onModelChange,
123
+ serviceTier,
124
+ onServiceTierChange,
118
125
  }: ComposerToolbarProps) {
119
126
  const showHarnessSeparate = showHarnessSelector && !showModelSelector;
120
127
 
@@ -146,6 +153,8 @@ export function ComposerToolbar({
146
153
  harness={showHarnessSelector ? undefined : harness}
147
154
  initialHarness={showHarnessSelector ? harness : undefined}
148
155
  onHarnessChange={showHarnessSelector ? onHarnessChange : undefined}
156
+ serviceTier={serviceTier}
157
+ onServiceTierChange={onServiceTierChange}
149
158
  disabled={disabled}
150
159
  />
151
160
  )}
@@ -7,6 +7,7 @@ import { useComposer } from "./useComposer.js";
7
7
  import { ComposerToolbar } from "./ComposerToolbar.js";
8
8
  import { type ConfigureMenuItem } from "./ConfigureMenu.js";
9
9
  import type { HarnessOption } from "../models/harness.js";
10
+ import type { ServiceTierOption } from "../models/service-tier.js";
10
11
  import type { InteractionModeOption } from "./InteractionModePicker.js";
11
12
  import { parseModelKey } from "../models/registry.js";
12
13
  import { WorkspaceEditor } from "../workspace/WorkspaceEditor.js";
@@ -147,6 +148,14 @@ export interface SessionComposerSubmitContext {
147
148
  * Pass to execution creation as `execution_config.interaction_mode`.
148
149
  */
149
150
  readonly interactionMode?: InteractionModeOption;
151
+ /**
152
+ * Service tier the user actively selected for this execution
153
+ * (stigmer/stigmer#357). Only ever `"fast"` — an untouched tier means
154
+ * "platform default" and is carried as `undefined`, preserving the
155
+ * unspecified-vs-explicit distinction all the way to the ledger.
156
+ * Pass to execution creation as `execution_config.service_tier`.
157
+ */
158
+ readonly serviceTier?: ServiceTierOption;
150
159
  /**
151
160
  * Workspace-relative file paths the user referenced via drag-to-reference.
152
161
  *
@@ -622,6 +631,11 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
622
631
  setModelIdRaw(id);
623
632
  }, []);
624
633
 
634
+ // Service tier (#357): composer-local state like modelId. "standard" is
635
+ // the resting default; the ModelSelector's fail-safe resets it when the
636
+ // user switches to a model without a fast tier.
637
+ const [serviceTier, setServiceTier] = useState<ServiceTierOption>("standard");
638
+
625
639
  const [displayNames, setDisplayNames] = useState<Map<string, string>>(
626
640
  () => new Map(),
627
641
  );
@@ -850,13 +864,20 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
850
864
  : undefined);
851
865
  const hasFileRefs = enableFileReferences && fileRefs.hasRefs;
852
866
  const buildFromPlan = overrides?.buildFromPlan;
867
+ // Carried only when the user actively chose fast: an untouched
868
+ // tier means "platform default" (which resolves to standard in
869
+ // the runner), and the UNSPECIFIED-vs-explicit distinction is
870
+ // load-bearing telemetry (#357).
871
+ const effectiveServiceTier = serviceTier === "fast" ? serviceTier : undefined;
853
872
 
854
873
  const context: SessionComposerSubmitContext | undefined =
855
874
  hasEnv || hasAttachments || effectiveMode || hasFileRefs || buildFromPlan
875
+ || effectiveServiceTier
856
876
  ? {
857
877
  runtimeEnv: hasEnv ? env : undefined,
858
878
  attachments: hasAttachments ? attachmentInputs : undefined,
859
879
  interactionMode: effectiveMode,
880
+ serviceTier: effectiveServiceTier,
860
881
  workspaceFileRefs: hasFileRefs ? [...fileRefs.refs] : undefined,
861
882
  buildFromPlan,
862
883
  }
@@ -874,7 +895,7 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
874
895
  fileRefs.clear();
875
896
  }
876
897
  },
877
- [onSubmit, modelId, stigmer, agentSetup.state, mcpSetup.pendingRuntimeEnv, sessionVariables, enableAttachments, attachments, personalEnv, showInteractionModePicker, interactionMode],
898
+ [onSubmit, modelId, serviceTier, stigmer, agentSetup.state, mcpSetup.pendingRuntimeEnv, sessionVariables, enableAttachments, attachments, personalEnv, showInteractionModePicker, interactionMode],
878
899
  );
879
900
 
880
901
  const composer = useComposer({
@@ -1614,6 +1635,8 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1614
1635
  showModelSelector={showModelSelector}
1615
1636
  modelId={modelId}
1616
1637
  onModelChange={handleModelChange}
1638
+ serviceTier={serviceTier}
1639
+ onServiceTierChange={setServiceTier}
1617
1640
  />
1618
1641
  </div>
1619
1642
  </div>
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
2
+ import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
3
+ import { type ReactNode } from "react";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { StigmerContext } from "../../context";
6
+ import { ModelRegistryContext } from "../../models/ModelRegistryContext";
7
+ import type { ModelInfo } from "../../models/registry";
8
+ import { SessionComposer } from "../SessionComposer";
9
+
10
+ // Portal into document.body so the popover mounts under happy-dom, and shim
11
+ // ResizeObserver for Base UI's positioner (the scheduleCreation.test.tsx
12
+ // pattern).
13
+ vi.mock("../../portal-container", () => ({
14
+ useStigmerPortalContainer: () => document.body,
15
+ }));
16
+
17
+ beforeAll(() => {
18
+ if (!("ResizeObserver" in globalThis)) {
19
+ (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver =
20
+ class {
21
+ observe() {}
22
+ unobserve() {}
23
+ disconnect() {}
24
+ };
25
+ }
26
+ });
27
+
28
+ /**
29
+ * End-to-end composer wiring for the service tier (stigmer/stigmer#357):
30
+ * the toolbar's ModelSelector toggle must reach composer state, and an
31
+ * active fast selection must ride the submit context to execution create.
32
+ * This seam (SessionComposer → ComposerToolbar props → submit context) is
33
+ * exactly where the feature can silently die while every layer on either
34
+ * side unit-tests green.
35
+ */
36
+
37
+ const FAST_CAPABLE: ModelInfo = {
38
+ modelId: "composer-2.5",
39
+ provider: "cursor",
40
+ displayName: "Composer 2.5",
41
+ shortDescription: "Fast agentic model",
42
+ speedTier: "fastest",
43
+ costTier: "economy",
44
+ harness: "cursor",
45
+ featured: true,
46
+ serviceTiers: ["fast"],
47
+ };
48
+
49
+ function createMinimalStigmerMock(): Stigmer {
50
+ return {
51
+ agentExecution: { uploadAttachment: vi.fn() },
52
+ environment: { getPersonal: vi.fn().mockResolvedValue(null) },
53
+ baseUrl: "http://localhost:8080",
54
+ getAuthCredential: vi.fn().mockResolvedValue("test-token"),
55
+ config: {
56
+ baseUrl: "http://localhost:8080",
57
+ getAccessToken: vi.fn().mockResolvedValue(""),
58
+ },
59
+ } as unknown as Stigmer;
60
+ }
61
+
62
+ function createWrapper(client: Stigmer) {
63
+ return function Wrapper({ children }: { children: ReactNode }) {
64
+ return (
65
+ <StigmerContext.Provider value={client}>
66
+ <ModelRegistryContext.Provider
67
+ value={{
68
+ models: [FAST_CAPABLE],
69
+ isLoading: false,
70
+ error: null,
71
+ refetch: vi.fn(),
72
+ }}
73
+ >
74
+ {children}
75
+ </ModelRegistryContext.Provider>
76
+ </StigmerContext.Provider>
77
+ );
78
+ };
79
+ }
80
+
81
+ function renderComposer() {
82
+ const client = createMinimalStigmerMock();
83
+ const onSubmit = vi.fn();
84
+
85
+ const result = render(
86
+ <SessionComposer
87
+ onSubmit={onSubmit}
88
+ harness="cursor"
89
+ defaultModelId="composer-2.5"
90
+ />,
91
+ { wrapper: createWrapper(client) },
92
+ );
93
+
94
+ return { ...result, onSubmit };
95
+ }
96
+
97
+ function submitMessage(message: string) {
98
+ const textarea = screen.getByRole("textbox");
99
+ fireEvent.change(textarea, { target: { value: message } });
100
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
101
+ }
102
+
103
+ /**
104
+ * Open the model selector popover (Base UI portals mount asynchronously in
105
+ * jsdom — the switch is awaited) and flip the fast-tier switch in the
106
+ * options area, then close the popover (toggling keeps it open).
107
+ */
108
+ async function toggleFastTier() {
109
+ fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
110
+ const toggle = await screen.findByRole("switch", { name: "Fast tier" });
111
+ fireEvent.click(toggle);
112
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
113
+ }
114
+
115
+ afterEach(cleanup);
116
+
117
+ describe("SessionComposer — service tier submit contract", () => {
118
+ it("renders the tier switch through the toolbar for a fast-capable model", async () => {
119
+ renderComposer();
120
+
121
+ fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
122
+ expect(await screen.findByRole("switch", { name: "Fast tier" })).toBeTruthy();
123
+ });
124
+
125
+ it("carries serviceTier 'fast' on the submit context after toggling", async () => {
126
+ const { onSubmit } = renderComposer();
127
+
128
+ await toggleFastTier();
129
+ submitMessage("Run it fast");
130
+
131
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
132
+ const context = onSubmit.mock.calls[0][2];
133
+ expect(context?.serviceTier).toBe("fast");
134
+ });
135
+
136
+ it("leaves serviceTier undefined when the switch is untouched", async () => {
137
+ const { onSubmit } = renderComposer();
138
+
139
+ submitMessage("Run it normally");
140
+
141
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
142
+ const context = onSubmit.mock.calls[0][2];
143
+ expect(context?.serviceTier).toBeUndefined();
144
+ });
145
+
146
+ it("leaves serviceTier undefined after toggling fast on and back off", async () => {
147
+ const { onSubmit } = renderComposer();
148
+
149
+ await toggleFastTier();
150
+ await toggleFastTier();
151
+ submitMessage("Changed my mind");
152
+
153
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
154
+ const context = onSubmit.mock.calls[0][2];
155
+ expect(context?.serviceTier).toBeUndefined();
156
+ });
157
+ });
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { TriangleAlert } from "lucide-react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { getUserMessage } from "@stigmer/sdk";
6
+ import type { ChannelConversation } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/conversation_io_pb";
7
+ import { Button } from "../button/Button.js";
8
+ import type { UseConversationParticipationReturn } from "./useConversationParticipation.js";
9
+
10
+ /** Props for {@link ConversationAttentionBanner}. */
11
+ export interface ConversationAttentionBannerProps {
12
+ /** The conversation's fresh participation state. */
13
+ readonly conversation: ChannelConversation;
14
+ /** The participation commands (from `useConversationParticipation`). */
15
+ readonly participation: UseConversationParticipationReturn;
16
+ /** Additional classes for the banner container. */
17
+ readonly className?: string;
18
+ }
19
+
20
+ /**
21
+ * The needs-attention banner (channel-conversations DD-008): the
22
+ * escalating agent's reason, verbatim, with the false-alarm dismissal.
23
+ *
24
+ * Dismiss is `clearAttention` — attention clears in place and control
25
+ * never moves (taking over also clears it, structurally, because the
26
+ * human arriving IS the answer to an escalation). Renders nothing while
27
+ * the conversation is unflagged.
28
+ */
29
+ export function ConversationAttentionBanner({
30
+ conversation,
31
+ participation,
32
+ className,
33
+ }: ConversationAttentionBannerProps) {
34
+ if (!conversation.needsAttention) return null;
35
+
36
+ const { clearAttention, pendingCommands, commandErrors } = participation;
37
+ const error = commandErrors.get("clearAttention");
38
+
39
+ return (
40
+ <div
41
+ className={cn(
42
+ "border-b border-border bg-destructive-subtle px-4 py-2",
43
+ className,
44
+ )}
45
+ role="status"
46
+ >
47
+ <div className="flex flex-wrap items-center justify-between gap-2">
48
+ <p className="flex min-w-0 items-start gap-1.5 text-sm text-destructive">
49
+ <TriangleAlert aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
50
+ <span className="min-w-0 break-words">
51
+ The agent asked for a human
52
+ {conversation.attentionReason ? (
53
+ <> — &ldquo;{conversation.attentionReason}&rdquo;</>
54
+ ) : (
55
+ "."
56
+ )}
57
+ </span>
58
+ </p>
59
+ <Button
60
+ variant="outline"
61
+ size="xs"
62
+ onClick={() =>
63
+ void clearAttention().catch(() => {
64
+ // Recorded in commandErrors and rendered below.
65
+ })
66
+ }
67
+ disabled={pendingCommands.has("clearAttention")}
68
+ >
69
+ {pendingCommands.has("clearAttention") ? "Dismissing…" : "Dismiss"}
70
+ </Button>
71
+ </div>
72
+ {error && (
73
+ <p className="mt-1 text-xs text-destructive">{getUserMessage(error)}</p>
74
+ )}
75
+ </div>
76
+ );
77
+ }