@agent-native/toolkit 0.226.0 → 0.226.1-nightly-20261107004051
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/dist/app/agentkit/react/components.d.ts +11 -9
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +115 -111
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/stop-button.js +5 -5
- package/dist/app/agentkit/react/stop-button.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +19 -48
- package/dist/app/auth/AuthPage.d.ts.map +1 -1
- package/dist/app/auth/AuthPage.js +7 -5
- package/dist/app/auth/AuthPage.js.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +7 -2
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/integrations/IntegrationDetailPage.d.ts.map +1 -1
- package/dist/app/integrations/IntegrationDetailPage.js +43 -1
- package/dist/app/integrations/IntegrationDetailPage.js.map +1 -1
- package/dist/app/integrations/IntegrationsPage.d.ts.map +1 -1
- package/dist/app/integrations/IntegrationsPage.js +16 -7
- package/dist/app/integrations/IntegrationsPage.js.map +1 -1
- package/dist/app/integrations/connected-server-id.d.ts +17 -0
- package/dist/app/integrations/connected-server-id.d.ts.map +1 -0
- package/dist/app/integrations/connected-server-id.js +26 -0
- package/dist/app/integrations/connected-server-id.js.map +1 -0
- package/dist/composer/TiptapComposer.d.ts +5 -3
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +1 -1
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/package.json +4 -4
- package/src/app/agentkit/react/components.interaction.spec.tsx +112 -0
- package/src/app/agentkit/react/components.tsx +178 -192
- package/src/app/agentkit/react/lifecycle.spec.tsx +5 -16
- package/src/app/agentkit/react/stop-button.spec.tsx +1 -1
- package/src/app/agentkit/react/stop-button.tsx +5 -5
- package/src/app/agentkit/react/styles.css +19 -48
- package/src/app/agentkit/react/work-handoff.spec.tsx +202 -69
- package/src/app/auth/AuthPage.interaction.spec.tsx +17 -1
- package/src/app/auth/AuthPage.tsx +12 -4
- package/src/app/chat/AgentKitAssistantChat.tsx +6 -2
- package/src/app/integrations/IntegrationDetailPage.spec.tsx +30 -0
- package/src/app/integrations/IntegrationDetailPage.tsx +85 -1
- package/src/app/integrations/IntegrationsPage.spec.tsx +74 -0
- package/src/app/integrations/IntegrationsPage.tsx +47 -20
- package/src/app/integrations/connected-server-id.ts +34 -0
- package/src/composer/TiptapComposer.spec.ts +7 -7
- package/src/composer/TiptapComposer.tsx +12 -13
- package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +24 -1
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from "../settings/shell/context.js";
|
|
76
76
|
import type { SettingsPageContext } from "../settings/shell/registry.js";
|
|
77
77
|
import { submitToAgent } from "../shared/index.js";
|
|
78
|
+
import { connectedServerIntegrationId } from "./connected-server-id.js";
|
|
78
79
|
import {
|
|
79
80
|
BrandMark,
|
|
80
81
|
BreadcrumbTitle,
|
|
@@ -88,7 +89,10 @@ import {
|
|
|
88
89
|
catalogWithoutBuilderPublish,
|
|
89
90
|
RemoveServerDialog,
|
|
90
91
|
} from "./IntegrationsPage.js";
|
|
91
|
-
import {
|
|
92
|
+
import {
|
|
93
|
+
startMcpOAuthReconnect,
|
|
94
|
+
useMcpIntegrationsController,
|
|
95
|
+
} from "./IntegrationsPanel.js";
|
|
92
96
|
|
|
93
97
|
const K = "agentChat.settingsShell.integrationDetail";
|
|
94
98
|
const CH = "agentChat.settingsShell.channels";
|
|
@@ -402,6 +406,17 @@ export function IntegrationDetailPage({
|
|
|
402
406
|
}: IntegrationDetailPageProps) {
|
|
403
407
|
const catalog = useMemo(catalogWithoutBuilderPublish, []);
|
|
404
408
|
const integration = catalog.find((entry) => entry.id === id);
|
|
409
|
+
// A connected server that has no catalog entry (a custom integration added by URL) is
|
|
410
|
+
// still openable: the tile links to its slug, and this resolves the same slug back to
|
|
411
|
+
// the server, so every connected tile has a detail page rather than only the built-ins.
|
|
412
|
+
const mcp = useMcpIntegrationsController({ integrations: catalog });
|
|
413
|
+
const customServer = integration
|
|
414
|
+
? undefined
|
|
415
|
+
: // Every server, not only the connected ones: an ERRORED connection still needs a
|
|
416
|
+
// page — that is exactly when a person goes looking for Reconnect.
|
|
417
|
+
mcp.servers.find(
|
|
418
|
+
(server) => connectedServerIntegrationId(server, catalog) === id,
|
|
419
|
+
);
|
|
405
420
|
const { navigate } = useSettingsShell();
|
|
406
421
|
const channelId = CHANNEL_ALIASES[id] ?? id;
|
|
407
422
|
const isChannel =
|
|
@@ -413,6 +428,15 @@ export function IntegrationDetailPage({
|
|
|
413
428
|
}, [channelId, isChannel, navigate]);
|
|
414
429
|
|
|
415
430
|
if (isChannel) return null;
|
|
431
|
+
if (!integration && customServer) {
|
|
432
|
+
return (
|
|
433
|
+
<CustomServerDetail
|
|
434
|
+
server={customServer}
|
|
435
|
+
catalog={catalog}
|
|
436
|
+
integrationId={id}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
}
|
|
416
440
|
if (!integration) return <NotFound />;
|
|
417
441
|
return (
|
|
418
442
|
<IntegrationDetail
|
|
@@ -426,6 +450,66 @@ export function IntegrationDetailPage({
|
|
|
426
450
|
);
|
|
427
451
|
}
|
|
428
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The detail page for a CONNECTED server that has no catalog entry — a custom
|
|
455
|
+
* integration added by URL. It shows the same facts as a built-in's page (status, scope,
|
|
456
|
+
* tools, error reason) and the same actions (Reconnect when the connection errored,
|
|
457
|
+
* Remove), so every connected tile in the grid opens a page worth opening.
|
|
458
|
+
*/
|
|
459
|
+
function CustomServerDetail({
|
|
460
|
+
server,
|
|
461
|
+
catalog,
|
|
462
|
+
integrationId,
|
|
463
|
+
}: {
|
|
464
|
+
server: McpServer;
|
|
465
|
+
catalog: DefaultMcpIntegration[];
|
|
466
|
+
integrationId: string;
|
|
467
|
+
}) {
|
|
468
|
+
const t = useT();
|
|
469
|
+
const { navigate } = useSettingsShell();
|
|
470
|
+
const mcp = useMcpIntegrationsController({ integrations: catalog });
|
|
471
|
+
const [removing, setRemoving] = useState(false);
|
|
472
|
+
const canRemove =
|
|
473
|
+
server.scope === "user" ||
|
|
474
|
+
mcp.serversQuery.data?.role === "owner" ||
|
|
475
|
+
mcp.serversQuery.data?.role === "admin";
|
|
476
|
+
const reconnect = () => {
|
|
477
|
+
if (server.authMode === "oauth" && server.status.state === "error") {
|
|
478
|
+
startMcpOAuthReconnect(server);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
void mcp.reconnect(server);
|
|
482
|
+
};
|
|
483
|
+
return (
|
|
484
|
+
<div className="space-y-6" data-connected-server-detail={integrationId}>
|
|
485
|
+
<header className="space-y-1">
|
|
486
|
+
<h2 className="text-base font-medium text-foreground">{server.name}</h2>
|
|
487
|
+
<p className="truncate text-xs text-muted-foreground">{server.url}</p>
|
|
488
|
+
</header>
|
|
489
|
+
<ConnectedRow
|
|
490
|
+
server={server}
|
|
491
|
+
canRemove={canRemove}
|
|
492
|
+
onRemove={() => setRemoving(true)}
|
|
493
|
+
/>
|
|
494
|
+
{server.status.state === "error" ? (
|
|
495
|
+
<Button type="button" variant="outline" size="sm" onClick={reconnect}>
|
|
496
|
+
{t("mcpIntegrations.reconnect")}
|
|
497
|
+
</Button>
|
|
498
|
+
) : null}
|
|
499
|
+
<RemoveServerDialog
|
|
500
|
+
server={removing ? server : null}
|
|
501
|
+
onOpenChange={(open) => {
|
|
502
|
+
if (!open) setRemoving(false);
|
|
503
|
+
}}
|
|
504
|
+
onRemove={async (target) => {
|
|
505
|
+
await mcp.removeServer(target);
|
|
506
|
+
navigate("integrations");
|
|
507
|
+
}}
|
|
508
|
+
/>
|
|
509
|
+
</div>
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
429
513
|
function IntegrationDetail({
|
|
430
514
|
integration,
|
|
431
515
|
catalog,
|
|
@@ -276,6 +276,80 @@ describe("IntegrationsPage", () => {
|
|
|
276
276
|
expect(container.textContent).toContain("Internal docs");
|
|
277
277
|
});
|
|
278
278
|
|
|
279
|
+
it("opens a connected remote MCP server's detail page from its tile", async () => {
|
|
280
|
+
// Steve, 11/6: the connected "notion" tile had no way in — only a "…" menu — so a
|
|
281
|
+
// connected remote MCP server could not be viewed, edited or reconnected. A catalog
|
|
282
|
+
// integration that is connected must open the same page its catalog tile would.
|
|
283
|
+
mcpMocks.useMcpServers.mockReturnValue({
|
|
284
|
+
data: {
|
|
285
|
+
user: [
|
|
286
|
+
{
|
|
287
|
+
id: "notion-personal",
|
|
288
|
+
scope: "user",
|
|
289
|
+
name: "notion",
|
|
290
|
+
url: "https://mcp.notion.com/mcp",
|
|
291
|
+
authMode: "oauth",
|
|
292
|
+
createdAt: 1,
|
|
293
|
+
status: { state: "connected", toolCount: 77 },
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
org: [],
|
|
297
|
+
orgId: "acme",
|
|
298
|
+
role: "member",
|
|
299
|
+
},
|
|
300
|
+
isError: false,
|
|
301
|
+
isLoading: false,
|
|
302
|
+
});
|
|
303
|
+
await render();
|
|
304
|
+
|
|
305
|
+
const link = container.querySelector<HTMLAnchorElement>(
|
|
306
|
+
'[data-connected-server-link="notion"]',
|
|
307
|
+
);
|
|
308
|
+
expect(link).not.toBeNull();
|
|
309
|
+
expect(link?.getAttribute("href")).toContain("/integrations/notion");
|
|
310
|
+
await act(async () => {
|
|
311
|
+
link?.dispatchEvent(
|
|
312
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
expect(navigate).toHaveBeenCalledWith("integrations", "notion");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("opens a custom connected server from its tile too, under its own slug", async () => {
|
|
319
|
+
mcpMocks.useMcpServers.mockReturnValue({
|
|
320
|
+
data: {
|
|
321
|
+
user: [
|
|
322
|
+
{
|
|
323
|
+
id: "custom-1",
|
|
324
|
+
scope: "user",
|
|
325
|
+
name: "Internal Docs",
|
|
326
|
+
url: "https://mcp.example.com/mcp",
|
|
327
|
+
authMode: "headers",
|
|
328
|
+
createdAt: 1,
|
|
329
|
+
status: { state: "connected", toolCount: 3 },
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
org: [],
|
|
333
|
+
orgId: "acme",
|
|
334
|
+
role: "member",
|
|
335
|
+
},
|
|
336
|
+
isError: false,
|
|
337
|
+
isLoading: false,
|
|
338
|
+
});
|
|
339
|
+
await render();
|
|
340
|
+
|
|
341
|
+
const link = container.querySelector<HTMLAnchorElement>(
|
|
342
|
+
'[data-connected-server-link="Internal Docs"]',
|
|
343
|
+
);
|
|
344
|
+
expect(link?.getAttribute("href")).toContain("/integrations/internal-docs");
|
|
345
|
+
await act(async () => {
|
|
346
|
+
link?.dispatchEvent(
|
|
347
|
+
new MouseEvent("click", { bubbles: true, cancelable: true }),
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
expect(navigate).toHaveBeenCalledWith("integrations", "internal-docs");
|
|
351
|
+
});
|
|
352
|
+
|
|
279
353
|
it("starts Connected from an empty state when nothing is connected", async () => {
|
|
280
354
|
await render();
|
|
281
355
|
|
|
@@ -79,6 +79,7 @@ import {
|
|
|
79
79
|
} from "../settings/shell/context.js";
|
|
80
80
|
import { settingsPageHref } from "../settings/shell/routing.js";
|
|
81
81
|
import { useBuilderStatus } from "../settings/useBuilderStatus.js";
|
|
82
|
+
import { connectedServerIntegrationId } from "./connected-server-id.js";
|
|
82
83
|
import {
|
|
83
84
|
startMcpOAuthReconnect,
|
|
84
85
|
useMcpIntegrationsController,
|
|
@@ -256,6 +257,8 @@ function ConnectedServerTile({
|
|
|
256
257
|
canRemove,
|
|
257
258
|
reconnecting,
|
|
258
259
|
reconnectError,
|
|
260
|
+
detailHref,
|
|
261
|
+
onOpen,
|
|
259
262
|
onReconnect,
|
|
260
263
|
onRemove,
|
|
261
264
|
}: {
|
|
@@ -264,6 +267,8 @@ function ConnectedServerTile({
|
|
|
264
267
|
canRemove: boolean;
|
|
265
268
|
reconnecting: boolean;
|
|
266
269
|
reconnectError: string | null;
|
|
270
|
+
detailHref: string;
|
|
271
|
+
onOpen: () => void;
|
|
267
272
|
onReconnect: () => void;
|
|
268
273
|
onRemove: () => void;
|
|
269
274
|
}) {
|
|
@@ -286,27 +291,46 @@ function ConnectedServerTile({
|
|
|
286
291
|
className="flex min-w-0 items-center gap-3 rounded-lg p-2.5"
|
|
287
292
|
data-connected-server={server.name}
|
|
288
293
|
>
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
294
|
+
{/*
|
|
295
|
+
A connected tile opens the same detail page a catalog tile does: status, scope,
|
|
296
|
+
tools, Reconnect and Remove. It used to be an inert div with only a "…" menu, so
|
|
297
|
+
a connected remote MCP server (Steve's "notion", 11/6) could not be opened at all.
|
|
298
|
+
The menu stays outside the anchor so its clicks never navigate.
|
|
299
|
+
*/}
|
|
300
|
+
<a
|
|
301
|
+
href={detailHref}
|
|
302
|
+
onClick={(event) => {
|
|
303
|
+
if (isModifiedClick(event)) return;
|
|
304
|
+
event.preventDefault();
|
|
305
|
+
onOpen();
|
|
306
|
+
}}
|
|
307
|
+
data-connected-server-link={server.name}
|
|
308
|
+
className="flex min-w-0 flex-1 items-center gap-3 rounded-md text-start transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
309
|
+
>
|
|
310
|
+
{logo}
|
|
311
|
+
<span className="min-w-0 flex-1">
|
|
312
|
+
<span className="block truncate text-sm font-medium text-foreground">
|
|
313
|
+
{server.name}
|
|
307
314
|
</span>
|
|
308
|
-
|
|
309
|
-
|
|
315
|
+
<span className="block truncate text-xs leading-5 text-muted-foreground">
|
|
316
|
+
{detail}
|
|
317
|
+
</span>
|
|
318
|
+
{server.status.state !== "unknown" && server.status.error ? (
|
|
319
|
+
<span
|
|
320
|
+
className="block truncate text-xs leading-5 text-destructive"
|
|
321
|
+
title={server.status.error}
|
|
322
|
+
>
|
|
323
|
+
{reconnectError
|
|
324
|
+
? t("mcpIntegrations.reconnectFailed", {
|
|
325
|
+
error: reconnectError,
|
|
326
|
+
})
|
|
327
|
+
: t("mcpIntegrations.connectionErrorReason", {
|
|
328
|
+
reason: server.status.error,
|
|
329
|
+
})}
|
|
330
|
+
</span>
|
|
331
|
+
) : null}
|
|
332
|
+
</span>
|
|
333
|
+
</a>
|
|
310
334
|
{hasError || canRemove ? (
|
|
311
335
|
<DropdownMenu>
|
|
312
336
|
<DropdownMenuTrigger asChild>
|
|
@@ -553,10 +577,13 @@ export function IntegrationsPage({ appName }: IntegrationsPageProps) {
|
|
|
553
577
|
isMcpIntegrationUrl(item, server.url),
|
|
554
578
|
);
|
|
555
579
|
const key = serverKey(server);
|
|
580
|
+
const integrationId = connectedServerIntegrationId(server, catalog);
|
|
556
581
|
return (
|
|
557
582
|
<ConnectedServerTile
|
|
558
583
|
key={key}
|
|
559
584
|
server={server}
|
|
585
|
+
detailHref={settingsPageHref("integrations", integrationId)}
|
|
586
|
+
onOpen={() => navigate("integrations", integrationId)}
|
|
560
587
|
logo={
|
|
561
588
|
integration ? (
|
|
562
589
|
catalogLogo(integration)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isMcpIntegrationUrl,
|
|
3
|
+
type DefaultMcpIntegration,
|
|
4
|
+
} from "@agent-native/core/client/resources/mcp-integration-catalog";
|
|
5
|
+
import type { McpServer } from "@agent-native/core/client/resources/use-mcp-servers";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The settings-page id a CONNECTED server opens under.
|
|
9
|
+
*
|
|
10
|
+
* A server that came from the catalog opens its catalog entry, matched by URL, so the
|
|
11
|
+
* detail page is the same one the catalog tile would have opened before connecting
|
|
12
|
+
* (for example `integrations/notion`). A custom integration — one added by URL, with no
|
|
13
|
+
* catalog entry — opens under a slug of its own name, which is the same slug whose logo
|
|
14
|
+
* it already renders.
|
|
15
|
+
*
|
|
16
|
+
* This lives in one place because two surfaces must agree on it: the tile that links to
|
|
17
|
+
* the page, and the page that resolves which server it is showing. A remote MCP tile
|
|
18
|
+
* that cannot be clicked was the bug this exists to prevent (Steve, 11/6).
|
|
19
|
+
*/
|
|
20
|
+
export function connectedServerIntegrationId(
|
|
21
|
+
server: Pick<McpServer, "name" | "url">,
|
|
22
|
+
catalog: readonly DefaultMcpIntegration[],
|
|
23
|
+
): string {
|
|
24
|
+
const match = catalog.find((integration) =>
|
|
25
|
+
isMcpIntegrationUrl(integration, server.url),
|
|
26
|
+
);
|
|
27
|
+
if (match) return match.id;
|
|
28
|
+
const slug = server.name
|
|
29
|
+
.trim()
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "");
|
|
33
|
+
return slug || "mcp-server";
|
|
34
|
+
}
|
|
@@ -2456,7 +2456,7 @@ describe("createTiptapComposerExtensions", () => {
|
|
|
2456
2456
|
).toBe(false);
|
|
2457
2457
|
});
|
|
2458
2458
|
|
|
2459
|
-
it("
|
|
2459
|
+
it("shows one primary control: Stop while empty, Send once a draft exists", async () => {
|
|
2460
2460
|
const stop = vi.fn();
|
|
2461
2461
|
const focusRef: React.MutableRefObject<TiptapComposerHandle | null> = {
|
|
2462
2462
|
current: null,
|
|
@@ -2515,15 +2515,15 @@ describe("createTiptapComposerExtensions", () => {
|
|
|
2515
2515
|
expect(stopButton()).not.toBeNull();
|
|
2516
2516
|
expect(send()).toBeNull();
|
|
2517
2517
|
|
|
2518
|
+
// oracle-change: a draft now swaps Stop for Send, so there is no Stop-before-Send order to check.
|
|
2518
2519
|
act(() => focusRef.current?.setText("Follow-up while it runs"));
|
|
2519
|
-
expect(
|
|
2520
|
+
expect(
|
|
2521
|
+
container.querySelector<HTMLElement>(
|
|
2522
|
+
'[data-agent-composer-slot="stop-slot"]',
|
|
2523
|
+
)?.hidden,
|
|
2524
|
+
).toBe(true);
|
|
2520
2525
|
expect(send()?.disabled).toBe(false);
|
|
2521
2526
|
expect(send()?.getAttribute("aria-label")).toBe("Queue message");
|
|
2522
|
-
// Stop precedes Send, so Send keeps the trailing position.
|
|
2523
|
-
expect(
|
|
2524
|
-
stopButton()!.compareDocumentPosition(send()!) &
|
|
2525
|
-
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
2526
|
-
).toBeTruthy();
|
|
2527
2527
|
|
|
2528
2528
|
await pressEscape();
|
|
2529
2529
|
expect(stop).toHaveBeenCalledOnce();
|
|
@@ -1278,9 +1278,11 @@ export interface TiptapComposerProps {
|
|
|
1278
1278
|
/** Extra button to render alongside the primary action. */
|
|
1279
1279
|
extraActionButton?: React.ReactNode;
|
|
1280
1280
|
/**
|
|
1281
|
-
* Stop control
|
|
1282
|
-
*
|
|
1283
|
-
* editor clicks it when it is enabled.
|
|
1281
|
+
* Stop control for an active run. It takes the primary slot while the
|
|
1282
|
+
* composer is empty and gives it to Send once a draft exists; it stays
|
|
1283
|
+
* mounted, so Escape in the editor still clicks it when it is enabled.
|
|
1284
|
+
* During voice chat it sits beside End voice chat, which does not stop the
|
|
1285
|
+
* run. Pass it only while there is something to stop.
|
|
1284
1286
|
*/
|
|
1285
1287
|
stopButton?: React.ReactNode;
|
|
1286
1288
|
/** Custom attachment button to render instead of ComposerPrimitive.AddAttachment. */
|
|
@@ -6964,20 +6966,17 @@ function TiptapComposerSurface({
|
|
|
6964
6966
|
/>
|
|
6965
6967
|
)}
|
|
6966
6968
|
{stopButton ? (
|
|
6967
|
-
// Stop
|
|
6968
|
-
//
|
|
6969
|
-
//
|
|
6970
|
-
//
|
|
6969
|
+
// Stop and Send are one control: Stop while the composer is
|
|
6970
|
+
// empty, Send once a draft exists. The hidden Stop stays mounted
|
|
6971
|
+
// so Escape can still stop the run mid-draft. Voice chat keeps it
|
|
6972
|
+
// visible: End voice chat does not stop the run.
|
|
6971
6973
|
<div
|
|
6972
6974
|
ref={stopSlotRef}
|
|
6973
6975
|
data-agent-composer-slot="stop-slot"
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
}`}
|
|
6976
|
+
hidden={hasDraft && voiceChat === null}
|
|
6977
|
+
className="flex shrink-0"
|
|
6977
6978
|
>
|
|
6978
|
-
|
|
6979
|
-
{stopButton}
|
|
6980
|
-
</div>
|
|
6979
|
+
{stopButton}
|
|
6981
6980
|
</div>
|
|
6982
6981
|
) : null}
|
|
6983
6982
|
{stopButton && !hasDraft && !voiceChat ? null : sendButton}
|
|
@@ -414,7 +414,7 @@ describe("TiptapComposer primary slot", () => {
|
|
|
414
414
|
expect(container.textContent).toContain("Stop run");
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
-
it("
|
|
417
|
+
it("swaps Stop for Send once a draft exists during a run", async () => {
|
|
418
418
|
await act(async () =>
|
|
419
419
|
root.render(
|
|
420
420
|
<Harness
|
|
@@ -426,6 +426,11 @@ describe("TiptapComposer primary slot", () => {
|
|
|
426
426
|
);
|
|
427
427
|
|
|
428
428
|
expect(trailing()).toEqual(["voice-button", "stop-slot", "send-button"]);
|
|
429
|
+
expect(
|
|
430
|
+
container.querySelector<HTMLElement>(
|
|
431
|
+
'[data-agent-composer-slot="stop-slot"]',
|
|
432
|
+
)?.hidden,
|
|
433
|
+
).toBe(true);
|
|
429
434
|
expect(send()?.dataset.composerPrimary).toBe("send");
|
|
430
435
|
});
|
|
431
436
|
|
|
@@ -496,6 +501,24 @@ describe("TiptapComposer voice chat", () => {
|
|
|
496
501
|
expect(realtime.end).toHaveBeenCalledTimes(1);
|
|
497
502
|
});
|
|
498
503
|
|
|
504
|
+
it("keeps Stop beside End voice chat while a run is active", async () => {
|
|
505
|
+
activate("listening");
|
|
506
|
+
await act(async () =>
|
|
507
|
+
root.render(
|
|
508
|
+
<Harness
|
|
509
|
+
adapters={realtimeReady}
|
|
510
|
+
stopButton={<button type="button">Stop run</button>}
|
|
511
|
+
/>,
|
|
512
|
+
),
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// End voice chat does not cancel the run, so Stop stays reachable.
|
|
516
|
+
expect(slot<HTMLElement>("stop-slot")?.hidden).toBe(false);
|
|
517
|
+
expect(
|
|
518
|
+
slot<HTMLButtonElement>("send-button")?.dataset.composerPrimary,
|
|
519
|
+
).toBe("end-voice-chat");
|
|
520
|
+
});
|
|
521
|
+
|
|
499
522
|
it("renders no floating dock", async () => {
|
|
500
523
|
activate("speaking");
|
|
501
524
|
await act(async () => root.render(<Harness adapters={realtimeReady} />));
|