@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
@@ -9,10 +9,10 @@ import {
9
9
  useAgentThread,
10
10
  } from "./context.js";
11
11
 
12
- // Once a draft brings Send back beside Stop, Send keeps the primary fill and
13
- // Stop steps down to secondary, so the row never shows two identical
14
- // primaries.
15
- const STOP_BESIDE_SEND =
12
+ // During voice chat, End voice chat holds the primary slot beside Stop (it
13
+ // renders as the send-button slot), so Stop steps down to secondary and the
14
+ // row never shows two identical primaries. A draft hides Stop instead.
15
+ const STOP_BESIDE_PRIMARY =
16
16
  "[[data-agent-composer-slot=toolbar]:has([data-agent-composer-slot=send-button])_&]:bg-secondary [[data-agent-composer-slot=toolbar]:has([data-agent-composer-slot=send-button])_&]:text-secondary-foreground";
17
17
 
18
18
  // Stands in for the run key while a submission has no run id yet, so the
@@ -89,7 +89,7 @@ export function useAgentKitStopButton(options: {
89
89
  aria-busy={stopping || undefined}
90
90
  disabled={stopping}
91
91
  data-agent-composer-slot="stop-button"
92
- className={`flex size-[var(--agentkit-composer-primary-control-size,2rem)] shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,background-color,color] duration-150 hover:opacity-90 disabled:cursor-default disabled:opacity-35 ${STOP_BESIDE_SEND}`}
92
+ className={`flex size-[var(--agentkit-composer-primary-control-size,2rem)] shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,background-color,color] duration-150 hover:opacity-90 disabled:cursor-default disabled:opacity-35 ${STOP_BESIDE_PRIMARY}`}
93
93
  >
94
94
  <IconPlayerStopFilled aria-hidden="true" className="size-4" />
95
95
  </button>
@@ -1257,49 +1257,6 @@
1257
1257
  grid-row: 2;
1258
1258
  }
1259
1259
 
1260
- /* A live finish: the row's old place above the reply closes while the row
1261
- fades in on the footer line. At rest the placeholder takes no space, so a
1262
- host that drops the animation never keeps a gap. 180ms matches
1263
- AGENT_WORK_HANDOFF_MS. */
1264
- .agentkit-work-handoff {
1265
- block-size: 0;
1266
- margin-block: 0;
1267
- overflow: hidden;
1268
- opacity: 0;
1269
- pointer-events: none;
1270
- animation: agentkit-work-handoff-leave 180ms ease-out both;
1271
- }
1272
-
1273
- .agentkit-message-work-host
1274
- .agentkit-activities[data-handoff="enter"]
1275
- .agentkit-activities-row {
1276
- animation: agentkit-work-handoff-enter 180ms ease-out both;
1277
- }
1278
-
1279
- @keyframes agentkit-work-handoff-leave {
1280
- from {
1281
- block-size: var(--agentkit-work-handoff-size);
1282
- margin-block: var(--agentkit-run-segment-gap, 0.75rem);
1283
- opacity: 1;
1284
- }
1285
- }
1286
-
1287
- @keyframes agentkit-work-handoff-enter {
1288
- from {
1289
- opacity: 0;
1290
- translate: 0 -0.25rem;
1291
- }
1292
- }
1293
-
1294
- @media (prefers-reduced-motion: reduce) {
1295
- .agentkit-work-handoff,
1296
- .agentkit-message-work-host
1297
- .agentkit-activities[data-handoff="enter"]
1298
- .agentkit-activities-row {
1299
- animation: none;
1300
- }
1301
- }
1302
-
1303
1260
  /* The last icon's glyph, not its 24px hit area, ends on the text edge. */
1304
1261
  .agentkit-message-actions[data-role="assistant"] .agentkit-message-toolbar {
1305
1262
  grid-column: 2;
@@ -1465,17 +1422,25 @@
1465
1422
  pointer-events: auto;
1466
1423
  }
1467
1424
 
1468
- .agentkit-message .agentkit-message-actions[data-streaming="true"],
1469
- .agentkit-message:hover .agentkit-message-actions[data-streaming="true"],
1425
+ .agentkit-message
1426
+ .agentkit-message-actions[data-streaming="true"]:not(
1427
+ :has(.agentkit-activities)
1428
+ ),
1429
+ .agentkit-message:hover
1430
+ .agentkit-message-actions[data-streaming="true"]:not(
1431
+ :has(.agentkit-activities)
1432
+ ),
1470
1433
  .agentkit-message:focus-within
1471
- .agentkit-message-actions[data-streaming="true"] {
1434
+ .agentkit-message-actions[data-streaming="true"]:not(
1435
+ :has(.agentkit-activities)
1436
+ ) {
1472
1437
  opacity: 0;
1473
1438
  visibility: hidden;
1474
1439
  pointer-events: none;
1475
1440
  }
1476
1441
 
1477
- /* A footer carrying finished work always shows and takes its space; only an
1478
- older reply's controls wait for hover or focus. */
1442
+ /* A footer carrying its reply's work always shows and takes its space, live
1443
+ or finished; only an older reply's controls wait for hover or focus. */
1479
1444
  .agentkit-message-actions[data-role="assistant"]:has(.agentkit-activities) {
1480
1445
  position: static;
1481
1446
  opacity: 1;
@@ -1483,6 +1448,12 @@
1483
1448
  pointer-events: auto;
1484
1449
  }
1485
1450
 
1451
+ .agentkit-message-actions[data-role="assistant"][data-streaming="true"]
1452
+ .agentkit-message-toolbar {
1453
+ visibility: hidden;
1454
+ pointer-events: none;
1455
+ }
1456
+
1486
1457
  .agentkit-message-actions[data-role="assistant"]:has(
1487
1458
  .agentkit-activities
1488
1459
  ):not([data-latest="true"], [data-expanded="true"])
@@ -8,7 +8,7 @@ import {
8
8
  type AgentThreadState,
9
9
  } from "@agent-native/agentkit/client";
10
10
  import type { AgentEvent } from "@agent-native/agentkit/protocol";
11
- import { act } from "react";
11
+ import { act, useEffect } from "react";
12
12
  import { createRoot, type Root } from "react-dom/client";
13
13
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
14
 
@@ -150,68 +150,43 @@ function stubReducedMotion(reduce: boolean) {
150
150
 
151
151
  const work = () =>
152
152
  container.querySelector<HTMLElement>(".agentkit-activities")!;
153
- const placeholder = () =>
154
- container.querySelector<HTMLElement>('[data-agentkit-work-handoff="leave"]');
155
-
156
- describe("Worked for handoff to the reply footer", () => {
157
- beforeEach(() => {
158
- stubReducedMotion(false);
159
- // happy-dom lays nothing out; the handoff carries the row's height over.
160
- vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(40);
161
- });
153
+ const follows = (later: Node, earlier: Node) =>
154
+ Boolean(
155
+ earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING,
156
+ );
157
+
158
+ describe("Worked for row moving into the reply footer", () => {
159
+ beforeEach(() => stubReducedMotion(false));
162
160
 
163
161
  afterEach(async () => {
164
162
  await act(async () => root.unmount());
165
163
  container.remove();
166
164
  vi.unstubAllGlobals();
167
- vi.restoreAllMocks();
168
165
  });
169
166
 
170
- it("closes the row's old space while it fades in on a live finish", async () => {
167
+ it("lets the streaming reply be the focus, then puts the row in its footer", async () => {
171
168
  const live = reduce(liveEvents);
172
169
  const observable = observableController(snapshotOf(live, 0));
173
170
  await render(observable.controller);
174
- expect(work().closest(".agentkit-message-actions")).toBeNull();
175
- // Opened to watch the live work.
176
- await act(async () =>
177
- work()
178
- .querySelector<HTMLButtonElement>(".agentkit-disclosure-summary")!
179
- .click(),
180
- );
181
- expect(work().dataset.open).toBe("true");
171
+ const reply = () => container.querySelector('[data-message-id="reply"]')!;
172
+ // oracle-change: the reply's text is the newest output, so no work row shows while it streams, and the finish no longer animates a placeholder out of the space above the reply.
173
+ expect(container.querySelector(".agentkit-activities")).toBeNull();
182
174
 
183
175
  await act(async () =>
184
176
  observable.update(snapshotOf(reduce(finishEvents, live), 1)),
185
177
  );
186
178
  await flush();
187
179
 
188
- const settled = work();
189
- expect(settled.closest(".agentkit-message-actions")).not.toBeNull();
190
- expect(settled.dataset.handoff).toBe("enter");
191
- // It closes as it moves, so its steps never open below the reply.
192
- expect(settled.dataset.open).toBe("false");
193
- const leaving = placeholder();
194
- expect(leaving).not.toBeNull();
195
- expect(leaving!.getAttribute("aria-hidden")).toBe("true");
180
+ expect(container.querySelectorAll(".agentkit-activities")).toHaveLength(1);
196
181
  expect(
197
- leaving!.style.getPropertyValue("--agentkit-work-handoff-size"),
198
- ).toBe("40px");
199
- expect(leaving!.textContent).toContain("Worked for");
200
- // It holds the row's old place, above the reply.
201
- const reply = container.querySelector('[data-message-id="reply"]')!;
202
- expect(
203
- leaving!.compareDocumentPosition(reply) &
204
- Node.DOCUMENT_POSITION_FOLLOWING,
205
- ).toBeTruthy();
206
-
207
- await act(async () => {
208
- await new Promise((resolve) => setTimeout(resolve, 300));
209
- });
210
- expect(placeholder()).toBeNull();
211
- expect(work().dataset.handoff).toBeUndefined();
182
+ reply().contains(work().closest(".agentkit-message-work-host")),
183
+ ).toBe(true);
184
+ expect(work().textContent).toContain("Worked for");
185
+ // Nothing is left holding a place above the reply.
186
+ expect(reply().previousElementSibling).toBeNull();
212
187
  });
213
188
 
214
- it("hands off a row it saw running when the reply lands whole", async () => {
189
+ it("moves a row it saw running when the reply lands whole", async () => {
215
190
  const running = reduce(liveEvents.slice(0, 2));
216
191
  const observable = observableController(snapshotOf(running, 0));
217
192
  await render(observable.controller);
@@ -235,33 +210,17 @@ describe("Worked for handoff to the reply footer", () => {
235
210
  ),
236
211
  );
237
212
  await flush();
213
+ expect(container.querySelectorAll(".agentkit-activities")).toHaveLength(1);
238
214
  expect(work().closest(".agentkit-message-actions")).not.toBeNull();
239
- expect(work().dataset.handoff).toBe("enter");
240
- expect(placeholder()).not.toBeNull();
241
215
  });
242
216
 
243
- it("does not animate a thread that loads already finished", async () => {
217
+ it("renders a thread that loads finished with its row in the footer", async () => {
244
218
  const finished = reduce(finishEvents, reduce(liveEvents));
245
219
  const observable = observableController(snapshotOf(finished, 0));
246
220
  await render(observable.controller);
247
221
  await flush();
222
+ expect(container.querySelectorAll(".agentkit-activities")).toHaveLength(1);
248
223
  expect(work().closest(".agentkit-message-actions")).not.toBeNull();
249
- expect(work().dataset.handoff).toBeUndefined();
250
- expect(placeholder()).toBeNull();
251
- });
252
-
253
- it("moves at once with reduced motion", async () => {
254
- stubReducedMotion(true);
255
- const live = reduce(liveEvents);
256
- const observable = observableController(snapshotOf(live, 0));
257
- await render(observable.controller);
258
- await act(async () =>
259
- observable.update(snapshotOf(reduce(finishEvents, live), 1)),
260
- );
261
- await flush();
262
- expect(work().closest(".agentkit-message-actions")).not.toBeNull();
263
- expect(work().dataset.handoff).toBeUndefined();
264
- expect(placeholder()).toBeNull();
265
224
  });
266
225
  });
267
226
 
@@ -340,20 +299,153 @@ describe("a reply that narrates between steps", () => {
340
299
  vi.unstubAllGlobals();
341
300
  });
342
301
 
343
- it("keeps one live row above the reply while the run works", async () => {
302
+ const textOf = (text: string): Node => {
303
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
304
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
305
+ if (node.textContent?.includes(text)) return node;
306
+ }
307
+ throw new Error(`No text "${text}" on screen`);
308
+ };
309
+
310
+ it("keeps a saved reply's leading reasoning in the live row", async () => {
311
+ const working = reduce([
312
+ event(1, { type: "run.started" }),
313
+ event(2, {
314
+ type: "message.delta",
315
+ messageId: "reply",
316
+ role: "assistant",
317
+ text: "Looking it up.",
318
+ }),
319
+ search(3, "first", "running"),
320
+ ]);
321
+ const saved: AgentThreadState = {
322
+ ...working,
323
+ messages: [
324
+ {
325
+ id: "reply",
326
+ role: "assistant",
327
+ status: "complete",
328
+ parts: [
329
+ { type: "reasoning", text: "Weighing the two bikes." },
330
+ { type: "text", text: "Looking it up." },
331
+ ],
332
+ },
333
+ ],
334
+ };
335
+ await render(observableController(snapshotOf(saved, 0)).controller);
336
+ await flush();
337
+
338
+ expect(rows()).toHaveLength(1);
339
+ expect(rows()[0]!.dataset.running).toBe("true");
340
+ expect(rows()[0]!.textContent).toContain("Weighing the two bikes.");
341
+ });
342
+
343
+ it("shows a live row for a step whose only output is a result card", async () => {
344
+ function SearchResult({ value }: { value: { id: string } }) {
345
+ return <div data-search-result={value.id}>Results</div>;
346
+ }
347
+ const carded = reduce(narrated.slice(0, 5));
348
+ container = document.createElement("div");
349
+ document.body.appendChild(container);
350
+ root = createRoot(container);
351
+ await act(async () => {
352
+ root.render(
353
+ <AgentKitRoot
354
+ controller={observableController(snapshotOf(carded, 0)).controller}
355
+ threadId={threadId}
356
+ load="manual"
357
+ registry={{ tools: { "web-search": SearchResult } }}
358
+ >
359
+ <AgentKitChat composer={false} />
360
+ </AgentKitRoot>,
361
+ );
362
+ await Promise.resolve();
363
+ });
364
+ await flush();
365
+
366
+ const reply = container.querySelector('[data-message-id="reply"]')!;
367
+ expect(
368
+ follows(reply, container.querySelector('[data-search-result="first"]')!),
369
+ ).toBe(true);
370
+ expect(rows()).toHaveLength(1);
371
+ expect(rows()[0]!.dataset.running).toBe("true");
372
+ expect(follows(rows()[0]!, textOf("Looking"))).toBe(true);
373
+ });
374
+
375
+ it("keeps a row opened to watch the steps open across narration", async () => {
376
+ const searching = reduce(narrated.slice(0, 5));
377
+ const observable = observableController(snapshotOf(searching, 0));
378
+ await render(observable.controller);
379
+ await flush();
380
+ const visible = () => rows().filter((row) => !row.hidden);
381
+ expect(visible()).toHaveLength(1);
382
+ await act(async () =>
383
+ visible()[0]!
384
+ .querySelector<HTMLButtonElement>(".agentkit-disclosure-summary")!
385
+ .click(),
386
+ );
387
+ expect(visible()[0]!.dataset.open).toBe("true");
388
+
389
+ const narrating = reduce(narrated.slice(5, 6), searching);
390
+ await act(async () => observable.update(snapshotOf(narrating, 1)));
391
+ await flush();
392
+ expect(visible()).toHaveLength(0);
393
+
394
+ await act(async () =>
395
+ observable.update(snapshotOf(reduce(narrated.slice(6), narrating), 2)),
396
+ );
397
+ await flush();
398
+ expect(visible()).toHaveLength(1);
399
+ expect(visible()[0]!.dataset.open).toBe("true");
400
+ });
401
+
402
+ it("keeps a row opened to watch the steps open when the run finishes", async () => {
344
403
  const live = reduce(narrated);
345
404
  const observable = observableController(snapshotOf(live, 0));
346
405
  await render(observable.controller);
347
406
  await flush();
407
+ await act(async () =>
408
+ rows()[0]!
409
+ .querySelector<HTMLButtonElement>(".agentkit-disclosure-summary")!
410
+ .click(),
411
+ );
412
+ expect(rows()[0]!.dataset.open).toBe("true");
413
+
414
+ await act(async () =>
415
+ observable.update(snapshotOf(reduce(answered, live), 1)),
416
+ );
417
+ await flush();
348
418
 
349
419
  expect(rows()).toHaveLength(1);
350
- expect(rows()[0]!.dataset.running).toBe("true");
420
+ expect(rows()[0]!.closest(".agentkit-message-actions")).not.toBeNull();
421
+ expect(rows()[0]!.textContent).toContain("Worked for");
422
+ expect(rows()[0]!.dataset.open).toBe("true");
423
+ });
424
+
425
+ it("shows no row while narration after a step streams", async () => {
426
+ const observable = observableController(
427
+ snapshotOf(reduce(narrated.slice(0, 6)), 0),
428
+ );
429
+ await render(observable.controller);
430
+ await flush();
431
+
432
+ expect(rows()).toHaveLength(0);
433
+ expect(textOf("Checking")).toBeTruthy();
434
+ });
435
+
436
+ it("keeps one live row below the reply's latest text while the run works", async () => {
437
+ const live = reduce(narrated);
438
+ const observable = observableController(snapshotOf(live, 0));
439
+ await render(observable.controller);
440
+ await flush();
441
+
442
+ expect(rows()).toHaveLength(1);
443
+ const row = rows()[0]!;
444
+ expect(row.dataset.running).toBe("true");
351
445
  expect(container.textContent).not.toContain("Worked for");
446
+ expect(follows(row, textOf("Checking"))).toBe(true);
352
447
  const reply = container.querySelector('[data-message-id="reply"]')!;
353
- expect(
354
- rows()[0]!.compareDocumentPosition(reply) &
355
- Node.DOCUMENT_POSITION_FOLLOWING,
356
- ).toBeTruthy();
448
+ expect(follows(row, reply)).toBe(true);
357
449
 
358
450
  await act(async () =>
359
451
  observable.update(snapshotOf(reduce(answered, live), 1)),
@@ -362,6 +454,47 @@ describe("a reply that narrates between steps", () => {
362
454
 
363
455
  expect(rows()).toHaveLength(1);
364
456
  expect(rows()[0]!.closest(".agentkit-message-actions")).not.toBeNull();
457
+ expect(follows(rows()[0]!, textOf("Buy the Trek."))).toBe(true);
365
458
  expect(container.textContent?.match(/Worked for/g)).toHaveLength(1);
366
459
  });
460
+
461
+ it("keeps a step's result card above the reply through the finish", async () => {
462
+ const mounts = vi.fn();
463
+ function SearchResult({ value }: { value: { id: string } }) {
464
+ useEffect(() => mounts(value.id), [value.id]);
465
+ return <div data-search-result={value.id}>Results</div>;
466
+ }
467
+ const live = reduce(narrated);
468
+ const observable = observableController(snapshotOf(live, 0));
469
+ container = document.createElement("div");
470
+ document.body.appendChild(container);
471
+ root = createRoot(container);
472
+ await act(async () => {
473
+ root.render(
474
+ <AgentKitRoot
475
+ controller={observable.controller}
476
+ threadId={threadId}
477
+ load="manual"
478
+ registry={{ tools: { "web-search": SearchResult } }}
479
+ >
480
+ <AgentKitChat composer={false} />
481
+ </AgentKitRoot>,
482
+ );
483
+ await Promise.resolve();
484
+ });
485
+ await flush();
486
+
487
+ const reply = container.querySelector('[data-message-id="reply"]')!;
488
+ const card = () => container.querySelector('[data-search-result="first"]')!;
489
+ expect(follows(reply, card())).toBe(true);
490
+ expect(follows(rows()[0]!, reply)).toBe(true);
491
+
492
+ await act(async () =>
493
+ observable.update(snapshotOf(reduce(answered, live), 1)),
494
+ );
495
+ await flush();
496
+
497
+ expect(follows(reply, card())).toBe(true);
498
+ expect(mounts.mock.calls.filter(([id]) => id === "first")).toHaveLength(1);
499
+ });
367
500
  });
@@ -74,19 +74,33 @@ describe("AuthPage interactions", () => {
74
74
  trackingApp="design"
75
75
  googleOnly
76
76
  showGoogle
77
+ signupLegalNotice={{
78
+ termsUrl: "https://example.com/terms",
79
+ privacyUrl: "https://example.com/privacy",
80
+ }}
77
81
  identitySsoEnabled={false}
78
82
  />,
79
83
  );
80
84
  });
81
85
  expect(
82
86
  container.textContent?.includes(
83
- "Your Design signup also creates a Builder.io account",
87
+ "You also create a Builder.io account and agree to Builder’s",
84
88
  ),
85
89
  ).toBe(variant === "automatic");
86
90
  expect(
87
91
  container.querySelector<HTMLButtonElement>("#google-btn")?.disabled,
88
92
  ).toBe(false);
93
+ const footer = container.querySelector(".legal-note");
89
94
  if (variant === "automatic") {
95
+ expect(footer?.textContent).not.toContain(
96
+ "By signing up, you accept our",
97
+ );
98
+ expect(
99
+ container
100
+ .querySelector("#google-btn")
101
+ ?.compareDocumentPosition(footer ?? container),
102
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
103
+ expect(footer?.querySelectorAll("a").length).toBe(2);
90
104
  expect(
91
105
  container.querySelector(
92
106
  'a[href="https://www.builder.io/legal/terms"]',
@@ -97,6 +111,8 @@ describe("AuthPage interactions", () => {
97
111
  'a[href="https://www.builder.io/legal/privacy"]',
98
112
  ),
99
113
  ).not.toBeNull();
114
+ } else {
115
+ expect(footer).toBeNull();
100
116
  }
101
117
  },
102
118
  );
@@ -304,6 +304,7 @@ function trackAuth(
304
304
  );
305
305
  const legacyProperties = {
306
306
  app,
307
+ hostname: window.location.hostname,
307
308
  ...(app === "design" ? builderSignupTelemetry : {}),
308
309
  ...properties,
309
310
  };
@@ -2432,9 +2433,10 @@ export function AuthPage(props: AuthPageProps) {
2432
2433
  </div>
2433
2434
  ) : null;
2434
2435
  const builderLegalNote =
2436
+ view !== "login" &&
2435
2437
  builderSignup?.status === "assigned" &&
2436
2438
  builderSignup.variant === "automatic" ? (
2437
- <p className="legal-note">
2439
+ <span>
2438
2440
  {t("builderSignupDisclosure")}{" "}
2439
2441
  <a
2440
2442
  href="https://www.builder.io/legal/terms"
@@ -2452,7 +2454,7 @@ export function AuthPage(props: AuthPageProps) {
2452
2454
  {t("legalPrivacy")}
2453
2455
  </a>
2454
2456
  {t("legalSuffix")}
2455
- </p>
2457
+ </span>
2456
2458
  ) : null;
2457
2459
  const legalNote = signupLegalNotice ? (
2458
2460
  <p className="legal-note">
@@ -2481,7 +2483,10 @@ export function AuthPage(props: AuthPageProps) {
2481
2483
  <span data-i18n="legalSuffix">
2482
2484
  {signupLegalNotice.suffix ?? t("legalSuffix")}
2483
2485
  </span>
2486
+ {builderLegalNote && <> {builderLegalNote}</>}
2484
2487
  </p>
2488
+ ) : builderLegalNote ? (
2489
+ <p className="legal-note">{builderLegalNote}</p>
2485
2490
  ) : null;
2486
2491
  const signupForm = (
2487
2492
  <AuthForm
@@ -2684,7 +2689,6 @@ export function AuthPage(props: AuthPageProps) {
2684
2689
  {t("googleNotConfigured")}
2685
2690
  </p>
2686
2691
  ) : null}
2687
- {view !== "login" && builderLegalNote}
2688
2692
  {showGoogle ? (
2689
2693
  <div className="google-signin" id="google-signin">
2690
2694
  <button
@@ -2847,7 +2851,11 @@ export function AuthPage(props: AuthPageProps) {
2847
2851
  </button>
2848
2852
  </div>
2849
2853
  ) : null}
2850
- {!googleOnly ? signupForm : null}
2854
+ {!googleOnly ? (
2855
+ signupForm
2856
+ ) : builderLegalNote ? (
2857
+ <p className="legal-note">{builderLegalNote}</p>
2858
+ ) : null}
2851
2859
  <div
2852
2860
  id="verification-step"
2853
2861
  className={`form verification-step ${view === "verification" ? "active" : ""}`}
@@ -6851,7 +6851,7 @@ function AgentKitRunFailure({
6851
6851
  )
6852
6852
  }
6853
6853
  />
6854
- {continueFailed ? (
6854
+ {continueFailed && !superseded ? (
6855
6855
  <ComposerNotice
6856
6856
  role="alert"
6857
6857
  noticeId="continue-failed"
@@ -6912,8 +6912,12 @@ function AgentKitRunFailure({
6912
6912
  : undefined
6913
6913
  }
6914
6914
  onContinue={continueStoppedRun}
6915
+ // A newer run carries the turn now, so a refused Continue is stale and
6916
+ // would contradict the Stop button that run shows.
6915
6917
  continueError={
6916
- continueFailed ? t("agentChat.recovery.continueUnavailable") : null
6918
+ continueFailed && !superseded
6919
+ ? t("agentChat.recovery.continueUnavailable")
6920
+ : null
6917
6921
  }
6918
6922
  onRetry={() => void retryFailedTurn()}
6919
6923
  onRetryWithoutAttachments={retryWithoutAttachments}
@@ -522,6 +522,36 @@ describe("IntegrationDetailPage", () => {
522
522
  expect(row?.querySelector("button")?.textContent).toBe("Remove");
523
523
  });
524
524
 
525
+ it("opens a custom connected server by its slug, with Reconnect and Remove", async () => {
526
+ // The tile links a custom integration (one added by URL, with no catalog entry) to a
527
+ // slug of its name; this is that slug resolving back to the server, so the page has
528
+ // the facts and the actions a built-in's page has.
529
+ servers([
530
+ {
531
+ id: "custom-1",
532
+ scope: "user",
533
+ name: "Internal Docs",
534
+ url: "https://mcp.example.com/mcp",
535
+ authMode: "headers",
536
+ createdAt: 1,
537
+ mergedId: "user:custom-1",
538
+ status: { state: "error", error: "the server refused the token" },
539
+ },
540
+ ]);
541
+ await render("internal-docs");
542
+ const detail = container.querySelector("[data-connected-server-detail]");
543
+ expect(detail).not.toBeNull();
544
+ expect(detail?.textContent).toContain("Internal Docs");
545
+ expect(detail?.textContent).toContain("https://mcp.example.com/mcp");
546
+ expect(detail?.textContent).toContain("Connection error");
547
+ expect(detail?.textContent).toContain("the server refused the token");
548
+ const labels = Array.from(detail?.querySelectorAll("button") ?? []).map(
549
+ (button) => button.textContent?.trim(),
550
+ );
551
+ expect(labels).toContain("Reconnect");
552
+ expect(labels).toContain("Remove");
553
+ });
554
+
525
555
  it("marks a beta integration", async () => {
526
556
  await render("grafana");
527
557
  expect(rowText("status")).toContain("Beta");