@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.
Files changed (46) hide show
  1. package/dist/app/agentkit/react/components.d.ts +11 -9
  2. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  3. package/dist/app/agentkit/react/components.js +115 -111
  4. package/dist/app/agentkit/react/components.js.map +1 -1
  5. package/dist/app/agentkit/react/stop-button.js +5 -5
  6. package/dist/app/agentkit/react/stop-button.js.map +1 -1
  7. package/dist/app/agentkit/react/styles.css +19 -48
  8. package/dist/app/auth/AuthPage.d.ts.map +1 -1
  9. package/dist/app/auth/AuthPage.js +7 -5
  10. package/dist/app/auth/AuthPage.js.map +1 -1
  11. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  12. package/dist/app/chat/AgentKitAssistantChat.js +7 -2
  13. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  14. package/dist/app/integrations/IntegrationDetailPage.d.ts.map +1 -1
  15. package/dist/app/integrations/IntegrationDetailPage.js +43 -1
  16. package/dist/app/integrations/IntegrationDetailPage.js.map +1 -1
  17. package/dist/app/integrations/IntegrationsPage.d.ts.map +1 -1
  18. package/dist/app/integrations/IntegrationsPage.js +16 -7
  19. package/dist/app/integrations/IntegrationsPage.js.map +1 -1
  20. package/dist/app/integrations/connected-server-id.d.ts +17 -0
  21. package/dist/app/integrations/connected-server-id.d.ts.map +1 -0
  22. package/dist/app/integrations/connected-server-id.js +26 -0
  23. package/dist/app/integrations/connected-server-id.js.map +1 -0
  24. package/dist/composer/TiptapComposer.d.ts +5 -3
  25. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  26. package/dist/composer/TiptapComposer.js +1 -1
  27. package/dist/composer/TiptapComposer.js.map +1 -1
  28. package/package.json +4 -4
  29. package/src/app/agentkit/react/components.interaction.spec.tsx +112 -0
  30. package/src/app/agentkit/react/components.tsx +178 -192
  31. package/src/app/agentkit/react/lifecycle.spec.tsx +5 -16
  32. package/src/app/agentkit/react/stop-button.spec.tsx +1 -1
  33. package/src/app/agentkit/react/stop-button.tsx +5 -5
  34. package/src/app/agentkit/react/styles.css +19 -48
  35. package/src/app/agentkit/react/work-handoff.spec.tsx +202 -69
  36. package/src/app/auth/AuthPage.interaction.spec.tsx +17 -1
  37. package/src/app/auth/AuthPage.tsx +12 -4
  38. package/src/app/chat/AgentKitAssistantChat.tsx +6 -2
  39. package/src/app/integrations/IntegrationDetailPage.spec.tsx +30 -0
  40. package/src/app/integrations/IntegrationDetailPage.tsx +85 -1
  41. package/src/app/integrations/IntegrationsPage.spec.tsx +74 -0
  42. package/src/app/integrations/IntegrationsPage.tsx +47 -20
  43. package/src/app/integrations/connected-server-id.ts +34 -0
  44. package/src/composer/TiptapComposer.spec.ts +7 -7
  45. package/src/composer/TiptapComposer.tsx +12 -13
  46. 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 { useMcpIntegrationsController } from "./IntegrationsPanel.js";
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
- {logo}
290
- <span className="min-w-0 flex-1">
291
- <span className="block truncate text-sm font-medium text-foreground">
292
- {server.name}
293
- </span>
294
- <span className="block truncate text-xs leading-5 text-muted-foreground">
295
- {detail}
296
- </span>
297
- {server.status.state !== "unknown" && server.status.error ? (
298
- <span
299
- className="block truncate text-xs leading-5 text-destructive"
300
- title={server.status.error}
301
- >
302
- {reconnectError
303
- ? t("mcpIntegrations.reconnectFailed", { error: reconnectError })
304
- : t("mcpIntegrations.connectionErrorReason", {
305
- reason: server.status.error,
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
- ) : null}
309
- </span>
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("puts Stop in Send's place until a draft exists, then beside Send", async () => {
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(stopButton()).not.toBeNull();
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 rendered beside send while a run is active, whatever the
1282
- * draft holds. Pass it only while there is something to stop; Escape in the
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 takes Send's place while the composer is empty, and Send
6968
- // joins it once a draft exists so a draft never hides Stop. The
6969
- // slot grows from zero width, gap included, so the controls to
6970
- // its left slide rather than jump.
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
- className={`grid shrink-0 grid-cols-[1fr] justify-items-end transition-[grid-template-columns,margin-inline-start,margin-inline-end] duration-150 ease-out starting:-ms-[var(--agentkit-composer-toolbar-control-gap,0.25rem)] starting:me-0 starting:grid-cols-[0fr] motion-reduce:transition-none ${
6975
- hasDraft ? "me-1" : ""
6976
- }`}
6976
+ hidden={hasDraft && voiceChat === null}
6977
+ className="flex shrink-0"
6977
6978
  >
6978
- <div className="flex min-w-0 transition-[opacity,scale] duration-150 ease-out starting:scale-75 starting:opacity-0 motion-reduce:transition-none">
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("keeps Send last, after Stop, once a draft exists during a run", async () => {
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} />));