@runtypelabs/persona 4.11.0 → 4.11.1

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.
@@ -4447,6 +4447,8 @@ type AgentWidgetToolCallConfig$1 = {
4447
4447
  }) => HTMLElement | string | null;
4448
4448
  /**
4449
4449
  * Override the summary content for grouped consecutive tool-call containers.
4450
+ * In `groupedMode: "summary"`, the summary owns the visible row starting
4451
+ * with the first tool so its DOM stays stable as more calls arrive.
4450
4452
  * Return `null` to fall back to the built-in `Called [x] tools` summary.
4451
4453
  */
4452
4454
  renderGroupedSummary?: (context: {
@@ -4447,6 +4447,8 @@ type AgentWidgetToolCallConfig$1 = {
4447
4447
  }) => HTMLElement | string | null;
4448
4448
  /**
4449
4449
  * Override the summary content for grouped consecutive tool-call containers.
4450
+ * In `groupedMode: "summary"`, the summary owns the visible row starting
4451
+ * with the first tool so its DOM stays stable as more calls arrive.
4450
4452
  * Return `null` to fall back to the built-in `Called [x] tools` summary.
4451
4453
  */
4452
4454
  renderGroupedSummary?: (context: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.11.0",
3
+ "version": "4.11.1",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
package/src/types.ts CHANGED
@@ -3478,6 +3478,8 @@ export type AgentWidgetToolCallConfig = {
3478
3478
  }) => HTMLElement | string | null;
3479
3479
  /**
3480
3480
  * Override the summary content for grouped consecutive tool-call containers.
3481
+ * In `groupedMode: "summary"`, the summary owns the visible row starting
3482
+ * with the first tool so its DOM stays stable as more calls arrive.
3481
3483
  * Return `null` to fall back to the built-in `Called [x] tools` summary.
3482
3484
  */
3483
3485
  renderGroupedSummary?: (context: {
@@ -31,7 +31,10 @@ const injectToolMessage = (
31
31
  id,
32
32
  role: "assistant",
33
33
  content: "",
34
- createdAt: new Date().toISOString(),
34
+ // Real stream updates reuse the original tool message's timeline fields.
35
+ // Keep them stable here so an upsert does not reorder the group.
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ sequence: Number(id.match(/(\d+)$/)?.[1] ?? 0),
35
38
  streaming: status !== "complete",
36
39
  variant: "tool",
37
40
  toolCall: {
@@ -76,6 +79,7 @@ const injectReasoningMessage = (
76
79
 
77
80
  describe("createAgentExperience tool call display modes", () => {
78
81
  beforeEach(() => {
82
+ window.localStorage.clear();
79
83
  vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
80
84
  cb(0);
81
85
  return 1;
@@ -157,6 +161,48 @@ describe("createAgentExperience tool call display modes", () => {
157
161
  controller.destroy();
158
162
  });
159
163
 
164
+ it("preserves a custom collapsed summary across tool chunk updates", () => {
165
+ const mount = createMount();
166
+ const controller = createAgentExperience(mount, {
167
+ apiUrl: "https://api.example.com/chat",
168
+ launcher: { enabled: false },
169
+ features: {
170
+ toolCallDisplay: {
171
+ grouped: false,
172
+ expandable: false,
173
+ loadingAnimation: "none",
174
+ },
175
+ },
176
+ toolCall: {
177
+ renderCollapsedSummary: ({ toolCall }: any) => {
178
+ const summary = document.createElement("span");
179
+ summary.setAttribute("data-test-collapsed-summary", "true");
180
+ summary.textContent = `${toolCall.name}:${toolCall.status}`;
181
+ return summary;
182
+ },
183
+ },
184
+ } as any);
185
+
186
+ injectToolMessage(controller, {
187
+ id: "tool-1",
188
+ name: "Run SQL",
189
+ chunks: ["starting"],
190
+ });
191
+ const bubble = mount.querySelector("#bubble-tool-1");
192
+ const summary = mount.querySelector("[data-test-collapsed-summary='true']");
193
+
194
+ injectToolMessage(controller, {
195
+ id: "tool-1",
196
+ name: "Run SQL",
197
+ chunks: ["starting", "received rows"],
198
+ });
199
+
200
+ expect(mount.querySelector("#bubble-tool-1")).toBe(bubble);
201
+ expect(mount.querySelector("[data-test-collapsed-summary='true']")).toBe(summary);
202
+
203
+ controller.destroy();
204
+ });
205
+
160
206
  it("renders a collapsed preview for active reasoning rows when enabled", () => {
161
207
  const mount = createMount();
162
208
  const controller = createAgentExperience(mount, {
@@ -202,6 +248,41 @@ describe("createAgentExperience tool call display modes", () => {
202
248
  controller.destroy();
203
249
  });
204
250
 
251
+ it("preserves tool rows when a standalone row becomes a stacked group", () => {
252
+ const mount = createMount();
253
+ const controller = createAgentExperience(mount, {
254
+ apiUrl: "https://api.example.com/chat",
255
+ launcher: { enabled: false },
256
+ features: {
257
+ toolCallDisplay: {
258
+ grouped: true,
259
+ groupedMode: "stack",
260
+ },
261
+ },
262
+ } as any);
263
+
264
+ injectToolMessage(controller, { id: "tool-1", name: "Inspect data" });
265
+
266
+ const firstRow = mount.querySelector("#wrapper-tool-1");
267
+ const firstBubble = mount.querySelector("#bubble-tool-1");
268
+ expect(firstRow).not.toBeNull();
269
+ expect(firstBubble).not.toBeNull();
270
+
271
+ injectToolMessage(controller, { id: "tool-2", name: "Run analysis" });
272
+
273
+ const groupRow = mount.querySelector("[data-persona-tool-group-row='true']");
274
+ expect(groupRow).not.toBeNull();
275
+ expect(mount.querySelector("#wrapper-tool-1")).toBe(firstRow);
276
+ expect(mount.querySelector("#bubble-tool-1")).toBe(firstBubble);
277
+
278
+ injectToolMessage(controller, { id: "tool-3", name: "Build chart" });
279
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
280
+ expect(mount.querySelector("#wrapper-tool-1")).toBe(firstRow);
281
+ expect(mount.querySelector("#bubble-tool-1")).toBe(firstBubble);
282
+
283
+ controller.destroy();
284
+ });
285
+
205
286
  it("keeps tool calls grouped across reasoning that is hidden", () => {
206
287
  const mount = createMount();
207
288
  const controller = createAgentExperience(mount, {
@@ -226,30 +307,118 @@ describe("createAgentExperience tool call display modes", () => {
226
307
  controller.destroy();
227
308
  });
228
309
 
229
- it("can render a grouped tool sequence as one summary item", () => {
310
+ it("keeps a summary group's visible DOM stable from the first tool onward", () => {
230
311
  const mount = createMount();
312
+ const renderedSummaries: HTMLElement[] = [];
231
313
  const controller = createAgentExperience(mount, {
232
314
  apiUrl: "https://api.example.com/chat",
233
315
  launcher: { enabled: false },
234
316
  features: {
235
317
  toolCallDisplay: {
318
+ collapsedMode: "tool-name",
319
+ activePreview: false,
236
320
  grouped: true,
237
321
  groupedMode: "summary",
322
+ expandable: false,
323
+ loadingAnimation: "shimmer-color",
238
324
  },
239
325
  },
240
326
  toolCall: {
241
- renderGroupedSummary: () => "Analysis ready",
327
+ renderCollapsedSummary: ({ message, toolCall }: any) => {
328
+ const step = document.createElement("span");
329
+ step.setAttribute("data-test-collapsed-step", message.id);
330
+ step.textContent = `${toolCall.name}:${toolCall.status}`;
331
+ return step;
332
+ },
333
+ renderGroupedSummary: ({ toolCalls }: any) => {
334
+ const list = document.createElement("span");
335
+ list.setAttribute("data-test-activity-list", "true");
336
+ toolCalls.forEach((toolCall: { id: string; status: string }) => {
337
+ const step = document.createElement("span");
338
+ step.setAttribute("data-test-activity-step", toolCall.id);
339
+ step.textContent = `${toolCall.id}:${toolCall.status}`;
340
+ list.appendChild(step);
341
+ });
342
+ renderedSummaries.push(list);
343
+ return list;
344
+ },
242
345
  },
243
346
  } as any);
244
347
 
245
- injectToolMessage(controller, { id: "tool-1", name: "Inspect data", status: "complete" });
246
- injectToolMessage(controller, { id: "tool-2", name: "Run analysis", status: "complete" });
348
+ injectToolMessage(controller, {
349
+ id: "tool-1",
350
+ name: "Inspect data",
351
+ status: "pending",
352
+ });
247
353
 
248
- const group = mount.querySelector("[data-persona-tool-group='true']");
249
- expect(group?.textContent).toContain("Analysis ready");
250
- expect(group?.querySelector("[data-persona-tool-group-item]")).toBeNull();
354
+ const groupRow = mount.querySelector("[data-persona-tool-group-row='true']");
355
+ const activityList = mount.querySelector("[data-test-activity-list='true']");
356
+ const firstStep = mount.querySelector("[data-test-activity-step='tool-1']");
357
+ expect(groupRow).not.toBeNull();
358
+ expect(groupRow?.getAttribute("data-wrapper-id")).toBe("tool-group-tool-1");
359
+ expect(activityList).not.toBeNull();
360
+ expect(firstStep).not.toBeNull();
251
361
  expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
252
362
 
363
+ injectToolMessage(controller, {
364
+ id: "tool-1",
365
+ name: "Inspect data",
366
+ status: "running",
367
+ chunks: ["starting"],
368
+ });
369
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
370
+ expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
371
+ expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
372
+
373
+ injectToolMessage(controller, {
374
+ id: "tool-1",
375
+ name: "Inspect data",
376
+ chunks: ["starting", "received rows"],
377
+ });
378
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
379
+ expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
380
+ expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
381
+
382
+ injectToolMessage(controller, { id: "tool-2", name: "Run analysis" });
383
+
384
+ const secondStep = mount.querySelector("[data-test-activity-step='tool-2']");
385
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
386
+ expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
387
+ expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
388
+ expect(secondStep).not.toBeNull();
389
+ expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
390
+
391
+ injectToolMessage(controller, {
392
+ id: "tool-1",
393
+ name: "Inspect data",
394
+ status: "complete",
395
+ chunks: ["starting", "received rows"],
396
+ });
397
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
398
+ expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
399
+ expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
400
+ expect(mount.querySelector("[data-test-activity-step='tool-2']")).toBe(secondStep);
401
+ expect(firstStep?.textContent).toBe("tool-1:complete");
402
+ expect(secondStep?.textContent).toBe("tool-2:running");
403
+
404
+ injectToolMessage(controller, {
405
+ id: "tool-2",
406
+ name: "Run analysis",
407
+ status: "complete",
408
+ });
409
+ expect(mount.querySelector("[data-persona-tool-group-row='true']")).toBe(groupRow);
410
+ expect(mount.querySelector("[data-test-activity-list='true']")).toBe(activityList);
411
+ expect(mount.querySelector("[data-test-activity-step='tool-1']")).toBe(firstStep);
412
+ expect(mount.querySelector("[data-test-activity-step='tool-2']")).toBe(secondStep);
413
+ expect(secondStep?.textContent).toBe("tool-2:complete");
414
+ expect(mount.querySelector(".persona-tool-bubble")).toBeNull();
415
+
416
+ // The callback can return a fresh tree on every status update; idiomorph
417
+ // must reconcile it into the existing visible summary rather than mounting
418
+ // those detached callback results.
419
+ expect(renderedSummaries.length).toBeGreaterThan(1);
420
+ expect(renderedSummaries).not.toContain(activityList);
421
+
253
422
  controller.destroy();
254
423
  });
255
424
  });
package/src/ui.ts CHANGED
@@ -4817,6 +4817,8 @@ export const createAgentExperience = (
4817
4817
  if (config.features?.toolCallDisplay?.grouped) {
4818
4818
  const toolGroups: AgentWidgetMessage[][] = [];
4819
4819
  let currentGroup: AgentWidgetMessage[] = [];
4820
+ const summaryOnly =
4821
+ config.features.toolCallDisplay.groupedMode === "summary";
4820
4822
 
4821
4823
  messages.forEach((message) => {
4822
4824
  if (message.variant === "tool" && message.toolCall && showToolCalls) {
@@ -4829,16 +4831,20 @@ export const createAgentExperience = (
4829
4831
  if (message.variant === "reasoning" && !showReasoning) {
4830
4832
  return;
4831
4833
  }
4832
- if (currentGroup.length > 1) {
4834
+ // Summary mode owns the visible row from the first tool onward. Waiting
4835
+ // for a second tool would replace the standalone tool bubble with a new
4836
+ // group row, restarting custom activity animations and transiently
4837
+ // dropping interactive DOM state during the handoff.
4838
+ if (currentGroup.length > 1 || (summaryOnly && currentGroup.length > 0)) {
4833
4839
  toolGroups.push(currentGroup);
4834
4840
  }
4835
4841
  currentGroup = [];
4836
4842
  });
4837
- if (currentGroup.length > 1) {
4843
+ if (currentGroup.length > 1 || (summaryOnly && currentGroup.length > 0)) {
4838
4844
  toolGroups.push(currentGroup);
4839
4845
  }
4840
4846
 
4841
- toolGroups.forEach((group, groupIndex) => {
4847
+ toolGroups.forEach((group) => {
4842
4848
  const wrappers = group
4843
4849
  .map((groupMessage) =>
4844
4850
  Array.from(tempContainer.children).find(
@@ -4849,12 +4855,12 @@ export const createAgentExperience = (
4849
4855
  )
4850
4856
  .filter((wrapper): wrapper is HTMLElement => Boolean(wrapper));
4851
4857
 
4852
- if (wrappers.length < 2) {
4858
+ if (wrappers.length < (summaryOnly ? 1 : 2)) {
4853
4859
  return;
4854
4860
  }
4855
4861
 
4856
4862
  const groupWrapper = createMessageRow(
4857
- `tool-group-${groupIndex}-${group[0].id}`,
4863
+ `tool-group-${group[0].id}`,
4858
4864
  "assistant"
4859
4865
  );
4860
4866
  groupWrapper.setAttribute("data-persona-tool-group-row", "true");
@@ -4867,8 +4873,9 @@ export const createAgentExperience = (
4867
4873
  const summary = document.createElement("div");
4868
4874
  summary.className =
4869
4875
  "persona-tool-group-summary persona-text-xs persona-text-persona-muted";
4876
+ summary.id = `tool-group-summary-${group[0].id}`;
4870
4877
 
4871
- const defaultSummary = `Called ${group.length} tools`;
4878
+ const defaultSummary = `Called ${group.length} ${group.length === 1 ? "tool" : "tools"}`;
4872
4879
  const renderedSummary = config.toolCall?.renderGroupedSummary?.({
4873
4880
  messages: group,
4874
4881
  toolCalls: group
@@ -4883,8 +4890,9 @@ export const createAgentExperience = (
4883
4890
 
4884
4891
  const stack = document.createElement("div");
4885
4892
  stack.className = "persona-tool-group-stack persona-flex persona-flex-col";
4893
+ stack.id = `tool-group-stack-${group[0].id}`;
4894
+ stack.setAttribute("data-persona-tool-group-stack", "true");
4886
4895
 
4887
- const summaryOnly = config.features?.toolCallDisplay?.groupedMode === "summary";
4888
4896
  groupContainer.appendChild(summary);
4889
4897
  if (!summaryOnly) {
4890
4898
  groupContainer.appendChild(stack);