@agent-native/toolkit 0.201.1 → 0.201.2-nightly-20261006010750

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 (68) hide show
  1. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  2. package/dist/app/agentkit/react/components.js +292 -23
  3. package/dist/app/agentkit/react/components.js.map +1 -1
  4. package/dist/app/agentkit/react/context.d.ts +5 -0
  5. package/dist/app/agentkit/react/context.d.ts.map +1 -1
  6. package/dist/app/agentkit/react/context.js +5 -0
  7. package/dist/app/agentkit/react/context.js.map +1 -1
  8. package/dist/app/agentkit/react/styles.css +74 -2
  9. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  10. package/dist/app/chat/AgentKitAssistantChat.js +5 -0
  11. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  12. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  13. package/dist/app/i18n/catalogs/ar-SA.js +3 -0
  14. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  15. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  16. package/dist/app/i18n/catalogs/de-DE.js +3 -0
  17. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  18. package/dist/app/i18n/catalogs/en-US.d.ts +3 -0
  19. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  20. package/dist/app/i18n/catalogs/en-US.js +3 -0
  21. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  22. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  23. package/dist/app/i18n/catalogs/es-ES.js +3 -0
  24. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  25. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  26. package/dist/app/i18n/catalogs/fr-FR.js +3 -0
  27. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  28. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  29. package/dist/app/i18n/catalogs/hi-IN.js +3 -0
  30. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  31. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  32. package/dist/app/i18n/catalogs/ja-JP.js +3 -0
  33. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  34. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  35. package/dist/app/i18n/catalogs/ko-KR.js +3 -0
  36. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  37. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  38. package/dist/app/i18n/catalogs/pt-BR.js +3 -0
  39. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  40. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  41. package/dist/app/i18n/catalogs/zh-CN.js +3 -0
  42. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  43. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  44. package/dist/app/i18n/catalogs/zh-TW.js +3 -0
  45. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  46. package/dist/composer/AgentSuggestionBar.d.ts +1 -1
  47. package/dist/composer/AgentSuggestionBar.d.ts.map +1 -1
  48. package/dist/composer/AgentSuggestionBar.js +75 -6
  49. package/dist/composer/AgentSuggestionBar.js.map +1 -1
  50. package/package.json +4 -4
  51. package/src/app/agentkit/react/components.tsx +492 -60
  52. package/src/app/agentkit/react/context.tsx +10 -0
  53. package/src/app/agentkit/react/lifecycle.spec.tsx +487 -1
  54. package/src/app/agentkit/react/styles.css +74 -2
  55. package/src/app/chat/AgentKitAssistantChat.tsx +5 -0
  56. package/src/app/i18n/catalogs/ar-SA.ts +3 -0
  57. package/src/app/i18n/catalogs/de-DE.ts +3 -0
  58. package/src/app/i18n/catalogs/en-US.ts +3 -0
  59. package/src/app/i18n/catalogs/es-ES.ts +3 -0
  60. package/src/app/i18n/catalogs/fr-FR.ts +3 -0
  61. package/src/app/i18n/catalogs/hi-IN.ts +3 -0
  62. package/src/app/i18n/catalogs/ja-JP.ts +3 -0
  63. package/src/app/i18n/catalogs/ko-KR.ts +3 -0
  64. package/src/app/i18n/catalogs/pt-BR.ts +3 -0
  65. package/src/app/i18n/catalogs/zh-CN.ts +3 -0
  66. package/src/app/i18n/catalogs/zh-TW.ts +3 -0
  67. package/src/composer/AgentSuggestionBar.overflow.spec.tsx +277 -0
  68. package/src/composer/AgentSuggestionBar.tsx +113 -8
@@ -922,6 +922,9 @@ const messages: ToolkitAgentChatTranslation = {
922
922
  "tool.failedWithoutDetails": "Aucun détail d’erreur disponible.",
923
923
  "tool.input": "Entrée",
924
924
  "tool.inputWithLabel": "Entrée - {{label}}",
925
+ "tool.identifierHidden": "[Identifiant masqué]",
926
+ "tool.contentOmitted": "[Contenu omis]",
927
+ "tool.circularReference": "[Référence circulaire]",
925
928
  "tool.interrupted":
926
929
  "Interrompu avant la confirmation de la fin de l’opération ; celle-ci a peut-être abouti ou non. Vérifiez avant de réessayer.",
927
930
  "tool.longRunning":
@@ -1271,6 +1271,9 @@ const messages: ToolkitAgentChatTranslation = {
1271
1271
  "tool.failedWithoutDetails": "त्रुटि का कोई विवरण उपलब्ध नहीं है।",
1272
1272
  "tool.input": "इनपुट",
1273
1273
  "tool.inputWithLabel": "इनपुट - {{label}}",
1274
+ "tool.identifierHidden": "[पहचानकर्ता छिपाया गया]",
1275
+ "tool.contentOmitted": "[सामग्री छोड़ी गई]",
1276
+ "tool.circularReference": "[चक्रीय संदर्भ]",
1274
1277
  "tool.interrupted":
1275
1278
  "रिपोर्ट पूरी होने से पहले प्रक्रिया रुक गई — संभव है कि कार्य पूरा हुआ हो या न हुआ हो। दोबारा प्रयास करने से पहले जाँच लें।",
1276
1279
  "tool.longRunning": "काम अभी जारी है। बड़े अपडेट में एक-दो मिनट लग सकते हैं।",
@@ -1306,6 +1306,9 @@ const messages: ToolkitAgentChatTranslation = {
1306
1306
  "tool.failedWithoutDetails": "エラーの詳細はありません。",
1307
1307
  "tool.input": "入力",
1308
1308
  "tool.inputWithLabel": "入力 - {{label}}",
1309
+ "tool.identifierHidden": "[識別子を非表示]",
1310
+ "tool.contentOmitted": "[内容を省略]",
1311
+ "tool.circularReference": "[循環参照]",
1309
1312
  "tool.interrupted":
1310
1313
  "完了報告の前に中断されました。処理は完了している場合も、していない場合もあります。再試行する前に確認してください。",
1311
1314
  "tool.longRunning":
@@ -1285,6 +1285,9 @@ const messages: ToolkitAgentChatTranslation = {
1285
1285
  "tool.failedWithoutDetails": "오류 세부 정보가 없습니다.",
1286
1286
  "tool.input": "입력",
1287
1287
  "tool.inputWithLabel": "입력 - {{label}}",
1288
+ "tool.identifierHidden": "[식별자 숨김]",
1289
+ "tool.contentOmitted": "[내용 생략]",
1290
+ "tool.circularReference": "[순환 참조]",
1288
1291
  "tool.interrupted":
1289
1292
  "완료 보고 전에 중단되었습니다. 작업이 완료되었을 수도 있고 아닐 수도 있습니다. 다시 시도하기 전에 확인하세요.",
1290
1293
  "tool.longRunning":
@@ -909,6 +909,9 @@ const messages: ToolkitAgentChatTranslation = {
909
909
  "tool.failedWithoutDetails": "Nenhum detalhe do erro disponível.",
910
910
  "tool.input": "Entrada",
911
911
  "tool.inputWithLabel": "Entrada - {{label}}",
912
+ "tool.identifierHidden": "[Identificador oculto]",
913
+ "tool.contentOmitted": "[Conteúdo omitido]",
914
+ "tool.circularReference": "[Referência circular]",
912
915
  "tool.interrupted":
913
916
  "Interrompido antes da confirmação da conclusão — a operação pode ou não ter sido concluída. Verifique antes de tentar novamente.",
914
917
  "tool.longRunning":
@@ -1218,6 +1218,9 @@ const messages: ToolkitAgentChatTranslation = {
1218
1218
  "tool.failedWithoutDetails": "没有可用的错误详情。",
1219
1219
  "tool.input": "输入",
1220
1220
  "tool.inputWithLabel": "输入 - {{label}}",
1221
+ "tool.identifierHidden": "[标识符已隐藏]",
1222
+ "tool.contentOmitted": "[内容已省略]",
1223
+ "tool.circularReference": "[循环引用]",
1221
1224
  "tool.interrupted":
1222
1225
  "在完成报告前被中断——操作可能已经完成,也可能尚未完成。重试前请先检查。",
1223
1226
  "tool.longRunning": "仍在处理。大型更新可能需要一两分钟。",
@@ -1222,6 +1222,9 @@ const messages: ToolkitAgentChatTranslation = {
1222
1222
  "tool.failedWithoutDetails": "沒有可用的錯誤詳細資料。",
1223
1223
  "tool.input": "輸入",
1224
1224
  "tool.inputWithLabel": "輸入 - {{label}}",
1225
+ "tool.identifierHidden": "[識別碼已隱藏]",
1226
+ "tool.contentOmitted": "[內容已省略]",
1227
+ "tool.circularReference": "[循環參照]",
1225
1228
  "tool.interrupted":
1226
1229
  "在完成回報前遭到中斷——操作可能已完成,也可能尚未完成。重試前請先檢查。",
1227
1230
  "tool.longRunning": "仍在處理。大型更新可能需要一兩分鐘。",
@@ -0,0 +1,277 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import React, { act } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import type { ActionButtonProps } from "../design-system/types.js";
12
+ import { ToolkitProvider } from "../provider.js";
13
+ import { AgentSuggestionBar } from "./AgentSuggestionBar.js";
14
+
15
+ const DOCUMENT_SUGGESTIONS = [
16
+ "Summarize this document",
17
+ "Improve this document",
18
+ "Find the action items",
19
+ ];
20
+ const CHIP_WIDTHS: Record<string, number> = {
21
+ "Summarize this document": 155,
22
+ "Improve this document": 144,
23
+ "Find the action items": 144,
24
+ };
25
+ // A 280px agent panel minus the bar's and scroller's inline padding.
26
+ const NARROWEST_SCROLLER_WIDTH = 252;
27
+
28
+ describe("AgentSuggestionBar overflow", () => {
29
+ let container: HTMLDivElement;
30
+ let root: Root;
31
+ let gapStyle: HTMLStyleElement;
32
+ let scrollerWidth: number;
33
+ let scrollOffset: number;
34
+ let resizeCallbacks: Array<() => void>;
35
+ let disconnectObserver: ReturnType<typeof vi.fn>;
36
+ const descriptors = new Map<string, PropertyDescriptor | undefined>();
37
+
38
+ const scroller = () =>
39
+ container.querySelector<HTMLElement>(
40
+ '[data-agent-suggestion-scroller="true"]',
41
+ )!;
42
+ const track = () =>
43
+ container.querySelector<HTMLElement>(
44
+ '[data-agent-suggestion-track="true"]',
45
+ )!;
46
+ const trackWidth = () =>
47
+ Number.parseFloat(
48
+ track().style.getPropertyValue("--agent-suggestion-track-width"),
49
+ );
50
+ const fadeEdges = () => ({
51
+ start: scroller().getAttribute("data-overflow-start"),
52
+ end: scroller().getAttribute("data-overflow-end"),
53
+ });
54
+
55
+ function defineLayout(name: string, descriptor: PropertyDescriptor) {
56
+ descriptors.set(
57
+ name,
58
+ Object.getOwnPropertyDescriptor(HTMLElement.prototype, name),
59
+ );
60
+ Object.defineProperty(HTMLElement.prototype, name, {
61
+ configurable: true,
62
+ ...descriptor,
63
+ });
64
+ }
65
+
66
+ function render(suggestions: string[]) {
67
+ act(() => {
68
+ root.render(
69
+ <AgentSuggestionBar
70
+ ariaLabel="Suggested prompts"
71
+ suggestions={suggestions}
72
+ onSelect={() => {}}
73
+ />,
74
+ );
75
+ });
76
+ }
77
+
78
+ function resizeScroller(width: number) {
79
+ scrollerWidth = width;
80
+ act(() => resizeCallbacks.forEach((callback) => callback()));
81
+ }
82
+
83
+ function scrollTo(offset: number) {
84
+ scrollOffset = offset;
85
+ act(() => {
86
+ scroller().dispatchEvent(new Event("scroll"));
87
+ });
88
+ }
89
+
90
+ beforeEach(() => {
91
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
92
+ scrollerWidth = NARROWEST_SCROLLER_WIDTH;
93
+ scrollOffset = 0;
94
+ resizeCallbacks = [];
95
+ disconnectObserver = vi.fn();
96
+ vi.stubGlobal(
97
+ "ResizeObserver",
98
+ class {
99
+ constructor(callback: () => void) {
100
+ resizeCallbacks.push(callback);
101
+ }
102
+ observe() {}
103
+ disconnect = disconnectObserver;
104
+ },
105
+ );
106
+ vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
107
+ callback(0);
108
+ return 1;
109
+ });
110
+ vi.stubGlobal("cancelAnimationFrame", () => {});
111
+
112
+ gapStyle = document.createElement("style");
113
+ gapStyle.textContent =
114
+ '[data-agent-suggestion-track="true"] { column-gap: 4px; }';
115
+ document.head.appendChild(gapStyle);
116
+
117
+ defineLayout("getBoundingClientRect", {
118
+ value(this: HTMLElement) {
119
+ const width = CHIP_WIDTHS[this.textContent ?? ""] ?? 0;
120
+ return { width, height: 28, top: 0, left: 0, right: width, bottom: 28 };
121
+ },
122
+ });
123
+ defineLayout("clientWidth", {
124
+ get(this: HTMLElement) {
125
+ return this.dataset.agentSuggestionScroller ? scrollerWidth : 0;
126
+ },
127
+ });
128
+ defineLayout("scrollWidth", {
129
+ get(this: HTMLElement) {
130
+ if (!this.dataset.agentSuggestionScroller) return 0;
131
+ return Math.max(scrollerWidth, trackWidth());
132
+ },
133
+ });
134
+ defineLayout("scrollLeft", {
135
+ get() {
136
+ return scrollOffset;
137
+ },
138
+ set(value: number) {
139
+ scrollOffset = value;
140
+ },
141
+ });
142
+
143
+ container = document.createElement("div");
144
+ document.body.appendChild(container);
145
+ root = createRoot(container);
146
+ });
147
+
148
+ afterEach(() => {
149
+ act(() => root.unmount());
150
+ container.remove();
151
+ gapStyle.remove();
152
+ for (const [name, descriptor] of descriptors) {
153
+ if (descriptor) {
154
+ Object.defineProperty(HTMLElement.prototype, name, descriptor);
155
+ } else {
156
+ delete (HTMLElement.prototype as unknown as Record<string, unknown>)[
157
+ name
158
+ ];
159
+ }
160
+ }
161
+ descriptors.clear();
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ it("wraps document suggestions onto two lines at the narrowest panel", () => {
166
+ render(DOCUMENT_SUGGESTIONS);
167
+
168
+ expect(
169
+ [...container.querySelectorAll("button")].map(
170
+ (button) => button.textContent,
171
+ ),
172
+ ).toEqual(DOCUMENT_SUGGESTIONS);
173
+ expect(track().className).toContain("flex-wrap");
174
+ expect(track().className).toContain("min-w-full");
175
+ // The measured width only lays the chips out in two lines once the track
176
+ // is sized by it; the mocked geometry here cannot show that on its own.
177
+ expect(track().className).toContain("w-(--agent-suggestion-track-width)");
178
+ expect(trackWidth()).toBe(144 + 4 + 144);
179
+ expect(fadeEdges()).toEqual({ start: null, end: "true" });
180
+ });
181
+
182
+ it("fades only the edges that still hide suggestions", () => {
183
+ render(DOCUMENT_SUGGESTIONS);
184
+ const hidden = trackWidth() - NARROWEST_SCROLLER_WIDTH;
185
+
186
+ scrollTo(hidden / 2);
187
+ expect(fadeEdges()).toEqual({ start: "true", end: "true" });
188
+
189
+ scrollTo(hidden);
190
+ expect(fadeEdges()).toEqual({ start: "true", end: null });
191
+ });
192
+
193
+ it("drops the fade once a resize lets every suggestion fit", () => {
194
+ render(DOCUMENT_SUGGESTIONS);
195
+ expect(fadeEdges().end).toBe("true");
196
+
197
+ resizeScroller(480);
198
+ expect(fadeEdges()).toEqual({ start: null, end: null });
199
+
200
+ resizeScroller(NARROWEST_SCROLLER_WIDTH);
201
+ expect(fadeEdges()).toEqual({ start: null, end: "true" });
202
+
203
+ act(() => root.unmount());
204
+ expect(disconnectObserver).toHaveBeenCalled();
205
+ root = createRoot(container);
206
+ });
207
+
208
+ it("scrolls a keyboard-focused suggestion out from under the fade", () => {
209
+ const scrollIntoView = vi.fn();
210
+ defineLayout("scrollIntoView", { value: scrollIntoView });
211
+ render(DOCUMENT_SUGGESTIONS);
212
+ const lastChip = [...container.querySelectorAll("button")].at(-1)!;
213
+
214
+ act(() => lastChip.focus());
215
+
216
+ expect(scrollIntoView).toHaveBeenCalledWith({
217
+ block: "nearest",
218
+ inline: "nearest",
219
+ });
220
+ });
221
+
222
+ it("reveals a keyboard-focused suggestion rendered by a host's ActionButton", () => {
223
+ const scrollIntoView = vi.fn();
224
+ defineLayout("scrollIntoView", { value: scrollIntoView });
225
+ // Adapters receive only the props Button forwards, which omit onFocus.
226
+ const HostActionButton = ({
227
+ label,
228
+ onClick,
229
+ elementRef,
230
+ }: ActionButtonProps) => (
231
+ <button ref={elementRef} type="button" onClick={onClick}>
232
+ {label}
233
+ </button>
234
+ );
235
+ act(() => {
236
+ root.render(
237
+ <ToolkitProvider
238
+ designSystem={{ components: { ActionButton: HostActionButton } }}
239
+ >
240
+ <AgentSuggestionBar
241
+ ariaLabel="Suggested prompts"
242
+ suggestions={DOCUMENT_SUGGESTIONS}
243
+ onSelect={() => {}}
244
+ />
245
+ </ToolkitProvider>,
246
+ );
247
+ });
248
+ const lastChip = [...container.querySelectorAll("button")].at(-1)!;
249
+
250
+ act(() => lastChip.focus());
251
+
252
+ expect(scrollIntoView).toHaveBeenCalledWith({
253
+ block: "nearest",
254
+ inline: "nearest",
255
+ });
256
+ });
257
+
258
+ it("ships the fade and wrap styles for every host", () => {
259
+ const styles = readFileSync(
260
+ path.resolve(
261
+ path.dirname(fileURLToPath(import.meta.url)),
262
+ "../app/agentkit/react/styles.css",
263
+ ),
264
+ "utf8",
265
+ ).replace(/\r\n/g, "\n");
266
+
267
+ expect(styles).toMatch(
268
+ /\[data-agent-suggestion-scroller="true"\]:is\(\s*\[data-overflow-start\],\s*\[data-overflow-end\]\s*\) \{[\s\S]*?mask-image: linear-gradient\(/,
269
+ );
270
+ expect(styles).toMatch(
271
+ /\[data-agent-suggestion-scroller="true"\]\[data-overflow-end\] \{\n --agent-suggestion-fade-end: 1\.5rem;/,
272
+ );
273
+ expect(styles).toMatch(
274
+ /\.agentkit-suggestions \[data-agent-suggestion-track="true"\] \{[\s\S]*?flex-wrap: wrap;[\s\S]*?inline-size: var\(--agent-suggestion-track-width\);[\s\S]*?min-inline-size: 100%;/,
275
+ );
276
+ });
277
+ });
@@ -1,8 +1,18 @@
1
- import type { ReactNode } from "react";
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type FocusEvent,
7
+ type ReactNode,
8
+ } from "react";
2
9
 
3
10
  import { Button } from "../ui/button.js";
4
11
  import { cn } from "../utils.js";
5
12
 
13
+ const useBrowserLayoutEffect =
14
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
15
+
6
16
  export interface AgentSuggestionItem {
7
17
  id: string;
8
18
  label: string;
@@ -41,6 +51,90 @@ export function agentSuggestionPrompt(
41
51
  : (suggestion.prompt ?? suggestion.label);
42
52
  }
43
53
 
54
+ function twoLineTrackWidth(widths: readonly number[], gap: number): number {
55
+ const total =
56
+ widths.reduce((sum, width) => sum + width, 0) +
57
+ gap * Math.max(widths.length - 1, 0);
58
+ let narrowest = total;
59
+ let firstLine = -gap;
60
+ for (const width of widths.slice(0, -1)) {
61
+ firstLine += gap + width;
62
+ narrowest = Math.min(
63
+ narrowest,
64
+ Math.max(firstLine, total - firstLine - gap),
65
+ );
66
+ }
67
+ return narrowest;
68
+ }
69
+
70
+ // Chrome does not scroll a partly visible chip into view when it takes
71
+ // keyboard focus, which leaves it under the edge fade. Listen on the track:
72
+ // a host's ActionButton adapter receives only the props Button forwards, and
73
+ // onFocus is not one of them.
74
+ function revealKeyboardFocusedChip(event: FocusEvent<HTMLDivElement>) {
75
+ const chip = event.target;
76
+ if (!(chip instanceof HTMLElement) || !chip.matches(":focus-visible")) return;
77
+ chip.scrollIntoView({ block: "nearest", inline: "nearest" });
78
+ }
79
+
80
+ function useTwoLineSuggestionTrack(layoutKey: string) {
81
+ const scrollerRef = useRef<HTMLDivElement>(null);
82
+ const trackRef = useRef<HTMLDivElement>(null);
83
+ const [overflow, setOverflow] = useState({ start: false, end: false });
84
+
85
+ useBrowserLayoutEffect(() => {
86
+ const scroller = scrollerRef.current;
87
+ const track = trackRef.current;
88
+ if (!scroller || !track) return;
89
+ const chips = Array.from(track.children);
90
+ const updateOverflow = () => {
91
+ const offset = Math.abs(scroller.scrollLeft);
92
+ const start = offset > 1;
93
+ const end = offset + scroller.clientWidth < scroller.scrollWidth - 1;
94
+ setOverflow((current) =>
95
+ current.start === start && current.end === end
96
+ ? current
97
+ : { start, end },
98
+ );
99
+ };
100
+ const update = () => {
101
+ const gap = Number.parseFloat(getComputedStyle(track).columnGap);
102
+ const width = twoLineTrackWidth(
103
+ chips.map((chip) => chip.getBoundingClientRect().width),
104
+ Number.isNaN(gap) ? 0 : gap,
105
+ );
106
+ track.style.setProperty(
107
+ "--agent-suggestion-track-width",
108
+ `${Math.ceil(width)}px`,
109
+ );
110
+ updateOverflow();
111
+ };
112
+ update();
113
+
114
+ // Resizing the track inside the observer callback resizes the observed
115
+ // scroller in the same frame, which the browser reports as a
116
+ // ResizeObserver loop error. Measure on the next frame instead.
117
+ let frame = 0;
118
+ const observer =
119
+ typeof ResizeObserver === "undefined"
120
+ ? null
121
+ : new ResizeObserver(() => {
122
+ cancelAnimationFrame(frame);
123
+ frame = requestAnimationFrame(update);
124
+ });
125
+ observer?.observe(scroller);
126
+ for (const chip of chips) observer?.observe(chip);
127
+ scroller.addEventListener("scroll", updateOverflow, { passive: true });
128
+ return () => {
129
+ cancelAnimationFrame(frame);
130
+ observer?.disconnect();
131
+ scroller.removeEventListener("scroll", updateOverflow);
132
+ };
133
+ }, [layoutKey]);
134
+
135
+ return { scrollerRef, trackRef, overflow };
136
+ }
137
+
44
138
  export function AgentSuggestionBar({
45
139
  suggestions,
46
140
  ariaLabel,
@@ -48,7 +142,11 @@ export function AgentSuggestionBar({
48
142
  renderSuggestion,
49
143
  className,
50
144
  }: AgentSuggestionBarProps) {
51
- if (suggestions.length === 0) return null;
145
+ const items = suggestions.map(normalizeAgentSuggestion);
146
+ const { scrollerRef, trackRef, overflow } = useTwoLineSuggestionTrack(
147
+ items.map((item) => item.id).join("\n"),
148
+ );
149
+ if (items.length === 0) return null;
52
150
 
53
151
  return (
54
152
  <section
@@ -57,12 +155,19 @@ export function AgentSuggestionBar({
57
155
  className={cn("w-full min-w-0 overflow-hidden px-3 py-2", className)}
58
156
  >
59
157
  <div
158
+ ref={scrollerRef}
60
159
  data-agent-suggestion-scroller="true"
61
- className="flex w-full min-w-0 snap-x snap-proximity gap-1 overflow-x-auto overscroll-x-contain px-0.5 py-px [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
160
+ data-overflow-start={overflow.start || undefined}
161
+ data-overflow-end={overflow.end || undefined}
162
+ className="w-full min-w-0 snap-x snap-proximity scroll-px-6 overflow-x-auto overscroll-x-contain px-0.5 py-px [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
62
163
  >
63
- {suggestions.map((input, index) => {
64
- const suggestion = normalizeAgentSuggestion(input, index);
65
- return (
164
+ <div
165
+ ref={trackRef}
166
+ data-agent-suggestion-track="true"
167
+ className="flex w-(--agent-suggestion-track-width) min-w-full flex-wrap gap-1"
168
+ onFocus={revealKeyboardFocusedChip}
169
+ >
170
+ {items.map((suggestion) => (
66
171
  <Button
67
172
  key={suggestion.id}
68
173
  type="button"
@@ -78,8 +183,8 @@ export function AgentSuggestionBar({
78
183
  : suggestion.label}
79
184
  </span>
80
185
  </Button>
81
- );
82
- })}
186
+ ))}
187
+ </div>
83
188
  </div>
84
189
  </section>
85
190
  );