@syntrologie/adapt-chatbot 2.28.0 → 2.29.0

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 (101) hide show
  1. package/dist/AdaptiveChatBar.composer.test.d.ts +2 -0
  2. package/dist/AdaptiveChatBar.composer.test.d.ts.map +1 -0
  3. package/dist/AdaptiveChatBar.d.ts +140 -4
  4. package/dist/AdaptiveChatBar.d.ts.map +1 -1
  5. package/dist/AdaptiveChatBar.js +4 -3
  6. package/dist/AdaptiveChatBarMountable.d.ts +47 -0
  7. package/dist/AdaptiveChatBarMountable.d.ts.map +1 -1
  8. package/dist/AdaptiveChatTrail.anchor-scroll.test.d.ts +2 -0
  9. package/dist/AdaptiveChatTrail.anchor-scroll.test.d.ts.map +1 -0
  10. package/dist/AdaptiveChatTrail.d.ts +397 -5
  11. package/dist/AdaptiveChatTrail.d.ts.map +1 -1
  12. package/dist/AdaptiveChatTrail.js +6 -4
  13. package/dist/AdaptiveChatTrail.test.d.ts +2 -0
  14. package/dist/AdaptiveChatTrail.test.d.ts.map +1 -0
  15. package/dist/AdaptiveChipsStrip.d.ts +17 -11
  16. package/dist/AdaptiveChipsStrip.d.ts.map +1 -1
  17. package/dist/AdaptiveChipsStrip.js +2 -2
  18. package/dist/ChatAssistantLit.d.ts.map +1 -1
  19. package/dist/ChatAssistantLit.js +4 -3
  20. package/dist/ChatSession.d.ts +66 -5
  21. package/dist/ChatSession.d.ts.map +1 -1
  22. package/dist/ChatTransport.d.ts +107 -0
  23. package/dist/ChatTransport.d.ts.map +1 -1
  24. package/dist/InlineRegistryWidgetMount.test.d.ts +2 -0
  25. package/dist/InlineRegistryWidgetMount.test.d.ts.map +1 -0
  26. package/dist/InlineWidgetLiveMount.test.d.ts +2 -0
  27. package/dist/InlineWidgetLiveMount.test.d.ts.map +1 -0
  28. package/dist/NavLinkMountable.d.ts.map +1 -1
  29. package/dist/TextAnswerMountable.test.d.ts +2 -0
  30. package/dist/TextAnswerMountable.test.d.ts.map +1 -0
  31. package/dist/Turnstile.d.ts.map +1 -1
  32. package/dist/chunk-A7RBZLIX.js +1678 -0
  33. package/dist/chunk-A7RBZLIX.js.map +7 -0
  34. package/dist/{chunk-435KJD27.js → chunk-EACGDSJI.js} +46 -1
  35. package/dist/chunk-EACGDSJI.js.map +7 -0
  36. package/dist/chunk-EWPPVPJ4.js +14 -0
  37. package/dist/chunk-EWPPVPJ4.js.map +7 -0
  38. package/dist/chunk-FXHYE6FY.js +632 -0
  39. package/dist/chunk-FXHYE6FY.js.map +7 -0
  40. package/dist/{chunk-AUER7ZCK.js → chunk-OIGVZGSX.js} +11 -4
  41. package/dist/chunk-OIGVZGSX.js.map +7 -0
  42. package/dist/{chunk-UC4XU6GH.js → chunk-VZIUXXAM.js} +761 -52
  43. package/dist/chunk-VZIUXXAM.js.map +7 -0
  44. package/dist/{chunk-KUO67E2W.js → chunk-ZXV4GXMO.js} +715 -227
  45. package/dist/chunk-ZXV4GXMO.js.map +7 -0
  46. package/dist/elements/ActionHandler.d.ts +1 -1
  47. package/dist/elements/ActionHandler.d.ts.map +1 -1
  48. package/dist/elements/ElementInstanceStore.d.ts +62 -0
  49. package/dist/elements/ElementInstanceStore.d.ts.map +1 -1
  50. package/dist/elements/TileHandler.d.ts.map +1 -1
  51. package/dist/elements/TileHandler.test.d.ts +2 -0
  52. package/dist/elements/TileHandler.test.d.ts.map +1 -0
  53. package/dist/elements/__tests__/ElementInstanceStore.recall.test.d.ts +2 -0
  54. package/dist/elements/__tests__/ElementInstanceStore.recall.test.d.ts.map +1 -0
  55. package/dist/elements/fetcher.d.ts +3 -3
  56. package/dist/elements/fetcher.d.ts.map +1 -1
  57. package/dist/elements/index.d.ts +1 -0
  58. package/dist/elements/index.d.ts.map +1 -1
  59. package/dist/elements/store-singleton.d.ts +4 -0
  60. package/dist/elements/store-singleton.d.ts.map +1 -0
  61. package/dist/elements/types.d.ts +4 -0
  62. package/dist/elements/types.d.ts.map +1 -1
  63. package/dist/observer/__tests__/bus-translate.test.d.ts +16 -0
  64. package/dist/observer/__tests__/bus-translate.test.d.ts.map +1 -0
  65. package/dist/observer/__tests__/lifecycle.test.d.ts +2 -0
  66. package/dist/observer/__tests__/lifecycle.test.d.ts.map +1 -0
  67. package/dist/observer/__tests__/observer-enrich.test.d.ts +10 -0
  68. package/dist/observer/__tests__/observer-enrich.test.d.ts.map +1 -0
  69. package/dist/observer/allowlist.d.ts +5 -7
  70. package/dist/observer/allowlist.d.ts.map +1 -1
  71. package/dist/observer/bus-translate.d.ts +33 -0
  72. package/dist/observer/bus-translate.d.ts.map +1 -0
  73. package/dist/observer/index.d.ts +10 -1
  74. package/dist/observer/index.d.ts.map +1 -1
  75. package/dist/observer/queue.d.ts +6 -0
  76. package/dist/observer/queue.d.ts.map +1 -1
  77. package/dist/observer/transport.d.ts +1 -0
  78. package/dist/observer/transport.d.ts.map +1 -1
  79. package/dist/personas.d.ts +30 -0
  80. package/dist/personas.d.ts.map +1 -0
  81. package/dist/runtime.d.ts +2 -0
  82. package/dist/runtime.d.ts.map +1 -1
  83. package/dist/runtime.js +856 -129
  84. package/dist/runtime.js.map +4 -4
  85. package/dist/schema.d.ts +363 -67
  86. package/dist/schema.d.ts.map +1 -1
  87. package/dist/schema.js +176 -3
  88. package/dist/schema.js.map +2 -2
  89. package/dist/test/setup.d.ts +1 -0
  90. package/dist/test/setup.d.ts.map +1 -0
  91. package/dist/types.d.ts +16 -0
  92. package/dist/types.d.ts.map +1 -1
  93. package/package.json +5 -1
  94. package/dist/chunk-435KJD27.js.map +0 -7
  95. package/dist/chunk-AUER7ZCK.js.map +0 -7
  96. package/dist/chunk-DOMEUJR7.js +0 -382
  97. package/dist/chunk-DOMEUJR7.js.map +0 -7
  98. package/dist/chunk-KUO67E2W.js.map +0 -7
  99. package/dist/chunk-QELVKBQV.js +0 -214
  100. package/dist/chunk-QELVKBQV.js.map +0 -7
  101. package/dist/chunk-UC4XU6GH.js.map +0 -7
@@ -0,0 +1,1678 @@
1
+ import {
2
+ renderMarkdown
3
+ } from "./chunk-VZIUXXAM.js";
4
+
5
+ // src/AdaptiveChatTrail.ts
6
+ import { css, html, LitElement, nothing } from "lit";
7
+ import { styleMap } from "lit/directives/style-map.js";
8
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
9
+ var DEFAULT_VISIBLE = 3;
10
+ var TRAIL_MAX_WIDTH = "88%";
11
+ var OPACITY_STEP = 0.22;
12
+ var OPACITY_FLOOR = 0.18;
13
+ var Y_DRIFT_PX = -2;
14
+ var TRAIL_BLUR_VEIL_PX = 72;
15
+ var TRAIL_BLUR_MAX_PX = 5;
16
+ var _AdaptiveChatTrail = class _AdaptiveChatTrail extends LitElement {
17
+ constructor() {
18
+ super(...arguments);
19
+ this.messages = [];
20
+ this.tileReceipts = [];
21
+ this.inlineWidgets = [];
22
+ this.mountInlineWidget = void 0;
23
+ this.visibleCount = DEFAULT_VISIBLE;
24
+ this.expanded = false;
25
+ this.forceExpanded = false;
26
+ this.inFlight = false;
27
+ this.thinkingText = "";
28
+ /** Persona identity (FEAT-1784917430) — name + small avatar for assistant
29
+ * turn-start chips and the thinking indicator. Empty = legacy rendering. */
30
+ this.personaName = "";
31
+ this.personaAvatarSm = "";
32
+ this.introSuggestion = void 0;
33
+ /**
34
+ * Pre-conversation phantom assistant message. Rendered only when
35
+ * `messages` is empty. NOT injected into chatSession state — the
36
+ * greeting is configuration, not conversation, so it disappears
37
+ * automatically when the first real message arrives.
38
+ */
39
+ this.greeting = void 0;
40
+ // ── Anchor-scroll model (FEAT-1783535427, shared contract with
41
+ // <syntro-chat>) — applies to the SCROLLABLE modes (expanded /
42
+ // forceExpanded). The ambient peek strip keeps its bottom pin: it is
43
+ // overflow-hidden (not user-scrollable) and the pin is what keeps the
44
+ // latest line visible under the blur veil.
45
+ /** Follow the bottom on content growth. On by default (history
46
+ * browsing); off from a new user message until the visitor opts back in
47
+ * (pill tap or a user-gesture scroll that reaches the bottom). */
48
+ this._stickToBottom = true;
49
+ /** The user message the current turn is anchored to. */
50
+ this._anchorMsgId = null;
51
+ /** Whether a turn anchor is live (drives expand-mid-turn landing). */
52
+ this._anchorActive = false;
53
+ /** Keep enforcing chip-at-top on updates (late geometry, mode flips)
54
+ * until the user scrolls or opts into following. */
55
+ this._anchorHold = false;
56
+ /** Number of messages whose BOTTOM edge is still below the viewport —
57
+ * messages the visitor hasn't scrolled past yet. Drives the
58
+ * "⌄ N messages" pill and stays accurate as they scroll (one long
59
+ * streaming reply counts as 1 until its end has been read). Reactive. */
60
+ this._messagesBelow = 0;
61
+ /** Timestamp of the last user scroll gesture (wheel/touch): scroll
62
+ * events outside this window are programmatic (our own pin/anchor) and
63
+ * must not re-engage following — anchoring the newest message clamps AT
64
+ * the bottom in any longer conversation. */
65
+ this._lastUserScrollIntentTs = 0;
66
+ this._markUserScrollIntent = () => {
67
+ this._lastUserScrollIntentTs = Date.now();
68
+ };
69
+ this._onTrailScroll = () => {
70
+ const container = this._trailContainer();
71
+ if (!container) return;
72
+ const atBottom = this._isAtBottom(container);
73
+ this._messagesBelow = this._scrollable && !atBottom ? this._countMessagesBelow(container) : 0;
74
+ const isUserDriven = Date.now() - this._lastUserScrollIntentTs < _AdaptiveChatTrail._USER_SCROLL_INTENT_WINDOW_MS;
75
+ if (isUserDriven) {
76
+ this._anchorHold = false;
77
+ this._stickToBottom = atBottom;
78
+ if (atBottom) this._anchorActive = false;
79
+ }
80
+ };
81
+ /** Pill tap: jump to the newest content and follow for the rest of the turn. */
82
+ this._onScrollToRecent = () => {
83
+ this._stickToBottom = true;
84
+ this._anchorHold = false;
85
+ this._anchorActive = false;
86
+ requestAnimationFrame(() => {
87
+ const container = this._trailContainer();
88
+ if (!container) return;
89
+ container.scrollTop = container.scrollHeight;
90
+ this._syncContentBelow();
91
+ });
92
+ };
93
+ // No `createRenderRoot()` override → default open shadow root. CSS
94
+ // custom properties (--sc-tile-text-color, --sc-content-bubble-*,
95
+ // etc.) inherit through the shadow boundary, so canvas-level theming
96
+ // continues to cascade into the chips without the light-DOM hack.
97
+ // See the `static styles` block above for the in-shadow stylesheet.
98
+ this._onExpand = () => {
99
+ this.expanded = true;
100
+ this.dispatchEvent(new CustomEvent("trail-expand", { bubbles: true, composed: true }));
101
+ };
102
+ /**
103
+ * Approve a pending client tool call. The trail emits a generic
104
+ * event so the host can decide whether to forward to chatSession
105
+ * (the common path) or override. Keeps the trail itself free of
106
+ * direct chatSession coupling — it's a pure view component.
107
+ */
108
+ this._onToolCallClick = (tc) => {
109
+ if (tc.status !== "pending") return;
110
+ this.dispatchEvent(
111
+ new CustomEvent("trail-toolcall-approved", {
112
+ detail: { toolCallId: tc.id, approved: true },
113
+ bubbles: true,
114
+ composed: true
115
+ })
116
+ );
117
+ };
118
+ this._onCollapse = () => {
119
+ this.expanded = false;
120
+ this.dispatchEvent(new CustomEvent("trail-collapse", { bubbles: true, composed: true }));
121
+ };
122
+ /**
123
+ * instanceId → id of the message that was newest when the receipt first
124
+ * appeared. This is what makes a receipt read like a chat bubble: it renders
125
+ * inline right after its anchor message, so newer messages push it up into
126
+ * history instead of it staying pinned below everything. Assigned once per
127
+ * instanceId (in willUpdate, the only place that sees both `messages` and
128
+ * `tileReceipts` at the moment a tile mounts) and never re-homed — a
129
+ * dismiss/bring-back keeps the chip where it was created. `null` = the tile
130
+ * mounted before the first chat message (canvas replay); those fall to the
131
+ * tail strip (see render).
132
+ */
133
+ this._receiptAnchors = /* @__PURE__ */ new Map();
134
+ /**
135
+ * instanceId → anchor message id for inline widgets — same mechanism as
136
+ * `_receiptAnchors`. The inline carousel renders right after the message
137
+ * that was newest when it mounted, so it reads like a rich reply and
138
+ * drifts up into history as later messages append.
139
+ */
140
+ this._inlineAnchors = /* @__PURE__ */ new Map();
141
+ /**
142
+ * instanceId → the cached inline widget entry (render node + props target +
143
+ * optional registry cleanup). Cached so a prop patch (same instanceId, new
144
+ * props) updates the EXISTING element instead of tearing it down and
145
+ * re-creating it — which would reset the carousel's scroll position +
146
+ * internal state. Created lazily the first time we see the widget:
147
+ * `document.createElement(tag)` for a defined tag, or a registry mount for a
148
+ * widget-id. An unresolvable widget caches `null` so the fail-safe renders
149
+ * nothing without re-checking the registry.
150
+ */
151
+ this._inlineElements = /* @__PURE__ */ new Map();
152
+ this._onTileReceiptClick = (instanceId) => {
153
+ this.dispatchEvent(
154
+ new CustomEvent("tile-receipt-activate", {
155
+ detail: { instanceId },
156
+ bubbles: true,
157
+ composed: true
158
+ })
159
+ );
160
+ };
161
+ this._onAlertReceiptClick = (instanceId) => {
162
+ this.dispatchEvent(
163
+ new CustomEvent("alert-receipt-activate", {
164
+ detail: { instanceId },
165
+ bubbles: true,
166
+ composed: true
167
+ })
168
+ );
169
+ };
170
+ this._onIntroSuggestionClick = () => {
171
+ const s = this.introSuggestion;
172
+ if (!s) return;
173
+ this.dispatchEvent(
174
+ new CustomEvent("trail-intro-suggestion", {
175
+ detail: { prompt: s.prompt },
176
+ bubbles: true,
177
+ composed: true
178
+ })
179
+ );
180
+ };
181
+ /**
182
+ * Visitor tapped a `suggest_replies` inline chip. Emit
183
+ * `trail-suggested-reply` carrying the chip text — the parent
184
+ * (AdaptiveChatBar) re-dispatches it as `chat-message-sent`, the
185
+ * SAME channel the input row uses, so the reply rides the existing
186
+ * send path (mountable → chatSession.send → transport). No fabricated
187
+ * AG-UI events; this is exactly what typing the text and hitting
188
+ * Enter would do. The chips clear on their own once the reply lands
189
+ * and a newer assistant message becomes the latest.
190
+ */
191
+ this._onSuggestedReplyClick = (text) => {
192
+ const trimmed = text.trim();
193
+ if (!trimmed) return;
194
+ this.dispatchEvent(
195
+ new CustomEvent("trail-suggested-reply", {
196
+ detail: { text: trimmed },
197
+ bubbles: true,
198
+ composed: true
199
+ })
200
+ );
201
+ };
202
+ }
203
+ /** True when `m` opens an assistant run (previous visible message is not
204
+ * assistant) — the persona byline renders once per turn, not per chip. */
205
+ _isAssistantTurnStart(m) {
206
+ if (m.role !== "assistant") return false;
207
+ const i = this.messages.findIndex((x) => x.id === m.id);
208
+ if (i <= 0) return true;
209
+ return this.messages[i - 1].role !== "assistant";
210
+ }
211
+ get _scrollable() {
212
+ return this.expanded || this.forceExpanded;
213
+ }
214
+ _trailContainer() {
215
+ return this.renderRoot.querySelector("[data-syntro-chat-trail]");
216
+ }
217
+ _isAtBottom(container) {
218
+ return container.scrollHeight - container.scrollTop - container.clientHeight <= _AdaptiveChatTrail._AT_BOTTOM_EPSILON_PX;
219
+ }
220
+ /** Count chips whose bottom edge hasn't entered the viewport yet. */
221
+ _countMessagesBelow(container) {
222
+ const viewportBottom = container.getBoundingClientRect().top + container.clientHeight;
223
+ let count = 0;
224
+ for (const chip of this.renderRoot.querySelectorAll("[data-trail-chip]")) {
225
+ if (chip.getBoundingClientRect().bottom > viewportBottom + 4) count += 1;
226
+ }
227
+ return count > 0 ? count : 1;
228
+ }
229
+ _syncContentBelow() {
230
+ const container = this._trailContainer();
231
+ if (!container) return;
232
+ this._messagesBelow = this._scrollable && !this._isAtBottom(container) ? this._countMessagesBelow(container) : 0;
233
+ }
234
+ /** Scroll so the anchored user chip sits at the top of the trail
235
+ * viewport. Idempotent — safe to re-apply on every update while the
236
+ * hold is live (content grows BELOW the anchor, so the target is
237
+ * stable; re-applying also covers late geometry, e.g. expanding
238
+ * mid-turn from the ambient strip). */
239
+ _applyAnchor() {
240
+ requestAnimationFrame(() => {
241
+ const container = this._trailContainer();
242
+ if (!container || this._anchorMsgId === null) return;
243
+ const chip = this.renderRoot.querySelector(
244
+ `[data-trail-chip-id="${String(this._anchorMsgId)}"]`
245
+ );
246
+ if (!chip) return;
247
+ const top = chip.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
248
+ container.scrollTop = top;
249
+ this._syncContentBelow();
250
+ });
251
+ }
252
+ _pinToBottom() {
253
+ requestAnimationFrame(() => {
254
+ const container = this._trailContainer();
255
+ if (!container) return;
256
+ container.scrollTop = container.scrollHeight;
257
+ this._syncContentBelow();
258
+ });
259
+ }
260
+ /**
261
+ * Whether to render the thinking-dots chip after the last message.
262
+ * True iff a request is in flight AND the assistant hasn't started
263
+ * streaming a reply yet — once the first delta lands, the streaming
264
+ * chip's caret takes over and the thinking indicator hides so the
265
+ * visitor isn't shown both at once. Also hidden when the last
266
+ * message is an error/system message, because the request that
267
+ * "errored" is already over from the user's perspective.
268
+ */
269
+ _shouldShowThinking() {
270
+ if (!this.inFlight) return false;
271
+ const last = this.messages[this.messages.length - 1];
272
+ if (!last) return true;
273
+ if (last.role === "assistant" && last.status === "streaming") {
274
+ return (last.text ?? "").trim().length === 0;
275
+ }
276
+ if (last.role === "system" || last.status === "error") return false;
277
+ return true;
278
+ }
279
+ disconnectedCallback() {
280
+ super.disconnectedCallback();
281
+ for (const entry of this._inlineElements.values()) {
282
+ entry?.cleanup?.();
283
+ }
284
+ this._inlineElements.clear();
285
+ }
286
+ /**
287
+ * The id of the newest message the render will actually PAINT — the only
288
+ * valid anchor target. Anchoring to the raw last message is wrong-by-
289
+ * construction for tool-call turns: mid-turn the newest message is often a
290
+ * whitespace-only assistant shell (some providers emit whitespace text
291
+ * deltas around tool calls) that the empty-bubble filter drops, so an
292
+ * anchor recorded on it can never be in the painted set and the receipt
293
+ * falls to the tail strip forever — resurrecting BUG-1783015943 (seen live
294
+ * on vela desktop, BUG-1783630187). `isSuggestionHost=false` here: a
295
+ * message whose ONLY content is reply chips is too ephemeral to anchor on
296
+ * (the chips clear when the visitor answers), so walk past it too.
297
+ */
298
+ _newestRenderableMessageId() {
299
+ for (let i = this.messages.length - 1; i >= 0; i--) {
300
+ const m = this.messages[i];
301
+ if (m.role !== "assistant" || assistantChipRendersContent(m, [])) return m.id;
302
+ }
303
+ return null;
304
+ }
305
+ /**
306
+ * Server-authoritative anchor for a restored element: `turnId` is the
307
+ * persisted `mounted_by.turn_id` — the id of the user message that
308
+ * triggered the mounting turn. The element anchors to the first
309
+ * content-bearing assistant message of that turn (where it appeared live:
310
+ * intro text, then the mount, then any follow-up), falling back to the
311
+ * turn's user message when the assistant hasn't produced renderable text.
312
+ * Returns null when `turnId` is absent or not in the transcript (nudge
313
+ * mounts carry a runId) — the caller then uses the legacy
314
+ * newest-renderable anchor. Without this, a reload assigned every element
315
+ * to the newest message of the fully restored transcript, dumping all
316
+ * inline views below the last message (the arrival-timing bug).
317
+ */
318
+ /**
319
+ * Exact restore anchor: `toolCallId` is the persisted id of the tool call
320
+ * that mounted the element, and the transcript message hosting that call
321
+ * carries it in `toolCalls`. Anchor to the host message itself when it
322
+ * renders content, else walk BACK to the nearest renderable message — a
323
+ * standalone tool-call message persists with empty content and must not
324
+ * strand the element. This survives every turn shape (text→mount,
325
+ * text→text→mount, mount→text); `_anchorFromTurn` is the coarser fallback
326
+ * for elements persisted before tool_call_id existed.
327
+ */
328
+ _anchorFromToolCall(toolCallId, turnId) {
329
+ if (!toolCallId) return null;
330
+ let start = 0;
331
+ let end = this.messages.length;
332
+ const turnIdx = turnId ? this.messages.findIndex((m) => String(m.id) === turnId) : -1;
333
+ if (turnIdx >= 0) {
334
+ start = turnIdx + 1;
335
+ end = this.messages.findIndex((m, i) => i > turnIdx && m.role === "user");
336
+ if (end < 0) end = this.messages.length;
337
+ }
338
+ let idx = -1;
339
+ for (let i = start; i < end; i++) {
340
+ if ((this.messages[i].toolCalls ?? []).some((tc) => tc.id === toolCallId)) {
341
+ idx = i;
342
+ break;
343
+ }
344
+ }
345
+ if (idx < 0) return null;
346
+ for (let i = idx; i >= 0; i--) {
347
+ const m = this.messages[i];
348
+ if (m.role !== "assistant" || assistantChipRendersContent(m, [])) return m.id;
349
+ }
350
+ return null;
351
+ }
352
+ _anchorFromTurn(turnId) {
353
+ if (!turnId) return null;
354
+ const idx = this.messages.findIndex((m) => String(m.id) === turnId);
355
+ if (idx < 0) return null;
356
+ for (let i = idx + 1; i < this.messages.length; i++) {
357
+ const m = this.messages[i];
358
+ if (m.role === "user") break;
359
+ if (m.role === "assistant" && assistantChipRendersContent(m, [])) return m.id;
360
+ }
361
+ return this.messages[idx].id;
362
+ }
363
+ willUpdate(changed) {
364
+ if (changed.has("messages")) {
365
+ const lastId = this._newestRenderableMessageId();
366
+ if (lastId !== null) {
367
+ const receiptTurns = new Map(this.tileReceipts.map((r) => [r.instanceId, r.turnId]));
368
+ const widgetTurns = new Map(this.inlineWidgets.map((w) => [w.instanceId, w.turnId]));
369
+ const receiptCalls = new Map(this.tileReceipts.map((r) => [r.instanceId, r.toolCallId]));
370
+ const widgetCalls = new Map(this.inlineWidgets.map((w) => [w.instanceId, w.toolCallId]));
371
+ for (const [key, anchor] of this._receiptAnchors) {
372
+ if (anchor === null) {
373
+ this._receiptAnchors.set(
374
+ key,
375
+ this._anchorFromToolCall(receiptCalls.get(key), receiptTurns.get(key)) ?? this._anchorFromTurn(receiptTurns.get(key)) ?? lastId
376
+ );
377
+ }
378
+ }
379
+ for (const [key, anchor] of this._inlineAnchors) {
380
+ if (anchor === null) {
381
+ this._inlineAnchors.set(
382
+ key,
383
+ this._anchorFromToolCall(widgetCalls.get(key), widgetTurns.get(key)) ?? this._anchorFromTurn(widgetTurns.get(key)) ?? lastId
384
+ );
385
+ }
386
+ }
387
+ }
388
+ }
389
+ if (changed.has("tileReceipts")) {
390
+ const lastId = this._newestRenderableMessageId();
391
+ const live = /* @__PURE__ */ new Set();
392
+ for (const r of this.tileReceipts) {
393
+ live.add(r.instanceId);
394
+ if (!this._receiptAnchors.has(r.instanceId)) {
395
+ this._receiptAnchors.set(
396
+ r.instanceId,
397
+ this._anchorFromToolCall(r.toolCallId, r.turnId) ?? this._anchorFromTurn(r.turnId) ?? lastId
398
+ );
399
+ }
400
+ }
401
+ for (const key of this._receiptAnchors.keys()) {
402
+ if (!live.has(key)) this._receiptAnchors.delete(key);
403
+ }
404
+ }
405
+ if (changed.has("inlineWidgets")) {
406
+ const lastId = this._newestRenderableMessageId();
407
+ const live = /* @__PURE__ */ new Set();
408
+ for (const w of this.inlineWidgets) {
409
+ live.add(w.instanceId);
410
+ if (!this._inlineAnchors.has(w.instanceId)) {
411
+ this._inlineAnchors.set(
412
+ w.instanceId,
413
+ this._anchorFromToolCall(w.toolCallId, w.turnId) ?? this._anchorFromTurn(w.turnId) ?? lastId
414
+ );
415
+ }
416
+ this._ensureInlineElement(w);
417
+ }
418
+ for (const key of this._inlineAnchors.keys()) {
419
+ if (!live.has(key)) this._inlineAnchors.delete(key);
420
+ }
421
+ for (const key of this._inlineElements.keys()) {
422
+ if (!live.has(key)) {
423
+ this._inlineElements.get(key)?.cleanup?.();
424
+ this._inlineElements.delete(key);
425
+ }
426
+ }
427
+ }
428
+ }
429
+ /**
430
+ * Create (once) or refresh the cached element for an inline widget.
431
+ *
432
+ * Two mount paths, both keeping the chat bundle free of any widget-package
433
+ * import:
434
+ * - Direct tag (self-defining, server-mounted widgets like the reco
435
+ * carousel whose declared `widget` IS its custom-element tag):
436
+ * `document.createElement(tag)`.
437
+ * - Widget-id (admin-template widgets like `adaptive-product:card` or
438
+ * `adaptive-faq:accordion`, whose `widget` is a runtime widget-id, NOT a
439
+ * valid custom-element tag): mount through the runtime widget registry
440
+ * via {@link mountInlineWidget}, which resolves the id to its real
441
+ * element exactly like the deck `SyntroTileCard` path. Without this,
442
+ * `createElement('adaptive-product:card')` yields an inert unknown
443
+ * element and the tile renders permanently blank (the "inline mode
444
+ * shows nothing but the reco" bug).
445
+ *
446
+ * Fail-safe: an id that resolves to neither a defined tag nor a registered
447
+ * widget caches `null` and renders nothing (no broken box).
448
+ */
449
+ _ensureInlineElement(w) {
450
+ let entry = this._inlineElements.get(w.instanceId);
451
+ if (entry === void 0) {
452
+ if (customElements.get(w.tag)) {
453
+ const el = document.createElement(w.tag);
454
+ el.setAttribute("data-inline-widget-el", "");
455
+ entry = { node: el, propsTarget: el };
456
+ } else if (this.mountInlineWidget) {
457
+ const container = document.createElement("div");
458
+ container.setAttribute("data-inline-widget-el", "");
459
+ const mounted = this.mountInlineWidget(w.tag, container, w.props);
460
+ entry = mounted ? { node: container, propsTarget: mounted.element, cleanup: mounted.cleanup } : null;
461
+ } else {
462
+ entry = null;
463
+ }
464
+ this._inlineElements.set(w.instanceId, entry);
465
+ }
466
+ if (!entry) return;
467
+ entry.propsTarget.props = w.props;
468
+ }
469
+ updated(changed) {
470
+ if (!changed.has("expanded") && !changed.has("forceExpanded") && !changed.has("messages")) {
471
+ return;
472
+ }
473
+ if (changed.has("messages")) {
474
+ let lastUserIdx = -1;
475
+ for (let i = this.messages.length - 1; i >= 0; i--) {
476
+ if (this.messages[i].role === "user") {
477
+ lastUserIdx = i;
478
+ break;
479
+ }
480
+ }
481
+ const lastUser = lastUserIdx >= 0 ? this.messages[lastUserIdx] : void 0;
482
+ const isLiveTurn = lastUser !== void 0 && this.messages.slice(lastUserIdx + 1).every((m) => m.role !== "user" && m.status === "streaming");
483
+ if (lastUser && isLiveTurn && lastUser.id !== this._anchorMsgId) {
484
+ this._anchorMsgId = lastUser.id;
485
+ this._anchorActive = true;
486
+ this._anchorHold = true;
487
+ this._stickToBottom = false;
488
+ }
489
+ }
490
+ if (!this._scrollable) {
491
+ this._pinToBottom();
492
+ return;
493
+ }
494
+ if (this._anchorActive && this._anchorHold) {
495
+ this._applyAnchor();
496
+ } else if (this._stickToBottom) {
497
+ this._pinToBottom();
498
+ } else {
499
+ requestAnimationFrame(() => this._syncContentBelow());
500
+ }
501
+ }
502
+ /** Render a set of inline widgets (reco carousel etc.) as
503
+ * `[data-trail-widget]` blocks, anchored to a message position like
504
+ * receipts. Each block hosts the cached custom element for that
505
+ * instanceId — a defined tag mounts inline; an undefined tag renders
506
+ * nothing (fail-safe). Rendered at every trail site (inline per anchor,
507
+ * tail, empty-state) so placement can't diverge. */
508
+ _renderInlineWidgetStrip(list) {
509
+ if (list.length === 0) return nothing;
510
+ const blocks = list.map((w) => {
511
+ const entry = this._inlineElements.get(w.instanceId);
512
+ if (!entry) return nothing;
513
+ return html`<div
514
+ data-trail-widget
515
+ data-instance-id=${w.instanceId}
516
+ style=${styleMap(inlineWidgetBlockStyles())}
517
+ >${entry.node}</div>`;
518
+ }).filter((b) => b !== nothing);
519
+ if (blocks.length === 0) return nothing;
520
+ return html`${blocks}`;
521
+ }
522
+ /** A row of receipt chips, used at every trail render site (inline per
523
+ * anchor message, the tail fallback, and the empty-state branches) so the
524
+ * markup + styling can never diverge. Returns `nothing` for an empty list. */
525
+ _renderReceiptStrip(list) {
526
+ if (list.length === 0) return nothing;
527
+ return html`<div data-trail-tile-receipts style=${styleMap(tileReceiptStripStyles())}>
528
+ ${list.map((tr) => this._renderReceiptChip(tr))}
529
+ </div>`;
530
+ }
531
+ /** One receipt chip — a single, unified, single-line design for both kinds
532
+ * (nav "alert" + mounted "tile"). Same container/title/action style so they
533
+ * read as one chip family; the icon + action label carry the kind. No
534
+ * subtitle: the old "Brought up earlier" line was hardcoded (wrong for
535
+ * same-turn mounts) and made the chip taller — dropping it is the "shorter"
536
+ * chip. Rendered at every trail site so the kinds can't diverge. */
537
+ _renderReceiptChip(tr) {
538
+ if (tr.kind === "alert") {
539
+ return html`<button
540
+ type="button"
541
+ data-trail-alert-receipt
542
+ data-instance-id=${tr.instanceId}
543
+ @click=${() => this._onAlertReceiptClick(tr.instanceId)}
544
+ style=${styleMap(tileReceiptChipStyles())}
545
+ >
546
+ <span style=${styleMap(alertReceiptIconStyles())} aria-hidden="true">⚡</span>
547
+ <span style=${styleMap(receiptChipTitleStyles("alert"))}>${tr.title}</span>
548
+ <span style=${styleMap(tileReceiptActionStyles(false))}>Go →</span>
549
+ </button>`;
550
+ }
551
+ return html`<button
552
+ type="button"
553
+ data-trail-tile-receipt
554
+ data-instance-id=${tr.instanceId}
555
+ @click=${() => this._onTileReceiptClick(tr.instanceId)}
556
+ style=${styleMap(tileReceiptChipStyles())}
557
+ >
558
+ <span style=${styleMap(tileReceiptGlyphStyles())} aria-hidden="true"
559
+ ><i style=${styleMap(tileReceiptGlyphLayerStyles(0))}></i
560
+ ><i style=${styleMap(tileReceiptGlyphLayerStyles(1))}></i
561
+ ></span>
562
+ <span style=${styleMap(receiptChipTitleStyles("tile"))}>${tr.title}</span>
563
+ <span style=${styleMap(tileReceiptActionStyles(tr.dismissed))}
564
+ >${tr.dismissed ? "Bring back \u21BA" : "View \u25B8"}</span
565
+ >
566
+ </button>`;
567
+ }
568
+ render() {
569
+ if (this.messages.length === 0 && !this.greeting && !this.introSuggestion && this.tileReceipts.length === 0 && this.inlineWidgets.length === 0)
570
+ return nothing;
571
+ if (this.messages.length === 0 && (this.greeting || this.introSuggestion)) {
572
+ return html`<div data-syntro-chat-trail data-state="empty" style=${styleMap({
573
+ display: "flex",
574
+ flexDirection: "column",
575
+ alignItems: "center",
576
+ justifyContent: "center",
577
+ textAlign: "center",
578
+ gap: "12px",
579
+ flex: "1 1 auto",
580
+ width: "100%",
581
+ padding: "24px 16px",
582
+ pointerEvents: "auto"
583
+ })}>
584
+ <div data-trail-empty-icon style=${styleMap(emptyIconStyles())} aria-hidden="true">
585
+ ${this.personaAvatarSm ? html`<img
586
+ data-trail-empty-avatar
587
+ src=${this.personaAvatarSm}
588
+ alt=""
589
+ style="width:44px;height:44px;border-radius:9999px;object-fit:cover;display:block;"
590
+ />` : "\u2726"}
591
+ </div>
592
+ ${this.greeting ? html`<h2
593
+ data-trail-empty-title
594
+ style=${styleMap(emptyTitleStyles())}
595
+ >${unsafeHTML(renderMarkdown(stripTrailingWhitespace(this.greeting)))}</h2>` : nothing}
596
+ ${this.introSuggestion ? html`<button
597
+ type="button"
598
+ data-trail-intro-suggestion
599
+ @click=${this._onIntroSuggestionClick}
600
+ style=${styleMap(introSuggestionStyles())}
601
+ >${this.introSuggestion.label}</button>` : nothing}
602
+ ${this._renderReceiptStrip(this.tileReceipts)}
603
+ ${this._renderInlineWidgetStrip(this.inlineWidgets)}
604
+ </div>`;
605
+ }
606
+ if (this.messages.length === 0 && (this.tileReceipts.length > 0 || this.inlineWidgets.length > 0)) {
607
+ return html`<div data-syntro-chat-trail data-state="empty" style=${styleMap({
608
+ display: "flex",
609
+ flexDirection: "column",
610
+ alignItems: "flex-start",
611
+ gap: "6px",
612
+ width: "100%",
613
+ padding: "8px 0",
614
+ pointerEvents: "auto"
615
+ })}>
616
+ ${this._renderReceiptStrip(this.tileReceipts)}
617
+ ${this._renderInlineWidgetStrip(this.inlineWidgets)}
618
+ </div>`;
619
+ }
620
+ let turnReplies = [];
621
+ for (let i = this.messages.length - 1; i >= 0; i--) {
622
+ const m = this.messages[i];
623
+ if (m.role === "user") break;
624
+ if (m.role === "assistant") {
625
+ const replies = extractSuggestedReplies(m.toolCalls);
626
+ if (replies.length > 0) {
627
+ turnReplies = replies;
628
+ break;
629
+ }
630
+ }
631
+ }
632
+ let suggestionHostId = null;
633
+ if (turnReplies.length > 0) {
634
+ for (let i = this.messages.length - 1; i >= 0; i--) {
635
+ const m = this.messages[i];
636
+ if (m.role === "user") break;
637
+ if (m.role === "assistant" && (assistantChipRendersContent(m, []) || extractSuggestedReplies(m.toolCalls).length > 0)) {
638
+ suggestionHostId = m.id;
639
+ break;
640
+ }
641
+ }
642
+ }
643
+ const renderable = this.messages.filter(
644
+ (m) => m.role !== "assistant" || assistantChipRendersContent(m, m.id === suggestionHostId ? turnReplies : [])
645
+ );
646
+ const visible = this.expanded || this.forceExpanded ? renderable : renderable.slice(-this.visibleCount);
647
+ const hidden = renderable.length - visible.length;
648
+ const visibleIds = new Set(visible.map((m) => m.id));
649
+ const receiptsByAnchor = /* @__PURE__ */ new Map();
650
+ const tailReceipts = [];
651
+ for (const r of this.tileReceipts) {
652
+ const anchor = this._receiptAnchors.get(r.instanceId) ?? null;
653
+ if (anchor !== null && visibleIds.has(anchor)) {
654
+ const list = receiptsByAnchor.get(anchor);
655
+ if (list) list.push(r);
656
+ else receiptsByAnchor.set(anchor, [r]);
657
+ } else {
658
+ tailReceipts.push(r);
659
+ }
660
+ }
661
+ const inlineByAnchor = /* @__PURE__ */ new Map();
662
+ const tailInlineWidgets = [];
663
+ for (const w of this.inlineWidgets) {
664
+ const anchor = this._inlineAnchors.get(w.instanceId) ?? null;
665
+ if (anchor !== null && visibleIds.has(anchor)) {
666
+ const list = inlineByAnchor.get(anchor);
667
+ if (list) list.push(w);
668
+ else inlineByAnchor.set(anchor, [w]);
669
+ } else {
670
+ tailInlineWidgets.push(w);
671
+ }
672
+ }
673
+ const baseStyles = {
674
+ display: "flex",
675
+ flexDirection: "column",
676
+ gap: "6px",
677
+ // NO `justifyContent: flex-end` here — combined with `overflow: auto`
678
+ // it's a known cross-browser bug: oversized flex children get
679
+ // pushed ABOVE the container without contributing to scrollHeight,
680
+ // so the scrollbar never appears. We rely on `:first-child {
681
+ // margin-top: auto }` (injected as global CSS) to push short
682
+ // content to the bottom of the box while leaving overflow handling
683
+ // to the native scroll container.
684
+ width: "100%",
685
+ // The trail itself has no chrome — chips sit on the host page bg.
686
+ padding: "0",
687
+ pointerEvents: "auto"
688
+ };
689
+ const modeStyles = this.forceExpanded ? {
690
+ // Full-screen host: fill all available vertical space and
691
+ // scroll the overflow. ``flex: 1 1 auto`` (basis AUTO, not 0) —
692
+ // in a DEFINITE-height parent (full-screen panel) grow:1 still
693
+ // fills all free space and margin-top:auto bottom-anchors, so
694
+ // fill behavior is unchanged; but basis auto also lets the trail
695
+ // report its CONTENT height as its natural size, so in an
696
+ // INDEFINITE-height parent (velvet's shrink-wrap bottom sheet)
697
+ // the trail contributes real content height instead of collapsing
698
+ // to 0 — which is what lets the sheet hug its content with no void.
699
+ // shrink:1 + min-height:0 still lets the trail shrink first when
700
+ // the parent runs out of room; the input row is absolute-pinned
701
+ // (never pushed off) and the header shrinks after the trail.
702
+ flex: "1 1 auto",
703
+ minHeight: "0",
704
+ overflowY: "auto",
705
+ // ``contain`` prevents scroll from bubbling out to the host
706
+ // page when the trail hits top or bottom. Required for the
707
+ // panel-scroll-leak regression test in
708
+ // SyntroBottomSheet.test.ts.
709
+ overscrollBehavior: "contain",
710
+ paddingTop: "8px",
711
+ borderTop: "var(--sc-content-bubble-border, 1px solid rgba(255, 255, 255, 0.18))",
712
+ scrollBehavior: "smooth",
713
+ scrollbarWidth: "thin"
714
+ } : this.expanded ? {
715
+ maxHeight: "min(320px, 40vh)",
716
+ overflowY: "auto",
717
+ paddingTop: "8px",
718
+ borderTop: "var(--sc-content-bubble-border, 1px solid rgba(255, 255, 255, 0.18))",
719
+ scrollBehavior: "smooth",
720
+ scrollbarWidth: "thin"
721
+ } : {
722
+ // Collapsed mode = ambient peek surface. Never a scrollbar:
723
+ // long messages slide up behind the blur veil instead of
724
+ // exposing a scrollbar. The "↑ N more · expand" affordance
725
+ // is the only entry into history; clicking it switches to
726
+ // expanded mode where scroll IS allowed.
727
+ // Height kept short so the trail reads as a peek strip above
728
+ // the chat bar, not a panel — long single replies fade into
729
+ // the blur veil at the top, user expands to read the rest.
730
+ maxHeight: "min(140px, 22vh)",
731
+ overflow: "hidden"
732
+ };
733
+ const containerStyles = { ...baseStyles, ...modeStyles };
734
+ const frameStyles = this.forceExpanded ? {
735
+ position: "relative",
736
+ width: "100%",
737
+ display: "flex",
738
+ flexDirection: "column",
739
+ // basis AUTO (see the scroller's modeStyles note): fills a definite
740
+ // parent, contributes content height in an indefinite one (velvet
741
+ // shrink-wrap). Every fill layer in the chain must be basis-auto or
742
+ // the min-content of a basis-0 layer collapses the whole chain to 0.
743
+ flex: "1 1 auto",
744
+ minHeight: "0"
745
+ } : {
746
+ position: "relative",
747
+ width: "100%"
748
+ };
749
+ return html`
750
+ ${// Single toggle button above the frame. Same DOM node morphs:
751
+ // collapsed → "↑ N more · expand" / "↑ expand" (calls _onExpand)
752
+ // expanded → "⌄ minimize" (calls _onCollapse)
753
+ // Lives OUTSIDE the frame so the absolute blur veil (top:0,
754
+ // z-index:2 inside the frame) doesn't cover it. Shown whenever
755
+ // there's anything in the trail — the user expects this affordance
756
+ // available regardless of how many chips are on screen.
757
+ // When ``forceExpanded`` is set by the host (full-screen panel,
758
+ // etc.), there's nowhere to collapse INTO and the affordance
759
+ // would just confuse the user — suppress it entirely.
760
+ this.messages.length === 0 || this.forceExpanded ? nothing : this.expanded ? html`<button
761
+ type="button"
762
+ data-trail-toggle
763
+ data-trail-collapse
764
+ @click=${this._onCollapse}
765
+ style=${styleMap(moreStyles())}
766
+ >⌄ minimize</button>` : html`<button
767
+ type="button"
768
+ data-trail-toggle
769
+ data-trail-more
770
+ @click=${this._onExpand}
771
+ style=${styleMap(moreStyles())}
772
+ >${hidden > 0 ? html`↑ ${hidden} more · expand` : html`↑ expand`}</button>`}
773
+ <div data-syntro-chat-trail-frame style=${styleMap(frameStyles)}>
774
+ <div
775
+ data-syntro-chat-trail
776
+ style=${styleMap(containerStyles)}
777
+ @scroll=${this._onTrailScroll}
778
+ @wheel=${this._markUserScrollIntent}
779
+ @touchmove=${this._markUserScrollIntent}
780
+ >
781
+ ${visible.map((m, i) => {
782
+ const pos = this.expanded || this.forceExpanded ? 0 : visible.length - 1 - i;
783
+ const state = chipVisualState(m);
784
+ const isStreaming = state.kind === "streaming";
785
+ const {
786
+ pendingToolCalls: toolCalls,
787
+ receipts,
788
+ replyChips
789
+ } = computeAssistantChipParts(m, m.id === suggestionHostId ? turnReplies : []);
790
+ const renderedText = m.role === "assistant" ? (
791
+ // Never paint a degenerate serialized-response dump, even if the
792
+ // turn also carries a legit part (receipt/chip) that keeps it in
793
+ // the renderable set. Mirrors the filter's drop signal.
794
+ looksLikeSerializedModelResponse(m.text ?? "") ? nothing : html`${unsafeHTML(renderMarkdown(stripTrailingWhitespace(m.text)))}`
795
+ ) : html`${m.text}`;
796
+ const chipStyle = isStreaming ? {
797
+ ...chipStyles(state, pos),
798
+ animation: "syntro-trail-chip-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both"
799
+ } : chipStyles(state, pos);
800
+ const personaByline = this.personaName && this._isAssistantTurnStart(m) ? html`<div data-trail-persona-byline style=${styleMap(personaBylineStyles())}>
801
+ ${this.personaAvatarSm ? html`<img
802
+ data-trail-persona-avatar
803
+ src=${this.personaAvatarSm}
804
+ alt=""
805
+ style=${styleMap(personaAvatarStyles())}
806
+ />` : nothing}<span>${this.personaName}</span>
807
+ </div>` : nothing;
808
+ return html`<div
809
+ data-trail-chip
810
+ data-trail-chip-id=${String(m.id)}
811
+ data-role=${m.role}
812
+ data-status=${m.status ?? "complete"}
813
+ style=${styleMap(chipStyle)}
814
+ >${personaByline}${renderedText}${isStreaming ? html`<span
815
+ data-trail-caret
816
+ aria-hidden="true"
817
+ style=${styleMap(caretStyles())}
818
+ ></span>` : nothing}${toolCalls.length > 0 ? html`<div data-trail-toolcalls style=${styleMap(toolCallStripStyles())}>
819
+ ${toolCalls.map(
820
+ (tc) => html`<button
821
+ type="button"
822
+ data-trail-toolcall
823
+ data-tool-id=${tc.id}
824
+ data-tool-status=${tc.status}
825
+ @click=${() => this._onToolCallClick(tc)}
826
+ style=${styleMap(pendingToolCallChipStyles())}
827
+ title="${tc.name} · ${tc.status}"
828
+ >? ${tc.name}</button>`
829
+ )}
830
+ </div>` : nothing}${receipts.length > 0 ? html`<div data-trail-receipts style=${styleMap(receiptStripStyles())}>
831
+ ${receipts.map(
832
+ (r) => html`<div
833
+ data-trail-receipt
834
+ data-receipt-type=${r.type}
835
+ style=${styleMap(receiptStyles())}
836
+ ><span aria-hidden="true" style=${styleMap(receiptIconStyles())}>✓</span
837
+ ><span>${receiptText(r)}</span></div>`
838
+ )}
839
+ </div>` : nothing}${replyChips.length > 0 ? html`<div data-trail-reply-chips style=${styleMap(replyChipStripStyles())}>
840
+ ${replyChips.map(
841
+ (reply) => html`<button
842
+ type="button"
843
+ data-trail-reply-chip
844
+ @click=${() => this._onSuggestedReplyClick(reply)}
845
+ style=${styleMap(replyChipStyles())}
846
+ >${reply}</button>`
847
+ )}
848
+ </div>` : nothing}</div>${// Receipt chips that mounted while THIS message was newest render
849
+ // inline right here, so they read as part of the flow and drift up
850
+ // as later messages append below them.
851
+ this._renderReceiptStrip(receiptsByAnchor.get(m.id) ?? [])}${// Inline widgets (reco carousel) anchored to this message render
852
+ // right here in the conversation flow — the whole point of the
853
+ // chat-inline slot: visible in the trail, not behind the panel.
854
+ this._renderInlineWidgetStrip(inlineByAnchor.get(m.id) ?? [])}`;
855
+ })}
856
+ ${// Thinking indicator — fills the gap between the user's
857
+ // most recent message and the assistant's first token.
858
+ // Shows when chatSession.inFlight is true AND the last
859
+ // message is NOT a streaming assistant message (the
860
+ // assistant's own caret takes over once tokens arrive).
861
+ // Styled as an assistant chip with three bouncing dots
862
+ // instead of text, so the thread reads naturally — the
863
+ // visitor sees an "assistant" bubble that's clearly busy.
864
+ this._shouldShowThinking() ? html`<div
865
+ data-trail-thinking
866
+ data-role="assistant"
867
+ data-status="thinking"
868
+ aria-label=${this.thinkingText ? `${this.personaName || "Assistant"} is thinking: ${this.thinkingText}` : `${this.personaName || "Assistant"} is thinking`}
869
+ style=${styleMap(thinkingSlotStyles())}
870
+ >
871
+ ${this.personaName ? html`<span data-trail-thinking-persona style=${styleMap(personaBylineStyles())}>
872
+ ${this.personaAvatarSm ? html`<img
873
+ data-trail-persona-avatar
874
+ src=${this.personaAvatarSm}
875
+ alt=""
876
+ style=${styleMap(personaAvatarStyles())}
877
+ />` : nothing}<span>${this.personaName} is typing…</span>
878
+ </span>` : nothing}
879
+ ${// When the backend has streamed reasoning narration into
880
+ // `thinkingText`, render the tail of it as a small italic
881
+ // line — the model's own intent ("Let me check what we have
882
+ // for student athletes…") narrates the gap better than
883
+ // silent dots. Falls back to the bouncing-dot pulse when
884
+ // no narration has arrived yet.
885
+ this.thinkingText ? html`<span
886
+ data-trail-thinking-text
887
+ style=${styleMap(thinkingTextStyles())}
888
+ >${truncateThinkingTail(this.thinkingText)}</span
889
+ >` : html`<span data-trail-thinking-dots style=${styleMap(thinkingDotsStyles())}>
890
+ <span style=${styleMap(thinkingDotStyles(0))}></span>
891
+ <span style=${styleMap(thinkingDotStyles(1))}></span>
892
+ <span style=${styleMap(thinkingDotStyles(2))}></span>
893
+ </span>`}
894
+ </div>` : nothing}
895
+ ${// Tail strip: receipts with no in-slice anchor (mounted pre-chat, or
896
+ // their anchor message scrolled out of the ambient view). Keeps them
897
+ // reachable at the bottom — the old always-pinned position — rather
898
+ // than dropping them. In expanded/maximized mode every message is
899
+ // painted, so this is usually empty and receipts sit inline instead.
900
+ this._renderReceiptStrip(tailReceipts)}
901
+ ${// Inline widgets with no in-slice anchor (mounted pre-chat, or their
902
+ // anchor scrolled out of the ambient view) render at the tail.
903
+ this._renderInlineWidgetStrip(tailInlineWidgets)}
904
+ </div>
905
+ ${// The blur veil only fires in collapsed (ambient) mode.
906
+ // Expanded = the user explicitly asked for history; blurring
907
+ // the top of the panel would hide content (including the
908
+ // minimize button just above the trail's top edge) the user
909
+ // came here to see.
910
+ this.expanded || this.forceExpanded ? nothing : html`<div
911
+ data-trail-blur-veil
912
+ aria-hidden="true"
913
+ style=${styleMap(blurVeilStyles())}
914
+ ></div>`}
915
+ ${// "Scroll to most recent" pill (FEAT-1783535427): shown only in
916
+ // the scrollable modes when content extends below the viewport —
917
+ // the visitor is reading the reply top-down and this is the
918
+ // one-tap opt-in back to the newest content. Absolute inside the
919
+ // positioning frame so it floats over the scroller's bottom edge.
920
+ this._scrollable && this._messagesBelow > 0 ? html`<button
921
+ type="button"
922
+ data-trail-scroll-recent
923
+ aria-label="Scroll to most recent — ${this._messagesBelow} ${this._messagesBelow === 1 ? "message" : "messages"} below"
924
+ @click=${this._onScrollToRecent}
925
+ style=${styleMap(scrollRecentPillStyles())}
926
+ ><span aria-hidden="true" style="font-size:11px;line-height:1;">⌄</span><span>${this._messagesBelow} ${this._messagesBelow === 1 ? "message" : "messages"}</span></button>` : nothing}
927
+ </div>
928
+ `;
929
+ }
930
+ };
931
+ // Trail-local styles. The chat-trail used to render into light DOM
932
+ // (`createRenderRoot() { return this }`) and inject these rules into
933
+ // `document.head` so chip + markdown styles could find their selectors.
934
+ // That broke the moment the trail was hosted inside any shadow root
935
+ // (sc-mount → custom-canvas → velvet-mobile → sc-mount → chat-bar →
936
+ // chat-trail is the production chain): CSS animations require the
937
+ // @keyframes rule to be in the SAME TreeScope as the element using
938
+ // it, and document-level keyframes don't cross a shadow boundary.
939
+ // Result was silent — `animation-name` parsed fine, the property got
940
+ // the right resolved value, but the browser never instantiated an
941
+ // Animation object because no matching keyframe was visible from
942
+ // inside the shadow tree. `getAnimations()` returned `[]`, dots sat
943
+ // frozen at base scale, the typing indicator looked dead.
944
+ //
945
+ // Shadow DOM with `static styles` is the right architecture here.
946
+ // CSS custom properties (--sc-tile-text-color, --sc-content-bubble-*,
947
+ // etc.) inherit through shadow boundaries by default, so the canvas
948
+ // theme cascade still reaches the chips without the light-DOM
949
+ // workaround. As a bonus the markdown reset rules can't leak out
950
+ // onto the host page, and the keyframes are owned by the trail's
951
+ // own tree scope where the chip + dot elements live.
952
+ _AdaptiveChatTrail.styles = css`
953
+ /* Bottom-anchor for CONVERSATIONS only: a short message list hugs the
954
+ composer instead of floating at the top. The :not() gate matters —
955
+ unscoped, the auto margin also grabbed the empty-state hero's first
956
+ child (the icon disc), ate all free space above it, and pinned the
957
+ greeting to the bottom of a maximized panel, silently defeating the
958
+ hero's own justify-content: center. */
959
+ [data-syntro-chat-trail]:not([data-state="empty"]) > :first-child {
960
+ margin-top: auto;
961
+ }
962
+ [data-trail-chip] > p:first-child {
963
+ margin-top: 0;
964
+ }
965
+ [data-trail-chip] > p:last-child {
966
+ margin-bottom: 0;
967
+ }
968
+ [data-trail-chip] p {
969
+ margin: 4px 0;
970
+ }
971
+ [data-trail-chip] br + br {
972
+ display: none;
973
+ }
974
+ [data-trail-chip] ul,
975
+ [data-trail-chip] ol {
976
+ margin: 4px 0;
977
+ padding-left: 1.1em;
978
+ }
979
+ [data-trail-chip] li {
980
+ margin: 0;
981
+ }
982
+ [data-trail-chip] li + li {
983
+ margin-top: 1px;
984
+ }
985
+ [data-trail-chip] pre {
986
+ margin: 6px 0;
987
+ padding: 6px 8px;
988
+ background: var(--sc-content-code-background-block, rgba(0, 0, 0, 0.35));
989
+ border-radius: 6px;
990
+ overflow-x: auto;
991
+ font-size: 11px;
992
+ }
993
+ [data-trail-chip] code {
994
+ font-family: ui-monospace, SF Mono, Menlo, monospace;
995
+ font-size: 11px;
996
+ background: var(--sc-content-code-background, rgba(0, 0, 0, 0.25));
997
+ padding: 1px 4px;
998
+ border-radius: 3px;
999
+ }
1000
+ [data-trail-chip] pre code {
1001
+ background: transparent;
1002
+ padding: 0;
1003
+ }
1004
+ [data-trail-chip] a {
1005
+ color: var(--sc-content-link-color, var(--sc-color-primary, #b72e2a));
1006
+ text-decoration: underline;
1007
+ }
1008
+ [data-trail-chip] strong {
1009
+ font-weight: 600;
1010
+ }
1011
+ /* Streaming-caret blink for in-progress assistant chips. */
1012
+ @keyframes syntro-trail-caret {
1013
+ 0% {
1014
+ opacity: 1;
1015
+ }
1016
+ 50% {
1017
+ opacity: 0;
1018
+ }
1019
+ 100% {
1020
+ opacity: 1;
1021
+ }
1022
+ }
1023
+ /* Thinking-dot scale + opacity pulse. The naked-dots redesign
1024
+ relies entirely on this motion to signal "alive" — there's no
1025
+ chip frame around the dots, so a subtle animation reads as
1026
+ broken. 0.4 → 1.0 scale with 0.25 → 1.0 opacity gives an
1027
+ unmistakable breathing cue. */
1028
+ @keyframes syntro-trail-thinking-pulse {
1029
+ 0%,
1030
+ 100% {
1031
+ transform: scale(0.4);
1032
+ opacity: 0.25;
1033
+ }
1034
+ 45% {
1035
+ transform: scale(1);
1036
+ opacity: 1;
1037
+ }
1038
+ }
1039
+ /* Chip entrance — fades a freshly-mounted chip up from below
1040
+ so the thinking → first-streaming-chip handoff feels like one
1041
+ continuous motion instead of an element swap. */
1042
+ @keyframes syntro-trail-chip-enter {
1043
+ from {
1044
+ opacity: 0;
1045
+ transform: translateY(6px) scale(0.97);
1046
+ }
1047
+ to {
1048
+ opacity: 1;
1049
+ transform: translateY(0) scale(1);
1050
+ }
1051
+ }
1052
+ `;
1053
+ _AdaptiveChatTrail.properties = {
1054
+ messages: { attribute: false },
1055
+ visibleCount: { type: Number },
1056
+ expanded: { type: Boolean },
1057
+ greeting: { type: String },
1058
+ /** When true: trail renders fully expanded AND the
1059
+ * expand/collapse affordance is hidden. Used by hosts that mount
1060
+ * the chat in a full-screen surface (the mobile panel) where
1061
+ * collapse makes no sense — there's nothing to collapse INTO. */
1062
+ forceExpanded: { type: Boolean },
1063
+ /**
1064
+ * Mirrors `chatSession.inFlight` — true between `send()` and the
1065
+ * assistant's first `receiveStart`/`receiveDelta`, OR while a
1066
+ * stream is in progress. Drives the "thinking" indicator that
1067
+ * fills the gap between the user's just-sent message and the
1068
+ * first assistant token. Wired through AdaptiveChatBar (it owns
1069
+ * the chatSession subscription and forwards inFlight as a prop).
1070
+ */
1071
+ inFlight: { type: Boolean },
1072
+ /**
1073
+ * Live reasoning narration streamed from the model via AG-UI
1074
+ * `THINKING_TEXT_MESSAGE_CONTENT`. When non-empty, the thinking
1075
+ * chip renders this in place of the bouncing dots so the visitor
1076
+ * sees what the model is actually doing instead of staring at
1077
+ * silent loading state during a tool-call gap.
1078
+ */
1079
+ thinkingText: { attribute: false },
1080
+ personaName: { attribute: false },
1081
+ personaAvatarSm: { attribute: false },
1082
+ /**
1083
+ * Pre-conversation suggestion chip. When the visitor opens the
1084
+ * chat and `messages` is empty, the trail renders this as a
1085
+ * button beneath the greeting. Clicking it dispatches a
1086
+ * `trail-intro-suggestion` event with the configured prompt —
1087
+ * the parent (chat-bar) forwards that to chatSession.send().
1088
+ * Disappears the moment the first message lands.
1089
+ */
1090
+ introSuggestion: { attribute: false },
1091
+ /**
1092
+ * Tappable tile-receipt chips rendered once at the end of the
1093
+ * trail. Produced from the store by Task 3 (AdaptiveChatBar).
1094
+ * Each chip emits `tile-receipt-activate` with its instanceId.
1095
+ */
1096
+ tileReceipts: { attribute: false },
1097
+ /**
1098
+ * Widgets rendered INLINE in the trail (reco carousel etc.),
1099
+ * anchored to a message position like receipts. Sourced from the
1100
+ * store's `chat-inline` slot mounts via AdaptiveChatBar.
1101
+ */
1102
+ inlineWidgets: { attribute: false },
1103
+ /**
1104
+ * Registry-backed mounter for inline widgets whose id is not their
1105
+ * custom-element tag. Injected from the chat-bar mountable.
1106
+ */
1107
+ mountInlineWidget: { attribute: false },
1108
+ _messagesBelow: { state: true }
1109
+ };
1110
+ _AdaptiveChatTrail._AT_BOTTOM_EPSILON_PX = 50;
1111
+ _AdaptiveChatTrail._USER_SCROLL_INTENT_WINDOW_MS = 1200;
1112
+ var AdaptiveChatTrail = _AdaptiveChatTrail;
1113
+ function scrollRecentPillStyles() {
1114
+ return {
1115
+ position: "absolute",
1116
+ bottom: "8px",
1117
+ left: "50%",
1118
+ transform: "translateX(-50%)",
1119
+ display: "flex",
1120
+ alignItems: "center",
1121
+ gap: "5px",
1122
+ height: "26px",
1123
+ padding: "0 10px",
1124
+ borderRadius: "9999px",
1125
+ border: "var(--sc-content-bubble-border, 1px solid rgba(255, 255, 255, 0.18))",
1126
+ background: "var(--sc-content-bubble-bg, rgba(20, 22, 28, 0.85))",
1127
+ color: "var(--sc-tile-text-color, rgba(255, 255, 255, 0.92))",
1128
+ fontSize: "12px",
1129
+ fontWeight: "500",
1130
+ lineHeight: "1",
1131
+ whiteSpace: "nowrap",
1132
+ cursor: "pointer",
1133
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.25)",
1134
+ zIndex: "3"
1135
+ };
1136
+ }
1137
+ function blurVeilStyles() {
1138
+ const mask = "linear-gradient(to bottom, black 0%, black 40%, transparent 100%)";
1139
+ return {
1140
+ position: "absolute",
1141
+ top: "0",
1142
+ left: "0",
1143
+ right: "0",
1144
+ height: `${TRAIL_BLUR_VEIL_PX}px`,
1145
+ pointerEvents: "none",
1146
+ backdropFilter: `blur(${TRAIL_BLUR_MAX_PX}px)`,
1147
+ WebkitBackdropFilter: `blur(${TRAIL_BLUR_MAX_PX}px)`,
1148
+ maskImage: mask,
1149
+ WebkitMaskImage: mask,
1150
+ zIndex: "2"
1151
+ };
1152
+ }
1153
+ function chipVisualState(m) {
1154
+ if (m.role === "system") {
1155
+ return { kind: "notice", role: m.role };
1156
+ }
1157
+ if (m.role === "assistant" && m.status === "error") {
1158
+ return { kind: "settled", role: m.role };
1159
+ }
1160
+ if (m.status === "error") {
1161
+ return { kind: "notice", role: m.role };
1162
+ }
1163
+ if (m.role === "assistant" && m.status === "streaming") {
1164
+ return { kind: "streaming" };
1165
+ }
1166
+ return { kind: "settled", role: m.role };
1167
+ }
1168
+ function chipStyles(state, pos) {
1169
+ const opacity = Math.max(OPACITY_FLOOR, 1 - pos * OPACITY_STEP);
1170
+ const yPx = pos * Y_DRIFT_PX;
1171
+ let border;
1172
+ let background;
1173
+ let color;
1174
+ let alignSelf;
1175
+ switch (state.kind) {
1176
+ case "settled":
1177
+ if (state.role === "user") {
1178
+ border = "var(--sc-content-bubble-border-user, 1px solid rgba(255, 255, 255, 0.28))";
1179
+ background = "var(--sc-content-bubble-background-user, rgba(255, 255, 255, 0.10))";
1180
+ alignSelf = "flex-end";
1181
+ } else {
1182
+ border = "var(--sc-content-bubble-border, 1px solid rgba(255, 255, 255, 0.18))";
1183
+ background = "var(--sc-content-bubble-background, rgba(20, 22, 24, 0.35))";
1184
+ alignSelf = "flex-start";
1185
+ }
1186
+ color = "var(--sc-tile-title-color, inherit)";
1187
+ break;
1188
+ case "streaming":
1189
+ case "pending":
1190
+ border = "var(--sc-content-bubble-border, 1px solid rgba(255, 255, 255, 0.18))";
1191
+ background = "var(--sc-content-bubble-background, rgba(20, 22, 24, 0.35))";
1192
+ color = "var(--sc-tile-title-color, inherit)";
1193
+ alignSelf = "flex-start";
1194
+ break;
1195
+ case "notice":
1196
+ border = "var(--sc-content-bubble-border-error, 1px solid rgba(255, 255, 255, 0.22))";
1197
+ background = "var(--sc-content-bubble-background-error, rgba(20, 22, 24, 0.28))";
1198
+ color = "var(--sc-content-bubble-text-error, rgba(255, 255, 255, 0.75))";
1199
+ alignSelf = state.role === "user" ? "flex-end" : "flex-start";
1200
+ break;
1201
+ }
1202
+ return {
1203
+ alignSelf,
1204
+ // Single shared width policy for EVERY bubble (user / assistant /
1205
+ // streaming / pending / error). Short and long replies now read as a
1206
+ // consistent column: `fit-content` sizes each bubble to its text, and
1207
+ // `TRAIL_MAX_WIDTH` caps the wide ones at the same ceiling — no more
1208
+ // per-role or per-path width drift (user feedback: chat tiles had
1209
+ // differing widths).
1210
+ maxWidth: TRAIL_MAX_WIDTH,
1211
+ width: "fit-content",
1212
+ // Bumped from 11px → 13px after the chat lid in production read as
1213
+ // cramped at the smaller size — body chat copy needs to be visitor-
1214
+ // legible on a phone without zoom, and 11px reads as a footnote
1215
+ // rather than the primary conversation surface. Code blocks inside
1216
+ // chip body scale proportionally (10px → 11px) below.
1217
+ fontSize: "13px",
1218
+ lineHeight: "1.45",
1219
+ padding: "5px 11px",
1220
+ borderRadius: "10px",
1221
+ border,
1222
+ background,
1223
+ // Frosted-glass bubbles are OPT-IN via the `--sc-content-bubble-blur`
1224
+ // knob (a host that wants glass sets e.g. `blur(8px)` deliberately); the
1225
+ // DEFAULT is `none`. A backdrop-filter on the bubble composites its OWN
1226
+ // text onto a GPU layer (grayscale AA + the blurred backdrop bleeding
1227
+ // under thin glyphs) — the resting-state compositing contract's
1228
+ // "backdrop-filter behind text" case: soft rasterization everywhere and
1229
+ // fully BLANKED text on some Android GPUs. Glass-by-default shipped that
1230
+ // hazard to every non-velvet surface, so the default flipped to `none`
1231
+ // (GPU/compositing hazard audit, Fix 3). Gate:
1232
+ // AdaptiveChatTrail.compositing.test.ts.
1233
+ backdropFilter: "var(--sc-content-bubble-blur, none)",
1234
+ WebkitBackdropFilter: "var(--sc-content-bubble-blur, none)",
1235
+ color,
1236
+ transition: "opacity 240ms cubic-bezier(0.22, 1, 0.36, 1), transform 240ms cubic-bezier(0.22, 1, 0.36, 1)",
1237
+ // Per-position opacity + y-drift falloff is still per-chip — those
1238
+ // operate naturally per element. Blur is NOT per-chip anymore: it's
1239
+ // applied via an absolute-positioned veil at the top of the scroll
1240
+ // viewport (TRAIL_BLUR_VEIL_PX above), so a single long message
1241
+ // doesn't get uniformly blurred across its full body.
1242
+ opacity: String(toFixedTrim(opacity)),
1243
+ transform: `translateY(${yPx}px)`,
1244
+ // User messages render as raw text (`html` template); pre-wrap preserves
1245
+ // newlines the visitor typed. Assistant messages render as markdown HTML
1246
+ // (`<p>` tags from marked); pre-wrap there would make marked's inter-tag
1247
+ // text nodes (the `\n` between/after `<p>` tags) visible, producing the
1248
+ // trailing/inter-paragraph blank lines visitors reported. Markdown's own
1249
+ // structure carries the paragraph breaks, so assistant chips use the
1250
+ // default `normal` whitespace handling.
1251
+ whiteSpace: (state.kind === "settled" || state.kind === "notice") && state.role === "user" ? "pre-wrap" : "normal",
1252
+ wordBreak: "break-word"
1253
+ };
1254
+ }
1255
+ function toolCallStripStyles() {
1256
+ return {
1257
+ display: "flex",
1258
+ flexWrap: "wrap",
1259
+ gap: "4px",
1260
+ marginTop: "4px",
1261
+ paddingTop: "4px",
1262
+ borderTop: "1px dashed var(--sc-content-divider-color, rgba(255, 255, 255, 0.12))"
1263
+ };
1264
+ }
1265
+ function pendingToolCallChipStyles() {
1266
+ return {
1267
+ display: "inline-flex",
1268
+ alignItems: "center",
1269
+ gap: "4px",
1270
+ padding: "2px 7px",
1271
+ border: "var(--sc-content-bubble-border-user, 1px solid rgba(255, 255, 255, 0.28))",
1272
+ background: "var(--sc-content-bubble-background-user, rgba(255, 255, 255, 0.10))",
1273
+ color: "var(--sc-tile-text-color, inherit)",
1274
+ borderRadius: "999px",
1275
+ fontSize: "10px",
1276
+ fontWeight: "500",
1277
+ fontFamily: "ui-monospace, SF Mono, Menlo, monospace",
1278
+ cursor: "pointer",
1279
+ opacity: "1"
1280
+ };
1281
+ }
1282
+ function deriveActivityReceipt(tc) {
1283
+ if (tc.name !== "primary_response_action") return null;
1284
+ const args = tc.args;
1285
+ if (!args || typeof args !== "object") return null;
1286
+ const templateId = typeof args.template_id === "string" ? args.template_id : "";
1287
+ const content = args.content ?? null;
1288
+ const title = content && typeof content.title === "string" && content.title.trim() ? content.title.trim() : "";
1289
+ const id = templateId.toLowerCase();
1290
+ let type;
1291
+ if (id.includes("faq")) type = "FAQ";
1292
+ else if (id.includes("chart") || id.includes("graph")) type = "chart";
1293
+ else if (id.includes("card") || id.includes("product")) type = "card";
1294
+ else if (id.includes("alert") || id.includes("navigation") || id.includes("nav")) type = "alert";
1295
+ else type = "added to your view";
1296
+ const label = title || templateId || "something";
1297
+ return { type, label };
1298
+ }
1299
+ function receiptText(r) {
1300
+ switch (r.type) {
1301
+ case "FAQ":
1302
+ return "Opened the FAQ for you";
1303
+ case "chart":
1304
+ return `Put together ${r.label} for you`;
1305
+ case "card":
1306
+ return "Pulled up the product for you";
1307
+ case "alert":
1308
+ return `Here's a shortcut: ${r.label}`;
1309
+ default:
1310
+ return `Pulled up ${r.label} for you`;
1311
+ }
1312
+ }
1313
+ function extractSuggestedReplies(toolCalls) {
1314
+ if (!toolCalls) return [];
1315
+ for (const tc of toolCalls) {
1316
+ if (tc.name !== "suggest_replies") continue;
1317
+ const replies = tc.args?.replies;
1318
+ if (!Array.isArray(replies)) return [];
1319
+ return replies.filter((r) => typeof r === "string" && r.trim().length > 0).map((r) => r.trim()).slice(0, 4);
1320
+ }
1321
+ return [];
1322
+ }
1323
+ function computeAssistantChipParts(m, hostReplies) {
1324
+ const toolCalls = m.toolCalls ?? [];
1325
+ return {
1326
+ pendingToolCalls: toolCalls.filter((tc) => tc.status === "pending"),
1327
+ receipts: toolCalls.map(deriveActivityReceipt).filter((r) => r !== null),
1328
+ // Threaded in by the render (empty for non-hosts): under pills-last the
1329
+ // hosting message can differ from the message that carried the tool call.
1330
+ replyChips: hostReplies
1331
+ };
1332
+ }
1333
+ function looksLikeSerializedModelResponse(text) {
1334
+ const t = text.trimStart();
1335
+ if (t.startsWith("(Empty response")) return true;
1336
+ let hits = 0;
1337
+ if (/['"]stop_reason['"]\s*:/.test(t)) hits++;
1338
+ if (/['"]signature['"]\s*:/.test(t)) hits++;
1339
+ if (/['"]type['"]\s*:\s*['"]thinking['"]/.test(t)) hits++;
1340
+ if (/['"]content['"]\s*:\s*\[/.test(t)) hits++;
1341
+ return hits >= 2;
1342
+ }
1343
+ function assistantChipRendersContent(m, hostReplies) {
1344
+ const text = (m.text ?? "").trim();
1345
+ if (text.length > 0 && !looksLikeSerializedModelResponse(text)) return true;
1346
+ const { pendingToolCalls, receipts, replyChips } = computeAssistantChipParts(m, hostReplies);
1347
+ return pendingToolCalls.length > 0 || receipts.length > 0 || replyChips.length > 0;
1348
+ }
1349
+ function inlineWidgetBlockStyles() {
1350
+ return {
1351
+ alignSelf: "stretch",
1352
+ width: "100%",
1353
+ minWidth: "0",
1354
+ marginTop: "6px",
1355
+ contain: "layout"
1356
+ };
1357
+ }
1358
+ function receiptStripStyles() {
1359
+ return {
1360
+ display: "flex",
1361
+ flexDirection: "column",
1362
+ gap: "2px",
1363
+ marginTop: "4px"
1364
+ };
1365
+ }
1366
+ function receiptStyles() {
1367
+ return {
1368
+ display: "inline-flex",
1369
+ alignItems: "center",
1370
+ gap: "5px",
1371
+ alignSelf: "flex-start",
1372
+ fontSize: "11px",
1373
+ lineHeight: "1.3",
1374
+ fontWeight: "500",
1375
+ letterSpacing: "0.01em",
1376
+ // Inherit the chip's OWN resolved text color (muted via opacity), so the
1377
+ // receipt is legible on every theme by construction. The old
1378
+ // `--sc-content-muted-color` fallback was rgba(255,255,255,…) — white —
1379
+ // invisible on light canvases; a `--sc-tile-text-color` token can likewise
1380
+ // be unset/mismatched on dark canvases. `currentColor` always matches the
1381
+ // chip text the visitor is already reading.
1382
+ color: "currentColor",
1383
+ opacity: "0.7"
1384
+ };
1385
+ }
1386
+ function receiptIconStyles() {
1387
+ return {
1388
+ display: "inline-flex",
1389
+ alignItems: "center",
1390
+ justifyContent: "center",
1391
+ width: "14px",
1392
+ height: "14px",
1393
+ borderRadius: "50%",
1394
+ fontSize: "9px",
1395
+ fontWeight: "700",
1396
+ color: "#fff",
1397
+ background: "hsl(var(--sc-accent-color, 145 40% 45%) / 0.7)"
1398
+ };
1399
+ }
1400
+ function replyChipStripStyles() {
1401
+ return {
1402
+ display: "flex",
1403
+ flexWrap: "wrap",
1404
+ gap: "6px",
1405
+ marginTop: "6px",
1406
+ alignSelf: "flex-start"
1407
+ };
1408
+ }
1409
+ function tileReceiptStripStyles() {
1410
+ return {
1411
+ display: "flex",
1412
+ flexDirection: "column",
1413
+ gap: "6px",
1414
+ marginTop: "6px",
1415
+ width: "100%",
1416
+ alignSelf: "stretch"
1417
+ };
1418
+ }
1419
+ function replyChipStyles() {
1420
+ return {
1421
+ display: "inline-flex",
1422
+ alignItems: "center",
1423
+ padding: "5px 12px",
1424
+ border: "1px solid hsl(var(--sc-accent-color, 145 40% 45%) / 0.40)",
1425
+ borderRadius: "9999px",
1426
+ background: "hsl(var(--sc-accent-color, 145 40% 45%) / 0.12)",
1427
+ color: "var(--sc-tile-text-color, currentColor)",
1428
+ font: "inherit",
1429
+ fontSize: "12px",
1430
+ fontWeight: "500",
1431
+ lineHeight: "1.2",
1432
+ cursor: "pointer",
1433
+ backdropFilter: "var(--sc-content-bubble-blur, none)",
1434
+ WebkitBackdropFilter: "var(--sc-content-bubble-blur, none)",
1435
+ transition: "background 150ms ease, transform 120ms ease"
1436
+ };
1437
+ }
1438
+ function alertReceiptIconStyles() {
1439
+ return {
1440
+ flex: "0 0 auto",
1441
+ width: "26px",
1442
+ height: "26px",
1443
+ borderRadius: "8px",
1444
+ display: "grid",
1445
+ placeItems: "center",
1446
+ background: "hsl(var(--sc-accent-color))",
1447
+ color: "var(--sc-accent-foreground, #fff)",
1448
+ fontSize: "14px"
1449
+ };
1450
+ }
1451
+ function tileReceiptChipStyles() {
1452
+ return {
1453
+ display: "flex",
1454
+ alignItems: "center",
1455
+ gap: "10px",
1456
+ width: "100%",
1457
+ textAlign: "left",
1458
+ cursor: "pointer",
1459
+ padding: "9px 10px 9px 11px",
1460
+ borderRadius: "12px",
1461
+ border: "1px solid hsl(var(--sc-accent-color) / 0.28)",
1462
+ background: "hsl(var(--sc-accent-color) / 0.06)"
1463
+ };
1464
+ }
1465
+ function tileReceiptGlyphStyles() {
1466
+ return { position: "relative", flex: "0 0 auto", width: "28px", height: "28px" };
1467
+ }
1468
+ function tileReceiptGlyphLayerStyles(i) {
1469
+ return {
1470
+ position: "absolute",
1471
+ inset: "0",
1472
+ borderRadius: "7px",
1473
+ background: "hsl(var(--sc-accent-color) / 0.22)",
1474
+ boxShadow: "inset 0 0 0 1px hsl(var(--sc-accent-color) / 0.4)",
1475
+ ...i === 0 ? { transform: "translate(3px,-3px) rotate(6deg)", opacity: "0.5" } : {}
1476
+ };
1477
+ }
1478
+ function receiptChipTitleStyles(_kind) {
1479
+ return {
1480
+ // Grows to fill the space between the icon and the action pill, truncating
1481
+ // long product names on one line (the chip is now single-line).
1482
+ flex: "1",
1483
+ minWidth: "0",
1484
+ fontSize: "13px",
1485
+ fontWeight: "650",
1486
+ lineHeight: "1.2",
1487
+ color: "var(--sc-content-text-color, #0f172a)",
1488
+ whiteSpace: "nowrap",
1489
+ overflow: "hidden",
1490
+ textOverflow: "ellipsis"
1491
+ };
1492
+ }
1493
+ function tileReceiptActionStyles(dismissed) {
1494
+ return {
1495
+ flex: "0 0 auto",
1496
+ fontSize: "11px",
1497
+ fontWeight: "700",
1498
+ borderRadius: "999px",
1499
+ padding: "4px 10px",
1500
+ ...dismissed ? { background: "hsl(var(--sc-accent-color) / 0.14)", color: "hsl(var(--sc-accent-color))" } : {
1501
+ background: "hsl(var(--sc-accent-color) / 0.9)",
1502
+ color: "var(--sc-accent-foreground, #fff)"
1503
+ }
1504
+ };
1505
+ }
1506
+ function caretStyles() {
1507
+ return {
1508
+ display: "inline-block",
1509
+ width: "6px",
1510
+ height: "12px",
1511
+ marginLeft: "3px",
1512
+ verticalAlign: "-1px",
1513
+ background: "hsl(var(--sc-accent-color) / 0.85)",
1514
+ borderRadius: "1px",
1515
+ animation: "syntro-trail-caret 1s steps(2, end) infinite"
1516
+ };
1517
+ }
1518
+ function thinkingSlotStyles() {
1519
+ return {
1520
+ alignSelf: "flex-start",
1521
+ color: "var(--sc-tile-title-color, inherit)",
1522
+ padding: "4px 4px",
1523
+ animation: "syntro-trail-chip-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both"
1524
+ };
1525
+ }
1526
+ function thinkingDotsStyles() {
1527
+ return {
1528
+ display: "inline-flex",
1529
+ alignItems: "center",
1530
+ gap: "6px",
1531
+ // Line height matches what a single-line text chip would compute
1532
+ // to, so swapping out the slot for the streaming chip doesn't
1533
+ // shift the next chip up or down by a couple of pixels.
1534
+ lineHeight: "14px"
1535
+ };
1536
+ }
1537
+ function thinkingDotStyles(index) {
1538
+ return {
1539
+ display: "inline-block",
1540
+ width: "7px",
1541
+ height: "7px",
1542
+ borderRadius: "50%",
1543
+ background: "currentColor",
1544
+ // The dots are now the entire visual — no bubble framing them —
1545
+ // so the pulse has to do all the work the chip used to do. Scale
1546
+ // 0.4 → 1.0 with opacity 0.25 → 1.0 gives an unmistakable
1547
+ // breathing motion: at the trough each dot reads like a faint
1548
+ // ghost, at the peak like a solid dot. 940ms cycle is a touch
1549
+ // faster than the original 1.05s; without the chip frame the
1550
+ // shorter cycle feels purposeful instead of agitated. Stagger
1551
+ // 130ms = clear rolling cadence across all three.
1552
+ transformOrigin: "center",
1553
+ animation: `syntro-trail-thinking-pulse 940ms cubic-bezier(0.4, 0, 0.6, 1) ${index * 0.13}s infinite both`
1554
+ };
1555
+ }
1556
+ function moreStyles() {
1557
+ return {
1558
+ // Center horizontally regardless of the parent's display model.
1559
+ // ``alignSelf: center`` only works when the parent is a flex/grid
1560
+ // container with cross-axis alignment; the chat-bar's flex column
1561
+ // stretches us full-width instead. ``display: block`` + auto inline
1562
+ // margins centers the auto-sized button without needing the parent
1563
+ // to opt in.
1564
+ display: "block",
1565
+ marginLeft: "auto",
1566
+ marginRight: "auto",
1567
+ marginBottom: "6px",
1568
+ fontSize: "10px",
1569
+ fontWeight: "500",
1570
+ letterSpacing: "0.06em",
1571
+ padding: "4px 10px",
1572
+ border: "1px solid hsl(var(--sc-accent-color) / 0.32)",
1573
+ borderRadius: "999px",
1574
+ background: "hsl(var(--sc-accent-color) / 0.10)",
1575
+ color: "var(--sc-tile-text-color, currentColor)",
1576
+ cursor: "pointer",
1577
+ opacity: "0.85",
1578
+ backdropFilter: "var(--sc-content-bubble-blur, none)",
1579
+ WebkitBackdropFilter: "var(--sc-content-bubble-blur, none)",
1580
+ transition: "opacity 150ms ease, background 150ms ease"
1581
+ };
1582
+ }
1583
+ function stripTrailingWhitespace(text) {
1584
+ return text.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
1585
+ }
1586
+ function toFixedTrim(n) {
1587
+ const s = n.toFixed(2);
1588
+ return s.replace(/\.?0+$/, "") || "0";
1589
+ }
1590
+ var THINKING_TAIL_MAX = 120;
1591
+ function truncateThinkingTail(text) {
1592
+ const flat = text.replace(/\s+/g, " ").trim();
1593
+ if (flat.length <= THINKING_TAIL_MAX) return flat;
1594
+ return `\u2026${flat.slice(-THINKING_TAIL_MAX)}`;
1595
+ }
1596
+ function thinkingTextStyles() {
1597
+ return {
1598
+ fontStyle: "italic",
1599
+ opacity: "0.75",
1600
+ fontSize: "13px",
1601
+ lineHeight: "18px",
1602
+ maxWidth: "32ch",
1603
+ overflow: "hidden",
1604
+ textOverflow: "ellipsis",
1605
+ whiteSpace: "nowrap"
1606
+ };
1607
+ }
1608
+ function introSuggestionStyles() {
1609
+ return {
1610
+ display: "inline-flex",
1611
+ alignItems: "center",
1612
+ gap: "6px",
1613
+ marginTop: "4px",
1614
+ padding: "10px 18px",
1615
+ borderRadius: "9999px",
1616
+ border: "0",
1617
+ background: "linear-gradient(135deg, var(--sc-color-primary, #5faf7d) 0%, var(--sc-color-primary-hover, #3d8a5e) 100%)",
1618
+ color: "#fff",
1619
+ cursor: "pointer",
1620
+ font: "inherit",
1621
+ fontSize: "14px",
1622
+ fontWeight: "600",
1623
+ transition: "transform 120ms ease, filter 160ms ease"
1624
+ };
1625
+ }
1626
+ function emptyIconStyles() {
1627
+ return {
1628
+ display: "inline-flex",
1629
+ alignItems: "center",
1630
+ justifyContent: "center",
1631
+ width: "48px",
1632
+ height: "48px",
1633
+ borderRadius: "9999px",
1634
+ background: "var(--sc-tile-icon-background, linear-gradient(135deg, var(--sc-color-primary, #5faf7d) 0%, var(--sc-color-primary-hover, #3d8a5e) 100%))",
1635
+ color: "#fff",
1636
+ fontSize: "22px"
1637
+ };
1638
+ }
1639
+ function emptyTitleStyles() {
1640
+ return {
1641
+ margin: "0",
1642
+ fontSize: "15px",
1643
+ fontWeight: "600",
1644
+ lineHeight: "1.35",
1645
+ color: "var(--sc-content-text-color, inherit)",
1646
+ maxWidth: "32ch"
1647
+ };
1648
+ }
1649
+ if (!customElements.get("adaptive-chat-trail")) {
1650
+ customElements.define("adaptive-chat-trail", AdaptiveChatTrail);
1651
+ }
1652
+ function personaBylineStyles() {
1653
+ return {
1654
+ display: "flex",
1655
+ alignItems: "center",
1656
+ gap: "5px",
1657
+ fontSize: "0.68rem",
1658
+ fontWeight: "600",
1659
+ opacity: "0.65",
1660
+ marginBottom: "3px",
1661
+ lineHeight: "1"
1662
+ };
1663
+ }
1664
+ function personaAvatarStyles() {
1665
+ return {
1666
+ width: "16px",
1667
+ height: "16px",
1668
+ borderRadius: "9999px",
1669
+ objectFit: "cover",
1670
+ flexShrink: "0"
1671
+ };
1672
+ }
1673
+
1674
+ export {
1675
+ AdaptiveChatTrail,
1676
+ looksLikeSerializedModelResponse
1677
+ };
1678
+ //# sourceMappingURL=chunk-A7RBZLIX.js.map