@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.
- package/channel/AgentChannelsPanel.d.ts.map +1 -1
- package/channel/AgentChannelsPanel.js +21 -2
- package/channel/AgentChannelsPanel.js.map +1 -1
- package/channel/ChannelConversationsDialog.d.ts +12 -4
- package/channel/ChannelConversationsDialog.d.ts.map +1 -1
- package/channel/ChannelConversationsDialog.js +18 -24
- package/channel/ChannelConversationsDialog.js.map +1 -1
- package/channel/providers.d.ts +23 -0
- package/channel/providers.d.ts.map +1 -1
- package/channel/providers.js +4 -0
- package/channel/providers.js.map +1 -1
- package/channel/useSaveAgentChannel.d.ts +5 -4
- package/channel/useSaveAgentChannel.d.ts.map +1 -1
- package/channel/useSaveAgentChannel.js +18 -4
- package/channel/useSaveAgentChannel.js.map +1 -1
- package/composer/ComposerToolbar.d.ts +1 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/SessionComposer.d.ts +1 -1
- package/composer/SessionComposer.js +1 -1
- package/composer/SessionComposer.js.map +1 -1
- package/conversation/ConversationAttentionBanner.d.ts +22 -0
- package/conversation/ConversationAttentionBanner.d.ts.map +1 -0
- package/conversation/ConversationAttentionBanner.js +25 -0
- package/conversation/ConversationAttentionBanner.js.map +1 -0
- package/conversation/ConversationComposer.d.ts +41 -0
- package/conversation/ConversationComposer.d.ts.map +1 -0
- package/conversation/ConversationComposer.js +57 -0
- package/conversation/ConversationComposer.js.map +1 -0
- package/conversation/ConversationControlBanner.d.ts +52 -0
- package/conversation/ConversationControlBanner.d.ts.map +1 -0
- package/conversation/ConversationControlBanner.js +59 -0
- package/conversation/ConversationControlBanner.js.map +1 -0
- package/conversation/ConversationListPane.d.ts +68 -0
- package/conversation/ConversationListPane.d.ts.map +1 -0
- package/conversation/ConversationListPane.js +120 -0
- package/conversation/ConversationListPane.js.map +1 -0
- package/conversation/ConversationTimelineView.d.ts +46 -0
- package/conversation/ConversationTimelineView.d.ts.map +1 -0
- package/conversation/ConversationTimelineView.js +166 -0
- package/conversation/ConversationTimelineView.js.map +1 -0
- package/conversation/ConversationsWorkbench.d.ts +63 -0
- package/conversation/ConversationsWorkbench.d.ts.map +1 -0
- package/conversation/ConversationsWorkbench.js +165 -0
- package/conversation/ConversationsWorkbench.js.map +1 -0
- package/conversation/conversationPresentation.d.ts +137 -0
- package/conversation/conversationPresentation.d.ts.map +1 -0
- package/conversation/conversationPresentation.js +230 -0
- package/conversation/conversationPresentation.js.map +1 -0
- package/conversation/index.d.ts +26 -0
- package/conversation/index.d.ts.map +1 -0
- package/conversation/index.js +14 -0
- package/conversation/index.js.map +1 -0
- package/conversation/polling.d.ts +39 -0
- package/conversation/polling.d.ts.map +1 -0
- package/conversation/polling.js +39 -0
- package/conversation/polling.js.map +1 -0
- package/conversation/useConversation.d.ts +61 -0
- package/conversation/useConversation.d.ts.map +1 -0
- package/conversation/useConversation.js +153 -0
- package/conversation/useConversation.js.map +1 -0
- package/conversation/useConversationList.d.ts +98 -0
- package/conversation/useConversationList.d.ts.map +1 -0
- package/conversation/useConversationList.js +200 -0
- package/conversation/useConversationList.js.map +1 -0
- package/conversation/useConversationParticipation.d.ts +63 -0
- package/conversation/useConversationParticipation.d.ts.map +1 -0
- package/conversation/useConversationParticipation.js +84 -0
- package/conversation/useConversationParticipation.js.map +1 -0
- package/conversation/useConversationTimeline.d.ts +71 -0
- package/conversation/useConversationTimeline.d.ts.map +1 -0
- package/conversation/useConversationTimeline.js +152 -0
- package/conversation/useConversationTimeline.js.map +1 -0
- package/conversation/useConversationsWantsHumanCount.d.ts +42 -0
- package/conversation/useConversationsWantsHumanCount.d.ts.map +1 -0
- package/conversation/useConversationsWantsHumanCount.js +40 -0
- package/conversation/useConversationsWantsHumanCount.js.map +1 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -1
- package/index.js +8 -0
- package/index.js.map +1 -1
- package/internal/tooltip.d.ts.map +1 -1
- package/internal/tooltip.js +7 -1
- package/internal/tooltip.js.map +1 -1
- package/internal/useAutoScroll.d.ts +25 -4
- package/internal/useAutoScroll.d.ts.map +1 -1
- package/internal/useAutoScroll.js +55 -15
- package/internal/useAutoScroll.js.map +1 -1
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +14 -4
- package/internal/useFetch.js.map +1 -1
- package/models/ModelSelector.d.ts +24 -5
- package/models/ModelSelector.d.ts.map +1 -1
- package/models/ModelSelector.js +31 -9
- package/models/ModelSelector.js.map +1 -1
- package/models/registry.d.ts +3 -2
- package/models/registry.d.ts.map +1 -1
- package/models/registry.js.map +1 -1
- package/models/service-tier.d.ts +1 -1
- package/models/service-tier.d.ts.map +1 -1
- package/models/service-tier.js +1 -1
- package/models/service-tier.js.map +1 -1
- package/package.json +4 -4
- package/schedule/ScheduleDetailView.js +1 -1
- package/schedule/ScheduleDetailView.js.map +1 -1
- package/schedule/ScheduleForm.d.ts.map +1 -1
- package/schedule/ScheduleForm.js +7 -6
- package/schedule/ScheduleForm.js.map +1 -1
- package/sharing/useSaveAgentShare.d.ts +10 -1
- package/sharing/useSaveAgentShare.d.ts.map +1 -1
- package/sharing/useSaveAgentShare.js +9 -0
- package/sharing/useSaveAgentShare.js.map +1 -1
- package/sidebar/WorkspaceSidebar.d.ts +12 -4
- package/sidebar/WorkspaceSidebar.d.ts.map +1 -1
- package/sidebar/WorkspaceSidebar.js +19 -7
- package/sidebar/WorkspaceSidebar.js.map +1 -1
- package/src/channel/AgentChannelsPanel.tsx +33 -2
- package/src/channel/ChannelConversationsDialog.tsx +22 -25
- package/src/channel/__tests__/AgentChannelsPanel.test.tsx +36 -5
- package/src/channel/__tests__/ChannelConversationsDialog.test.tsx +38 -1
- package/src/channel/__tests__/useSaveAgentChannel.test.tsx +26 -0
- package/src/channel/providers.ts +27 -0
- package/src/channel/useSaveAgentChannel.ts +18 -4
- package/src/composer/ComposerToolbar.tsx +1 -1
- package/src/composer/SessionComposer.tsx +2 -2
- package/src/composer/__tests__/SessionComposer-serviceTier.test.tsx +4 -3
- package/src/conversation/ConversationAttentionBanner.tsx +77 -0
- package/src/conversation/ConversationComposer.tsx +156 -0
- package/src/conversation/ConversationControlBanner.tsx +193 -0
- package/src/conversation/ConversationListPane.tsx +459 -0
- package/src/conversation/ConversationTimelineView.tsx +435 -0
- package/src/conversation/ConversationsWorkbench.tsx +378 -0
- package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +82 -0
- package/src/conversation/__tests__/ConversationComposer.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationControlBanner.test.tsx +190 -0
- package/src/conversation/__tests__/ConversationListPane.test.tsx +372 -0
- package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +160 -0
- package/src/conversation/__tests__/ConversationTimelineView.test.tsx +405 -0
- package/src/conversation/__tests__/ConversationsWorkbench.test.tsx +860 -0
- package/src/conversation/__tests__/a11y/conversation-surfaces.a11y.test.tsx +235 -0
- package/src/conversation/__tests__/a11y/harness.tsx +100 -0
- package/src/conversation/__tests__/conversation-tooltips.layout.test.tsx +167 -0
- package/src/conversation/__tests__/conversationPollingComposition.test.tsx +138 -0
- package/src/conversation/__tests__/conversationPresentation.test.ts +289 -0
- package/src/conversation/__tests__/useConversation.test.tsx +243 -0
- package/src/conversation/__tests__/useConversationList.test.tsx +492 -0
- package/src/conversation/__tests__/useConversationParticipation.test.tsx +190 -0
- package/src/conversation/__tests__/useConversationTimeline.test.tsx +258 -0
- package/src/conversation/__tests__/useConversationsWantsHumanCount.test.tsx +102 -0
- package/src/conversation/conversationPresentation.ts +305 -0
- package/src/conversation/index.ts +80 -0
- package/src/conversation/polling.ts +41 -0
- package/src/conversation/useConversation.ts +221 -0
- package/src/conversation/useConversationList.ts +328 -0
- package/src/conversation/useConversationParticipation.ts +178 -0
- package/src/conversation/useConversationTimeline.ts +244 -0
- package/src/conversation/useConversationsWantsHumanCount.ts +86 -0
- package/src/index.ts +67 -0
- package/src/internal/__tests__/fake-timer-slices.ts +29 -0
- package/src/internal/__tests__/useAutoScroll.layout.test.tsx +143 -0
- package/src/internal/__tests__/useAutoScroll.test.tsx +62 -5
- package/src/internal/__tests__/useFetch-poll-under-render-pressure.test.tsx +79 -0
- package/src/internal/tooltip.tsx +6 -1
- package/src/internal/useAutoScroll.ts +66 -20
- package/src/internal/useFetch.ts +15 -4
- package/src/models/ModelSelector.tsx +81 -49
- package/src/models/__tests__/ModelSelector-serviceTier.test.tsx +105 -21
- package/src/models/registry.ts +3 -2
- package/src/models/service-tier.ts +1 -1
- package/src/schedule/ScheduleDetailView.tsx +1 -1
- package/src/schedule/ScheduleForm.tsx +7 -6
- package/src/schedule/__tests__/scheduleCreation.test.tsx +2 -2
- package/src/sharing/__tests__/useSaveAgentShare.test.tsx +55 -1
- package/src/sharing/__tests__/useShareToolReadiness.test.tsx +1 -0
- package/src/sharing/useSaveAgentShare.ts +19 -1
- package/src/sidebar/WorkspaceSidebar.tsx +62 -3
- package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +56 -1
- 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
|
|
41
|
-
*
|
|
42
|
-
* connector and org admins; design
|
|
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
|
-
*
|
|
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
|
-
|
|
138
|
+
Sessions
|
|
130
139
|
</h2>
|
|
131
140
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
132
|
-
|
|
141
|
+
The sessions {channelName} created — read-only, visible to the
|
|
133
142
|
channel'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
|
|
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
|
|
158
|
-
description="When someone messages this channel,
|
|
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
|
|
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
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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:
|
|
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
|
|
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: /
|
|
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: /
|
|
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: /
|
|
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
|
|
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: {
|
package/src/channel/providers.ts
CHANGED
|
@@ -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`)
|
|
27
|
-
* (`app_ref`)
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
+
<> — “{conversation.attentionReason}”</>
|
|
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
|
+
}
|