@stigmer/react 3.7.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 (177) hide show
  1. package/channel/AgentChannelsPanel.d.ts.map +1 -1
  2. package/channel/AgentChannelsPanel.js +21 -2
  3. package/channel/AgentChannelsPanel.js.map +1 -1
  4. package/channel/ChannelConversationsDialog.d.ts +12 -4
  5. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  6. package/channel/ChannelConversationsDialog.js +18 -24
  7. package/channel/ChannelConversationsDialog.js.map +1 -1
  8. package/channel/providers.d.ts +23 -0
  9. package/channel/providers.d.ts.map +1 -1
  10. package/channel/providers.js +4 -0
  11. package/channel/providers.js.map +1 -1
  12. package/channel/useSaveAgentChannel.d.ts +5 -4
  13. package/channel/useSaveAgentChannel.d.ts.map +1 -1
  14. package/channel/useSaveAgentChannel.js +18 -4
  15. package/channel/useSaveAgentChannel.js.map +1 -1
  16. package/composer/ComposerToolbar.d.ts +1 -1
  17. package/composer/ComposerToolbar.d.ts.map +1 -1
  18. package/composer/SessionComposer.d.ts +1 -1
  19. package/composer/SessionComposer.js +1 -1
  20. package/composer/SessionComposer.js.map +1 -1
  21. package/conversation/ConversationAttentionBanner.d.ts +22 -0
  22. package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
  23. package/conversation/ConversationAttentionBanner.js +25 -0
  24. package/conversation/ConversationAttentionBanner.js.map +1 -0
  25. package/conversation/ConversationComposer.d.ts +41 -0
  26. package/conversation/ConversationComposer.d.ts.map +1 -0
  27. package/conversation/ConversationComposer.js +57 -0
  28. package/conversation/ConversationComposer.js.map +1 -0
  29. package/conversation/ConversationControlBanner.d.ts +52 -0
  30. package/conversation/ConversationControlBanner.d.ts.map +1 -0
  31. package/conversation/ConversationControlBanner.js +59 -0
  32. package/conversation/ConversationControlBanner.js.map +1 -0
  33. package/conversation/ConversationListPane.d.ts +68 -0
  34. package/conversation/ConversationListPane.d.ts.map +1 -0
  35. package/conversation/ConversationListPane.js +120 -0
  36. package/conversation/ConversationListPane.js.map +1 -0
  37. package/conversation/ConversationTimelineView.d.ts +46 -0
  38. package/conversation/ConversationTimelineView.d.ts.map +1 -0
  39. package/conversation/ConversationTimelineView.js +166 -0
  40. package/conversation/ConversationTimelineView.js.map +1 -0
  41. package/conversation/ConversationsWorkbench.d.ts +63 -0
  42. package/conversation/ConversationsWorkbench.d.ts.map +1 -0
  43. package/conversation/ConversationsWorkbench.js +165 -0
  44. package/conversation/ConversationsWorkbench.js.map +1 -0
  45. package/conversation/conversationPresentation.d.ts +137 -0
  46. package/conversation/conversationPresentation.d.ts.map +1 -0
  47. package/conversation/conversationPresentation.js +230 -0
  48. package/conversation/conversationPresentation.js.map +1 -0
  49. package/conversation/index.d.ts +26 -0
  50. package/conversation/index.d.ts.map +1 -0
  51. package/conversation/index.js +14 -0
  52. package/conversation/index.js.map +1 -0
  53. package/conversation/polling.d.ts +39 -0
  54. package/conversation/polling.d.ts.map +1 -0
  55. package/conversation/polling.js +39 -0
  56. package/conversation/polling.js.map +1 -0
  57. package/conversation/useConversation.d.ts +61 -0
  58. package/conversation/useConversation.d.ts.map +1 -0
  59. package/conversation/useConversation.js +153 -0
  60. package/conversation/useConversation.js.map +1 -0
  61. package/conversation/useConversationList.d.ts +98 -0
  62. package/conversation/useConversationList.d.ts.map +1 -0
  63. package/conversation/useConversationList.js +200 -0
  64. package/conversation/useConversationList.js.map +1 -0
  65. package/conversation/useConversationParticipation.d.ts +63 -0
  66. package/conversation/useConversationParticipation.d.ts.map +1 -0
  67. package/conversation/useConversationParticipation.js +84 -0
  68. package/conversation/useConversationParticipation.js.map +1 -0
  69. package/conversation/useConversationTimeline.d.ts +71 -0
  70. package/conversation/useConversationTimeline.d.ts.map +1 -0
  71. package/conversation/useConversationTimeline.js +152 -0
  72. package/conversation/useConversationTimeline.js.map +1 -0
  73. package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
  74. package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
  75. package/conversation/useConversationsWantsHumanCount.js +40 -0
  76. package/conversation/useConversationsWantsHumanCount.js.map +1 -0
  77. package/index.d.ts +4 -0
  78. package/index.d.ts.map +1 -1
  79. package/index.js +8 -0
  80. package/index.js.map +1 -1
  81. package/internal/tooltip.d.ts.map +1 -1
  82. package/internal/tooltip.js +7 -1
  83. package/internal/tooltip.js.map +1 -1
  84. package/internal/useAutoScroll.d.ts +25 -4
  85. package/internal/useAutoScroll.d.ts.map +1 -1
  86. package/internal/useAutoScroll.js +55 -15
  87. package/internal/useAutoScroll.js.map +1 -1
  88. package/internal/useFetch.d.ts.map +1 -1
  89. package/internal/useFetch.js +14 -4
  90. package/internal/useFetch.js.map +1 -1
  91. package/models/ModelSelector.d.ts +24 -5
  92. package/models/ModelSelector.d.ts.map +1 -1
  93. package/models/ModelSelector.js +31 -9
  94. package/models/ModelSelector.js.map +1 -1
  95. package/models/registry.d.ts +3 -2
  96. package/models/registry.d.ts.map +1 -1
  97. package/models/registry.js.map +1 -1
  98. package/models/service-tier.d.ts +1 -1
  99. package/models/service-tier.d.ts.map +1 -1
  100. package/models/service-tier.js +1 -1
  101. package/models/service-tier.js.map +1 -1
  102. package/package.json +4 -4
  103. package/schedule/ScheduleDetailView.js +1 -1
  104. package/schedule/ScheduleDetailView.js.map +1 -1
  105. package/schedule/ScheduleForm.d.ts.map +1 -1
  106. package/schedule/ScheduleForm.js +7 -6
  107. package/schedule/ScheduleForm.js.map +1 -1
  108. package/sharing/useSaveAgentShare.d.ts +10 -1
  109. package/sharing/useSaveAgentShare.d.ts.map +1 -1
  110. package/sharing/useSaveAgentShare.js +9 -0
  111. package/sharing/useSaveAgentShare.js.map +1 -1
  112. package/sidebar/WorkspaceSidebar.d.ts +12 -4
  113. package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
  114. package/sidebar/WorkspaceSidebar.js +19 -7
  115. package/sidebar/WorkspaceSidebar.js.map +1 -1
  116. package/src/channel/AgentChannelsPanel.tsx +33 -2
  117. package/src/channel/ChannelConversationsDialog.tsx +22 -25
  118. package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
  119. package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
  120. package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
  121. package/src/channel/providers.ts +27 -0
  122. package/src/channel/useSaveAgentChannel.ts +18 -4
  123. package/src/composer/ComposerToolbar.tsx +1 -1
  124. package/src/composer/SessionComposer.tsx +2 -2
  125. package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
  126. package/src/conversation/ConversationAttentionBanner.tsx +77 -0
  127. package/src/conversation/ConversationComposer.tsx +156 -0
  128. package/src/conversation/ConversationControlBanner.tsx +193 -0
  129. package/src/conversation/ConversationListPane.tsx +459 -0
  130. package/src/conversation/ConversationTimelineView.tsx +435 -0
  131. package/src/conversation/ConversationsWorkbench.tsx +378 -0
  132. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
  133. package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
  134. package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
  135. package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
  136. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
  137. package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
  138. package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
  139. package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
  140. package/src/conversation/__tests__/a11y/harness.tsx +100 -0
  141. package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
  142. package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
  143. package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
  144. package/src/conversation/__tests__/useConversation.test.tsx +243 -0
  145. package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
  146. package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
  147. package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
  148. package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
  149. package/src/conversation/conversationPresentation.ts +305 -0
  150. package/src/conversation/index.ts +80 -0
  151. package/src/conversation/polling.ts +41 -0
  152. package/src/conversation/useConversation.ts +221 -0
  153. package/src/conversation/useConversationList.ts +328 -0
  154. package/src/conversation/useConversationParticipation.ts +178 -0
  155. package/src/conversation/useConversationTimeline.ts +244 -0
  156. package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
  157. package/src/index.ts +67 -0
  158. package/src/internal/__tests__/fake-timer-slices.ts +29 -0
  159. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
  160. package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
  161. package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
  162. package/src/internal/tooltip.tsx +6 -1
  163. package/src/internal/useAutoScroll.ts +66 -20
  164. package/src/internal/useFetch.ts +15 -4
  165. package/src/models/ModelSelector.tsx +81 -49
  166. package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
  167. package/src/models/registry.ts +3 -2
  168. package/src/models/service-tier.ts +1 -1
  169. package/src/schedule/ScheduleDetailView.tsx +1 -1
  170. package/src/schedule/ScheduleForm.tsx +7 -6
  171. package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
  172. package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
  173. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
  174. package/src/sharing/useSaveAgentShare.ts +19 -1
  175. package/src/sidebar/WorkspaceSidebar.tsx +62 -3
  176. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
  177. package/styles.css +1 -1
@@ -7,6 +7,7 @@ import { cn } from "@stigmer/theme";
7
7
  import { getUserMessage } from "@stigmer/sdk";
8
8
  import type { AgentChannel } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/api_pb";
9
9
  import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
10
+ import { formatRelativeTime } from "../activity/format-relative-time.js";
10
11
  import { Button } from "../button/Button.js";
11
12
  import { EmptyState } from "../empty-state/EmptyState.js";
12
13
  import { channelSessionExternalUserKey } from "../session/channelOrigin.js";
@@ -37,11 +38,19 @@ export interface ChannelConversationsDialogProps {
37
38
  }
38
39
 
39
40
  /**
40
- * Lists the conversations a channel created — the Slack/WhatsApp sessions
41
- * the channel runtime owns, visible to exactly the channel's viewers (the
42
- * connector and org admins; design decision 012).
41
+ * Lists the SESSIONS a channel created — the session-level forensics view
42
+ * (which execution containers served the channel's traffic), visible to
43
+ * exactly the channel's viewers (the connector and org admins; design
44
+ * decision 012).
43
45
  *
44
- * Each row shows the conversation subject, the external platform user it
46
+ * Deliberately titled "Sessions", not "Conversations": the customer-facing
47
+ * Conversations surface is the top-level `ConversationsWorkbench` over the
48
+ * conversation timeline API, which supersedes this read for that purpose
49
+ * (channel-conversations DD-004 D-g). This dialog remains what it actually
50
+ * is — the observability view underneath a conversation. The component
51
+ * name keeps its historical export for API stability.
52
+ *
53
+ * Each row shows the session subject, the external platform user it
45
54
  * belongs to (an opaque provider id in v1), and the last activity time.
46
55
  * Rows link to the host's session route, where `SessionViewer` renders the
47
56
  * transcript read-only (the observer audience — channel viewers hold
@@ -126,10 +135,10 @@ function ChannelConversationsDialogBody({
126
135
  id="channel-conversations-title"
127
136
  className="text-sm font-semibold text-popover-foreground"
128
137
  >
129
- Conversations
138
+ Sessions
130
139
  </h2>
131
140
  <p className="mt-0.5 truncate text-xs text-muted-foreground">
132
- What people asked {channelName} — read-only, visible to the
141
+ The sessions {channelName} created — read-only, visible to the
133
142
  channel&apos;s owner and org admins.
134
143
  </p>
135
144
  </div>
@@ -137,7 +146,7 @@ function ChannelConversationsDialogBody({
137
146
  variant="ghost"
138
147
  size="xs"
139
148
  onClick={onClose}
140
- aria-label="Close conversations"
149
+ aria-label="Close sessions"
141
150
  >
142
151
  <X className="size-4" />
143
152
  </Button>
@@ -154,8 +163,8 @@ function ChannelConversationsDialogBody({
154
163
  <EmptyState
155
164
  variant="first-use"
156
165
  icon={<MessageSquare className="size-8" />}
157
- title="No conversations yet"
158
- description="When someone messages this channel, their conversation appears here."
166
+ title="No sessions yet"
167
+ description="When someone messages this channel, the sessions serving them appear here."
159
168
  />
160
169
  ) : (
161
170
  <ul className="space-y-1">
@@ -184,7 +193,7 @@ function ConversationRow({
184
193
  readonly session: Session;
185
194
  readonly href?: string;
186
195
  }) {
187
- const subject = session.spec?.subject || "Untitled conversation";
196
+ const subject = session.spec?.subject || "Untitled session";
188
197
  const externalUser = channelSessionExternalUserKey(session);
189
198
  const lastActivity = lastActivityDate(session);
190
199
 
@@ -242,9 +251,9 @@ function ConversationsSkeleton() {
242
251
  }
243
252
 
244
253
  /**
245
- * The conversation's last activity: `statusAudit.updatedAt` (bumped on
246
- * every meaningful status change), falling back to `specAudit.createdAt`
247
- * the recent-activity sort key, so the dialog agrees with the server's
254
+ * The session's last activity: `statusAudit.updatedAt` (bumped on every
255
+ * meaningful status change), falling back to `specAudit.createdAt`
256
+ * the recent-activity sort key, so the dialog agrees with the server's
248
257
  * ordering.
249
258
  */
250
259
  function lastActivityDate(session: Session): Date | null {
@@ -253,15 +262,3 @@ function lastActivityDate(session: Session): Date | null {
253
262
  session.status?.audit?.specAudit?.createdAt;
254
263
  return ts ? timestampDate(ts) : null;
255
264
  }
256
-
257
- function formatRelativeTime(date: Date): string {
258
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
259
- if (seconds < 60) return "just now";
260
- const minutes = Math.floor(seconds / 60);
261
- if (minutes < 60) return `${minutes}m ago`;
262
- const hours = Math.floor(minutes / 60);
263
- if (hours < 24) return `${hours}h ago`;
264
- const days = Math.floor(hours / 24);
265
- if (days < 30) return `${days}d ago`;
266
- return date.toLocaleDateString();
267
- }
@@ -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
 
@@ -45,7 +45,7 @@ export interface ComposerToolbarProps {
45
45
  readonly onModelChange: (id: string) => void;
46
46
  /** Current service tier for the selected model (#357). */
47
47
  readonly serviceTier?: ServiceTierOption;
48
- /** Enables the fast-tier toggle inside the model selector. */
48
+ /** Enables the fast-tier switch inside the model selector's options area. */
49
49
  readonly onServiceTierChange?: (tier: ServiceTierOption) => void;
50
50
 
51
51
  // -- Right group: Secondary actions (icon-only) ---------------------------
@@ -150,7 +150,7 @@ export interface SessionComposerSubmitContext {
150
150
  readonly interactionMode?: InteractionModeOption;
151
151
  /**
152
152
  * Service tier the user actively selected for this execution
153
- * (stigmer/stigmer#357). Only ever `"fast"` — an untouched toggle means
153
+ * (stigmer/stigmer#357). Only ever `"fast"` — an untouched tier means
154
154
  * "platform default" and is carried as `undefined`, preserving the
155
155
  * unspecified-vs-explicit distinction all the way to the ledger.
156
156
  * Pass to execution creation as `execution_config.service_tier`.
@@ -865,7 +865,7 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
865
865
  const hasFileRefs = enableFileReferences && fileRefs.hasRefs;
866
866
  const buildFromPlan = overrides?.buildFromPlan;
867
867
  // Carried only when the user actively chose fast: an untouched
868
- // toggle means "platform default" (which resolves to standard in
868
+ // tier means "platform default" (which resolves to standard in
869
869
  // the runner), and the UNSPECIFIED-vs-explicit distinction is
870
870
  // load-bearing telemetry (#357).
871
871
  const effectiveServiceTier = serviceTier === "fast" ? serviceTier : undefined;
@@ -102,7 +102,8 @@ function submitMessage(message: string) {
102
102
 
103
103
  /**
104
104
  * Open the model selector popover (Base UI portals mount asynchronously in
105
- * jsdom — the switch is awaited) and flip the fast-tier toggle.
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).
106
107
  */
107
108
  async function toggleFastTier() {
108
109
  fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
@@ -114,7 +115,7 @@ async function toggleFastTier() {
114
115
  afterEach(cleanup);
115
116
 
116
117
  describe("SessionComposer — service tier submit contract", () => {
117
- it("renders the tier toggle through the toolbar for a fast-capable model", async () => {
118
+ it("renders the tier switch through the toolbar for a fast-capable model", async () => {
118
119
  renderComposer();
119
120
 
120
121
  fireEvent.click(screen.getByRole("button", { name: /Composer 2\.5/ }));
@@ -132,7 +133,7 @@ describe("SessionComposer — service tier submit contract", () => {
132
133
  expect(context?.serviceTier).toBe("fast");
133
134
  });
134
135
 
135
- it("leaves serviceTier undefined when the toggle is untouched", async () => {
136
+ it("leaves serviceTier undefined when the switch is untouched", async () => {
136
137
  const { onSubmit } = renderComposer();
137
138
 
138
139
  submitMessage("Run it normally");
@@ -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
+ }
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import { useId, useState } from "react";
4
+ import { Send, TriangleAlert } from "lucide-react";
5
+ import { cn } from "@stigmer/theme";
6
+ import { getUserMessage } from "@stigmer/sdk";
7
+ import { ChannelSendOutcome } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
8
+ import type { SendChannelMessageOutput } from "@stigmer/protos/ai/stigmer/agentic/agentchannel/v1/message_io_pb";
9
+ import { Button } from "../button/Button.js";
10
+ import { SpinnerIcon } from "../composer/icons.js";
11
+ import { useComposer } from "../composer/useComposer.js";
12
+
13
+ /** Props for {@link ConversationComposer}. */
14
+ export interface ConversationComposerProps {
15
+ /** Send a staff reply (from `useConversationParticipation().reply`). */
16
+ readonly onSend: (text: string) => Promise<SendChannelMessageOutput>;
17
+ /** `true` while a reply is in flight. */
18
+ readonly isSending: boolean;
19
+ /**
20
+ * Why replying is unavailable, when it is — rendered in place of the
21
+ * input (a senderless provider, or a conversation the customer has
22
+ * not started yet). `null` enables the composer.
23
+ */
24
+ readonly disabledReason: string | null;
25
+ /**
26
+ * A pre-send forecast that ANNOTATES the enabled input — the
27
+ * `disabledReason` pattern's sibling with the opposite contract
28
+ * (channel-conversations DD-014 D-e): a closed service window is a
29
+ * forecast, not a structural block, so the input stays usable and the
30
+ * send engines remain the authority. Rendered above the input and
31
+ * associated via `aria-describedby` (never a live region: the
32
+ * advisory is state a reader meets on open, not an event). Compute it
33
+ * with `serviceWindowOf`; `null` or omitted renders nothing.
34
+ */
35
+ readonly advisory?: string | null;
36
+ /** Additional classes for the composer container. */
37
+ readonly className?: string;
38
+ }
39
+
40
+ /**
41
+ * The staff reply composer. Sending is an implicit takeover — the agent
42
+ * goes quiet the moment a staff reply lands, before the send itself —
43
+ * so this composer is only ever rendered alongside the control banner
44
+ * that shows that state change.
45
+ *
46
+ * Outcome honesty: `refused` keeps the draft (the words never left) and
47
+ * renders the server's refusal detail verbatim; `queued` clears it and
48
+ * says the platform is retrying; a thrown failure keeps the draft and
49
+ * renders the error. The timeline shows the delivered item via its own
50
+ * read — this component never fabricates one.
51
+ */
52
+ export function ConversationComposer({
53
+ onSend,
54
+ isSending,
55
+ disabledReason,
56
+ advisory,
57
+ className,
58
+ }: ConversationComposerProps) {
59
+ const advisoryId = useId();
60
+ const hasAdvisory = advisory != null && advisory !== "";
61
+ const [notice, setNotice] = useState<{
62
+ readonly kind: "refused" | "queued" | "error";
63
+ readonly text: string;
64
+ } | null>(null);
65
+
66
+ const composer = useComposer({
67
+ onSubmit: (message) => {
68
+ setNotice(null);
69
+ onSend(message).then(
70
+ (output) => {
71
+ if (output.outcome === ChannelSendOutcome.refused) {
72
+ // The words never left; keep them so the user can adjust.
73
+ composer.setMessage(message);
74
+ setNotice({
75
+ kind: "refused",
76
+ text: output.detail || "The provider refused this message.",
77
+ });
78
+ } else if (output.outcome === ChannelSendOutcome.queued) {
79
+ setNotice({
80
+ kind: "queued",
81
+ text: "Delivery hit a transient issue — the platform is retrying in the background.",
82
+ });
83
+ }
84
+ },
85
+ (err) => {
86
+ composer.setMessage(message);
87
+ setNotice({ kind: "error", text: getUserMessage(err) });
88
+ },
89
+ );
90
+ },
91
+ disabled: disabledReason !== null || isSending,
92
+ });
93
+
94
+ if (disabledReason !== null) {
95
+ return (
96
+ <div className={cn("border-t border-border px-4 py-3", className)}>
97
+ <p className="text-center text-xs text-muted-foreground">{disabledReason}</p>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <div className={cn("border-t border-border px-4 py-3", className)}>
104
+ {/* The persistent forecast sits above the transient send-outcome
105
+ notice: the notice is the more recent event and belongs closest
106
+ to the input it reacts to. */}
107
+ {hasAdvisory && (
108
+ <p
109
+ id={advisoryId}
110
+ className="mb-2 flex items-start gap-1.5 rounded-md bg-status-degraded-subtle px-2.5 py-1.5 text-xs text-status-degraded"
111
+ >
112
+ <TriangleAlert aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
113
+ <span className="min-w-0">{advisory}</span>
114
+ </p>
115
+ )}
116
+ {notice && (
117
+ <p
118
+ className={cn(
119
+ "mb-2 text-xs",
120
+ notice.kind === "queued" ? "text-muted-foreground" : "text-destructive",
121
+ )}
122
+ role="status"
123
+ >
124
+ {notice.text}
125
+ </p>
126
+ )}
127
+ <div className="flex items-end gap-2">
128
+ <textarea
129
+ {...composer.textareaProps}
130
+ rows={1}
131
+ placeholder="Reply as your business…"
132
+ aria-label="Reply to the customer"
133
+ aria-describedby={hasAdvisory ? advisoryId : undefined}
134
+ className={cn(
135
+ "min-h-9 flex-1 resize-none rounded-md border border-border bg-background",
136
+ "px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground-faint",
137
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
138
+ )}
139
+ />
140
+ <Button
141
+ variant="primary"
142
+ size="sm"
143
+ onClick={composer.submit}
144
+ disabled={!composer.canSubmit || isSending}
145
+ aria-label="Send reply"
146
+ >
147
+ {/* The house in-flight glyph (the session composer's pattern):
148
+ the spinner replaces the Send icon so the button itself
149
+ reports progress, not just its label. */}
150
+ {isSending ? <SpinnerIcon /> : <Send aria-hidden="true" className="size-4" />}
151
+ {isSending ? "Sending…" : "Send"}
152
+ </Button>
153
+ </div>
154
+ </div>
155
+ );
156
+ }