@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
@@ -223,8 +223,8 @@ var _createTrustedTypesPolicy = function _createTrustedTypesPolicy2(trustedTypes
223
223
  const policyName = "dompurify" + (suffix ? "#" + suffix : "");
224
224
  try {
225
225
  return trustedTypes.createPolicy(policyName, {
226
- createHTML(html8) {
227
- return html8;
226
+ createHTML(html9) {
227
+ return html9;
228
228
  },
229
229
  createScriptURL(scriptUrl) {
230
230
  return scriptUrl;
@@ -2238,12 +2238,102 @@ var en = x.lex;
2238
2238
  import { html as html22 } from "lit";
2239
2239
  import { html as html3, nothing as nothing2 } from "lit";
2240
2240
  import { html as html5 } from "lit";
2241
+ import { html as html8, LitElement as LitElement2 } from "lit";
2242
+ function getCtor() {
2243
+ if (typeof window === "undefined") return void 0;
2244
+ const w2 = window;
2245
+ return w2.SpeechRecognition ?? w2.webkitSpeechRecognition;
2246
+ }
2247
+ function isDictationSupported() {
2248
+ return getCtor() !== void 0;
2249
+ }
2250
+ function mapError(engineCode) {
2251
+ switch (engineCode) {
2252
+ case "not-allowed":
2253
+ case "service-not-allowed":
2254
+ return "not-allowed";
2255
+ case "no-speech":
2256
+ return "no-speech";
2257
+ case "aborted":
2258
+ return "aborted";
2259
+ case "network":
2260
+ return "network";
2261
+ default:
2262
+ return "other";
2263
+ }
2264
+ }
2265
+ var MAX_SESSION_MS = 6e4;
2266
+ function createDictation(opts) {
2267
+ let rec = null;
2268
+ let listening = false;
2269
+ let capTimer = null;
2270
+ const finish = () => {
2271
+ if (capTimer !== null) {
2272
+ clearTimeout(capTimer);
2273
+ capTimer = null;
2274
+ }
2275
+ if (!listening) return;
2276
+ listening = false;
2277
+ rec = null;
2278
+ opts.onEnd();
2279
+ };
2280
+ return {
2281
+ get listening() {
2282
+ return listening;
2283
+ },
2284
+ start() {
2285
+ if (listening) return;
2286
+ const Ctor = getCtor();
2287
+ if (!Ctor) return;
2288
+ rec = new Ctor();
2289
+ rec.lang = opts.lang ?? (document.documentElement.lang || "en-US");
2290
+ rec.continuous = true;
2291
+ rec.interimResults = true;
2292
+ rec.onresult = (e) => {
2293
+ if (!listening) return;
2294
+ const parts = [];
2295
+ let allFinal = true;
2296
+ for (let i = 0; i < e.results.length; i++) {
2297
+ const result = e.results[i];
2298
+ const seg = result[0]?.transcript.trim() ?? "";
2299
+ if (!result.isFinal) allFinal = false;
2300
+ if (seg && seg !== parts[parts.length - 1]) parts.push(seg);
2301
+ }
2302
+ opts.onTranscript(parts.join(" "), allFinal);
2303
+ };
2304
+ rec.onerror = (e) => {
2305
+ const code = mapError(e.error);
2306
+ console.debug(`chat.dictation:error ${e.error} -> ${code}`);
2307
+ listening = false;
2308
+ rec = null;
2309
+ opts.onError(code);
2310
+ };
2311
+ rec.onend = finish;
2312
+ listening = true;
2313
+ capTimer = setTimeout(() => {
2314
+ console.debug("chat.dictation:cap 60s session limit reached \u2014 stopping");
2315
+ const active = rec;
2316
+ finish();
2317
+ active?.stop();
2318
+ }, MAX_SESSION_MS);
2319
+ rec.start();
2320
+ },
2321
+ stop() {
2322
+ const active = rec;
2323
+ finish();
2324
+ active?.stop();
2325
+ }
2326
+ };
2327
+ }
2241
2328
  var sendIcon = html2`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 14V2M8 2L3 7M8 2L13 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
2242
2329
  var stopIcon = html2`<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="12" height="12" rx="2" fill="currentColor"/></svg>`;
2330
+ var micIcon = html2`<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="6" y="1.5" width="4" height="8" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`;
2243
2331
  function autoResize(e) {
2244
2332
  const el = e.target;
2245
2333
  el.style.height = "auto";
2246
- el.style.height = `${Math.min(el.scrollHeight, 150)}px`;
2334
+ const capped = Math.min(el.scrollHeight, 150);
2335
+ el.style.height = `${capped}px`;
2336
+ el.style.overflowY = capped >= 150 ? "auto" : "hidden";
2247
2337
  }
2248
2338
  function renderChatInput(props) {
2249
2339
  const canSend = props.value.trim().length > 0 && !props.isStreaming;
@@ -2273,6 +2363,15 @@ function renderChatInput(props) {
2273
2363
  aria-label="Message input"
2274
2364
  ></textarea>
2275
2365
  ${props.inputActionsSlot ?? nothing}
2366
+ ${props.showMic ? html2`<button
2367
+ data-sc-mic-btn
2368
+ data-listening=${props.micListening ? "true" : "false"}
2369
+ ?disabled=${props.micDisabled ?? false}
2370
+ @click=${props.onMicClick}
2371
+ aria-pressed=${props.micListening ? "true" : "false"}
2372
+ aria-label=${props.micListening ? "Stop dictation" : "Start dictation"}
2373
+ title=${props.micDisabled ? "Microphone blocked" : props.micListening ? "Stop dictation" : "Dictate"}
2374
+ >${micIcon}</button>` : nothing}
2276
2375
  <button
2277
2376
  data-sc-send-btn
2278
2377
  ?disabled=${!canSend && !props.isStreaming}
@@ -2283,7 +2382,18 @@ function renderChatInput(props) {
2283
2382
  `;
2284
2383
  }
2285
2384
  function fingerprint(msg) {
2286
- const tcIds = msg.toolCalls?.map((tc) => `${tc.id}:${tc.status}`).join(",") ?? "";
2385
+ const tcIds = msg.toolCalls?.map((tc) => {
2386
+ let resultKey = "-";
2387
+ if (tc.result !== void 0) {
2388
+ try {
2389
+ const json = JSON.stringify(tc.result);
2390
+ resultKey = json === void 0 ? "u" : String(json.length);
2391
+ } catch {
2392
+ resultKey = String(String(tc.result).length);
2393
+ }
2394
+ }
2395
+ return `${tc.id}:${tc.status}:${tc.interruptId ?? ""}:${resultKey}:${tc.error ?? ""}`;
2396
+ }).join(",") ?? "";
2287
2397
  return `${msg.id}:${msg.role}:${msg.content.length}:${msg.status}:${tcIds}`;
2288
2398
  }
2289
2399
  var marked = new B({ async: false, gfm: true, breaks: true });
@@ -2343,16 +2453,50 @@ function statusIcon(status) {
2343
2453
  return toolIcon;
2344
2454
  }
2345
2455
  }
2456
+ var STATUS_TEXT = {
2457
+ "args-streaming": "Running\u2026",
2458
+ running: "Running\u2026",
2459
+ pending: "Needs approval",
2460
+ done: "Done",
2461
+ error: "Failed",
2462
+ cancelled: "Cancelled"
2463
+ };
2464
+ function humanizeToolName(name) {
2465
+ const words = name.replace(/[_-]+/g, " ").trim();
2466
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
2467
+ }
2468
+ var ARGS_SUMMARY_MAX = 90;
2469
+ var ARG_VALUE_MAX = 48;
2470
+ function summarizeArgs(args) {
2471
+ if (!args) return "";
2472
+ const bits = [];
2473
+ for (const [key, value] of Object.entries(args)) {
2474
+ if (value === null || value === void 0) continue;
2475
+ const t = typeof value;
2476
+ if (t !== "string" && t !== "number" && t !== "boolean") continue;
2477
+ let rendered = String(value);
2478
+ if (rendered.length > ARG_VALUE_MAX) rendered = `${rendered.slice(0, ARG_VALUE_MAX)}\u2026`;
2479
+ bits.push(`${key}: ${rendered}`);
2480
+ if (bits.join(" \xB7 ").length >= ARGS_SUMMARY_MAX) break;
2481
+ }
2482
+ let summary = bits.join(" \xB7 ");
2483
+ if (summary.length > ARGS_SUMMARY_MAX) summary = `${summary.slice(0, ARGS_SUMMARY_MAX)}\u2026`;
2484
+ return summary;
2485
+ }
2346
2486
  function renderToolCallCard(props) {
2347
2487
  const { toolCall, toolLabel, approve, deny } = props;
2348
- const displayName = toolLabel ?? toolCall.name;
2488
+ const displayName = toolLabel ?? humanizeToolName(toolCall.name);
2349
2489
  const isActive = toolCall.status === "args-streaming" || toolCall.status === "running";
2490
+ const statusText = STATUS_TEXT[toolCall.status] ?? "";
2491
+ const argsSummary = summarizeArgs(toolCall.args);
2350
2492
  return html3`
2351
- <div data-sc-tool-card ?data-sc-active=${isActive}>
2493
+ <div data-sc-tool-card ?data-sc-active=${isActive} data-sc-tool-phase=${toolCall.status}>
2352
2494
  <div data-sc-tool-header>
2353
2495
  <span data-sc-tool-icon data-sc-status=${toolCall.status}>${statusIcon(toolCall.status)}</span>
2354
2496
  <span data-sc-tool-name>${displayName}</span>
2497
+ ${statusText ? html3`<span data-sc-tool-status-text data-sc-status=${toolCall.status}>${statusText}</span>` : nothing2}
2355
2498
  </div>
2499
+ ${argsSummary ? html3`<div data-sc-tool-args>${argsSummary}</div>` : nothing2}
2356
2500
  ${toolCall.status === "error" && toolCall.error ? html3`<div data-sc-tool-error>${toolCall.error}</div>` : nothing2}
2357
2501
  ${toolCall.status === "pending" ? html3`<div data-sc-tool-actions>
2358
2502
  <button data-sc-approve @click=${approve} aria-label="Approve ${displayName}">Approve</button>
@@ -2369,18 +2513,20 @@ function renderBubbleContent(message) {
2369
2513
  return html4`<div data-sc-msg-assistant>${unsafeHTML(renderMarkdown(message.content))}</div>`;
2370
2514
  }
2371
2515
  function renderMessageBubble(props) {
2372
- const { message, toolLabels, toolRenderers, defaultToolRenderer } = props;
2516
+ const { message, toolLabels, toolRenderers, toolContext, defaultToolRenderer } = props;
2373
2517
  const isUser = message.role === "user";
2374
2518
  const toolCards = message.toolCalls?.map((tc) => {
2375
- const approve = () => props.onToolApprove(tc.id);
2519
+ const approve = (payload) => props.onToolApprove(tc.id, payload);
2376
2520
  const deny = () => props.onToolDeny(tc.id);
2521
+ const context = toolContext?.[tc.name];
2377
2522
  const customRenderer = toolRenderers?.[tc.name];
2378
2523
  if (customRenderer) {
2379
2524
  return customRenderer.render({
2380
2525
  toolCall: tc,
2381
2526
  phase: tc.status,
2382
2527
  approve,
2383
- deny
2528
+ deny,
2529
+ context
2384
2530
  });
2385
2531
  }
2386
2532
  if (defaultToolRenderer) {
@@ -2388,22 +2534,23 @@ function renderMessageBubble(props) {
2388
2534
  toolCall: tc,
2389
2535
  phase: tc.status,
2390
2536
  approve,
2391
- deny
2537
+ deny,
2538
+ context
2392
2539
  });
2393
2540
  }
2394
2541
  return renderToolCallCard({
2395
2542
  toolCall: tc,
2396
2543
  toolLabel: toolLabels?.[tc.name],
2397
- approve,
2544
+ approve: () => approve(),
2398
2545
  deny
2399
2546
  });
2400
2547
  });
2401
2548
  return html4`
2402
- <div data-sc-msg-row data-sc-role=${message.role}>
2549
+ <div data-sc-msg-row data-sc-role=${message.role} data-sc-msg-id=${message.id}>
2403
2550
  ${!isUser ? assistantIcon : nothing3}
2404
2551
  <div data-sc-msg-content>
2405
2552
  ${renderBubbleContent(message)}
2406
- ${message.status === "error" ? html4`<div data-sc-msg-error>Failed to send</div>` : nothing3}
2553
+ ${message.status === "error" ? html4`<div data-sc-msg-error>${isUser ? "Failed to send" : "Response interrupted"}</div>` : nothing3}
2407
2554
  ${toolCards ?? nothing3}
2408
2555
  ${renderActionBar({
2409
2556
  messageId: message.id,
@@ -2435,11 +2582,12 @@ function renderMessageList(props) {
2435
2582
  props.messages,
2436
2583
  (msg) => msg.id,
2437
2584
  (msg) => guard(
2438
- [fingerprint(msg)],
2585
+ [fingerprint(msg), props.toolContext],
2439
2586
  () => renderMessageBubble({
2440
2587
  message: msg,
2441
2588
  toolLabels: props.toolLabels,
2442
2589
  toolRenderers: props.toolRenderers,
2590
+ toolContext: props.toolContext,
2443
2591
  defaultToolRenderer: props.defaultToolRenderer,
2444
2592
  onCopy: props.onCopy,
2445
2593
  onRegenerate: props.onRegenerate,
@@ -2655,7 +2803,11 @@ var STYLE_RULES = `
2655
2803
  border-radius: var(--sc-chat-textarea-radius, 14px);
2656
2804
  padding: var(--sc-chat-textarea-padding, 10px 16px);
2657
2805
  font-family: inherit;
2658
- font-size: var(--sc-chat-textarea-font-size, 0.9rem);
2806
+ /* >= 16px: iOS Safari auto-zooms a focused input < 16px and never zooms back
2807
+ out (same class as the vela AdaptiveChatBar composer fix). 0.9rem was 14.4px
2808
+ \u2192 zoom-on-focus + stuck-zoomed on the native <syntro-chat> mobile surfaces.
2809
+ A workspace can still override the token, but must keep it >= 16px on mobile. */
2810
+ font-size: var(--sc-chat-textarea-font-size, 1rem);
2659
2811
  resize: none;
2660
2812
  outline: none;
2661
2813
  background: var(--sc-chat-input-bg, #ffffff);
@@ -2664,7 +2816,7 @@ var STYLE_RULES = `
2664
2816
  max-height: 150px;
2665
2817
  line-height: 1.4;
2666
2818
  transition: border-color 0.2s, box-shadow 0.2s;
2667
- overflow-y: auto;
2819
+ overflow-y: hidden;
2668
2820
  }
2669
2821
 
2670
2822
  [data-sc-chat] [data-sc-chat-input] textarea:focus {
@@ -2706,6 +2858,60 @@ var STYLE_RULES = `
2706
2858
  display: block;
2707
2859
  }
2708
2860
 
2861
+ [data-sc-chat] [data-sc-mic-btn] {
2862
+ background: transparent;
2863
+ color: var(--sc-content-text-secondary-color, #87919f);
2864
+ border: none;
2865
+ border-radius: 50%;
2866
+ width: var(--sc-chat-send-btn-size, 38px);
2867
+ height: var(--sc-chat-send-btn-size, 38px);
2868
+ cursor: pointer;
2869
+ display: flex;
2870
+ align-items: center;
2871
+ justify-content: center;
2872
+ flex-shrink: 0;
2873
+ transition: color 0.2s, transform 0.15s, opacity 0.2s;
2874
+ }
2875
+
2876
+ [data-sc-chat] [data-sc-mic-btn]:hover:not(:disabled) {
2877
+ color: var(--sc-color-primary, #6a59ce);
2878
+ }
2879
+
2880
+ [data-sc-chat] [data-sc-mic-btn]:disabled {
2881
+ opacity: 0.35;
2882
+ cursor: not-allowed;
2883
+ }
2884
+
2885
+ [data-sc-chat] [data-sc-mic-btn][data-listening='true'] {
2886
+ background: var(--sc-color-primary, #6a59ce);
2887
+ color: var(--sc-color-primary-text, #ffffff);
2888
+ animation: sc-mic-ring 1.5s ease-out infinite;
2889
+ }
2890
+
2891
+ @keyframes sc-mic-ring {
2892
+ 0% {
2893
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--sc-color-primary, #6a59ce) 45%, transparent);
2894
+ transform: scale(1);
2895
+ }
2896
+ 50% { transform: scale(1.08); }
2897
+ 70% { box-shadow: 0 0 0 12px transparent; }
2898
+ 100% {
2899
+ box-shadow: 0 0 0 0 transparent;
2900
+ transform: scale(1);
2901
+ }
2902
+ }
2903
+
2904
+ @media (prefers-reduced-motion: reduce) {
2905
+ [data-sc-chat] [data-sc-mic-btn][data-listening='true'] {
2906
+ animation: none;
2907
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--sc-color-primary, #6a59ce) 35%, transparent);
2908
+ }
2909
+ }
2910
+
2911
+ [data-sc-chat] [data-sc-mic-btn] svg {
2912
+ display: block;
2913
+ }
2914
+
2709
2915
  /* \u2500\u2500 Typing indicator (compact: three bouncing dots) \u2500\u2500 */
2710
2916
  [data-sc-chat] [data-sc-typing] {
2711
2917
  display: flex;
@@ -2832,7 +3038,50 @@ var STYLE_RULES = `
2832
3038
  }
2833
3039
 
2834
3040
  [data-sc-chat] [data-sc-tool-name] {
3041
+ font-weight: 600;
3042
+ }
3043
+
3044
+ [data-sc-chat] [data-sc-tool-status-text] {
3045
+ margin-left: auto;
3046
+ font-size: 0.72rem;
2835
3047
  font-weight: 500;
3048
+ letter-spacing: 0.02em;
3049
+ text-transform: uppercase;
3050
+ color: var(--sc-content-text-secondary-color, #6b6b80);
3051
+ white-space: nowrap;
3052
+ }
3053
+
3054
+ [data-sc-chat] [data-sc-tool-status-text][data-sc-status="done"] {
3055
+ color: #22c55e;
3056
+ }
3057
+
3058
+ [data-sc-chat] [data-sc-tool-status-text][data-sc-status="error"] {
3059
+ color: #ef4444;
3060
+ }
3061
+
3062
+ [data-sc-chat] [data-sc-tool-status-text][data-sc-status="running"],
3063
+ [data-sc-chat] [data-sc-tool-status-text][data-sc-status="args-streaming"] {
3064
+ color: #3b82f6;
3065
+ }
3066
+
3067
+ [data-sc-chat] [data-sc-tool-status-text][data-sc-status="cancelled"] {
3068
+ color: var(--sc-content-text-secondary-color, #6b6b80);
3069
+ text-decoration: line-through;
3070
+ }
3071
+
3072
+ [data-sc-chat] [data-sc-tool-args] {
3073
+ margin-top: 6px;
3074
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
3075
+ font-size: 0.76rem;
3076
+ color: var(--sc-content-text-secondary-color, #6b6b80);
3077
+ overflow: hidden;
3078
+ text-overflow: ellipsis;
3079
+ white-space: nowrap;
3080
+ }
3081
+
3082
+ [data-sc-chat] [data-sc-tool-card][data-sc-tool-phase="cancelled"] {
3083
+ opacity: 0.65;
3084
+ border-left-color: var(--sc-content-border-color, #e8e8ee);
2836
3085
  }
2837
3086
 
2838
3087
  [data-sc-chat] [data-sc-tool-error] {
@@ -2892,7 +3141,7 @@ var STYLE_RULES = `
2892
3141
  margin-top: 4px;
2893
3142
  }
2894
3143
 
2895
- /* \u2500\u2500 Scroll-to-bottom button \u2500\u2500 */
3144
+ /* \u2500\u2500 "Scroll to most recent" pill \u2500\u2500 */
2896
3145
  [data-sc-chat] [data-sc-scroll-btn] {
2897
3146
  position: absolute;
2898
3147
  bottom: 80px;
@@ -2901,12 +3150,17 @@ var STYLE_RULES = `
2901
3150
  background: var(--sc-chat-assistant-bubble-bg, #f4f4f8);
2902
3151
  color: var(--sc-content-text-color, #1a1a2e);
2903
3152
  border: 1px solid var(--sc-content-border-color, #e8e8ee);
2904
- border-radius: 50%;
2905
- width: 36px;
2906
- height: 36px;
3153
+ border-radius: 9999px;
3154
+ height: 28px;
3155
+ padding: 0 10px;
2907
3156
  display: flex;
2908
3157
  align-items: center;
2909
3158
  justify-content: center;
3159
+ gap: 5px;
3160
+ font-size: 12px;
3161
+ font-weight: 500;
3162
+ line-height: 1;
3163
+ white-space: nowrap;
2910
3164
  cursor: pointer;
2911
3165
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
2912
3166
  z-index: 1;
@@ -2935,34 +3189,50 @@ function injectChatStyles(root = typeof document === "undefined" ? void 0 : docu
2935
3189
  }
2936
3190
  root.insertBefore(style, root.firstChild ?? null);
2937
3191
  }
2938
- var SyntroChat = class extends LitElement {
3192
+ var _SyntroChat = class _SyntroChat2 extends LitElement {
2939
3193
  constructor() {
2940
3194
  super(...arguments);
3195
+ this.voiceInput = false;
2941
3196
  this._messages = [];
3197
+ this._micListening = false;
3198
+ this._micDisabled = false;
3199
+ this._micBase = "";
3200
+ this._micNetworkErrors = 0;
3201
+ this._dictation = null;
2942
3202
  this._isTyping = false;
2943
- this._isConnected = false;
2944
- this._sessionId = null;
2945
3203
  this._inputValue = "";
2946
3204
  this._isComposing = false;
2947
3205
  this._isStreaming = false;
2948
3206
  this._unsubscribe = null;
2949
3207
  this._scrollContainer = null;
2950
- this._userScrolledUp = false;
2951
3208
  this._errorMessage = null;
3209
+ this._stickToBottom = true;
3210
+ this._messagesBelow = 0;
3211
+ this._lastUserScrollIntentTs = 0;
3212
+ this._markUserScrollIntent = () => {
3213
+ this._lastUserScrollIntentTs = Date.now();
3214
+ };
2952
3215
  this._handleScroll = () => {
2953
3216
  if (!this._scrollContainer) return;
2954
- const { scrollTop, scrollHeight, clientHeight } = this._scrollContainer;
2955
- this._userScrolledUp = scrollHeight - scrollTop - clientHeight > 50;
3217
+ const atBottom = this._isAtBottom();
3218
+ this._messagesBelow = atBottom ? 0 : this._countMessagesBelow();
3219
+ const isUserDriven = Date.now() - this._lastUserScrollIntentTs < _SyntroChat2._USER_SCROLL_INTENT_WINDOW_MS;
3220
+ if (isUserDriven) {
3221
+ this._stickToBottom = atBottom;
3222
+ }
3223
+ };
3224
+ this._resumeFollowing = () => {
3225
+ this._stickToBottom = true;
3226
+ this._scrollToBottom();
2956
3227
  };
2957
3228
  this._handleEvent = (event) => {
2958
3229
  switch (event.type) {
2959
3230
  case "session-ready":
2960
- this._sessionId = event.sessionId;
2961
- this._isConnected = true;
2962
3231
  break;
2963
3232
  case "messages-snapshot":
2964
3233
  this._messages = [...event.messages];
2965
3234
  this._isStreaming = event.messages.some((m2) => m2.status === "streaming");
3235
+ this._stickToBottom = true;
2966
3236
  this._scrollToBottom();
2967
3237
  break;
2968
3238
  case "message-append":
@@ -2970,13 +3240,17 @@ var SyntroChat = class extends LitElement {
2970
3240
  this._messages = [...this._messages, event.message];
2971
3241
  }
2972
3242
  this._isStreaming = event.message.status === "streaming";
2973
- this._scrollToBottom();
3243
+ if (event.message.role === "user") {
3244
+ this._anchorToMessage(event.message.id);
3245
+ } else {
3246
+ this._onContentGrowth();
3247
+ }
2974
3248
  break;
2975
3249
  case "message-delta": {
2976
3250
  this._messages = this._messages.map(
2977
3251
  (m2) => m2.id === event.messageId ? { ...m2, content: m2.content + event.delta } : m2
2978
3252
  );
2979
- this._scrollToBottom();
3253
+ this._onContentGrowth();
2980
3254
  break;
2981
3255
  }
2982
3256
  case "message-complete":
@@ -2987,9 +3261,22 @@ var SyntroChat = class extends LitElement {
2987
3261
  break;
2988
3262
  case "typing":
2989
3263
  this._isTyping = event.isTyping;
2990
- if (event.isTyping) this._scrollToBottom();
3264
+ if (event.isTyping) this._onContentGrowth();
2991
3265
  break;
2992
3266
  case "tool-call": {
3267
+ const hasParent = this._messages.some((m2) => m2.id === event.messageId);
3268
+ if (!hasParent) {
3269
+ this._messages = [
3270
+ ...this._messages,
3271
+ {
3272
+ id: event.messageId,
3273
+ role: "assistant",
3274
+ content: "",
3275
+ timestamp: Date.now(),
3276
+ status: "streaming"
3277
+ }
3278
+ ];
3279
+ }
2993
3280
  this._messages = this._messages.map((m2) => {
2994
3281
  if (m2.id !== event.messageId) return m2;
2995
3282
  const existing = (m2.toolCalls ?? []).findIndex((tc) => tc.id === event.toolCall.id);
@@ -3038,17 +3325,125 @@ var SyntroChat = class extends LitElement {
3038
3325
  });
3039
3326
  break;
3040
3327
  }
3041
- case "error":
3042
- this._errorMessage = event.message;
3328
+ case "tool-call-deferred": {
3329
+ this._messages = this._messages.map((m2) => {
3330
+ if (!m2.toolCalls) return m2;
3331
+ const toolCalls = m2.toolCalls.map(
3332
+ (tc) => tc.id === event.toolCallId ? { ...tc, interruptId: event.interruptId, status: "pending" } : tc
3333
+ );
3334
+ return { ...m2, toolCalls };
3335
+ });
3336
+ break;
3337
+ }
3338
+ case "tool-call-cancelled": {
3339
+ this._messages = this._messages.map((m2) => {
3340
+ if (!m2.toolCalls) return m2;
3341
+ const toolCalls = m2.toolCalls.map(
3342
+ (tc) => tc.id === event.toolCallId ? { ...tc, status: "cancelled" } : tc
3343
+ );
3344
+ return { ...m2, toolCalls };
3345
+ });
3346
+ break;
3347
+ }
3348
+ case "tool-call-result": {
3349
+ let foundToolName;
3350
+ let lastIdx = -1;
3351
+ for (let i = this._messages.length - 1; i >= 0; i--) {
3352
+ if (this._messages[i].toolCalls?.some((tc) => tc.id === event.toolCallId)) {
3353
+ lastIdx = i;
3354
+ break;
3355
+ }
3356
+ }
3357
+ this._messages = this._messages.map((m2, idx) => {
3358
+ if (idx !== lastIdx || !m2.toolCalls) return m2;
3359
+ const toolCalls = m2.toolCalls.map((tc) => {
3360
+ if (tc.id !== event.toolCallId) return tc;
3361
+ foundToolName = tc.name;
3362
+ const status = tc.status === "cancelled" ? "cancelled" : "done";
3363
+ return { ...tc, result: event.result, status };
3364
+ });
3365
+ return { ...m2, toolCalls };
3366
+ });
3367
+ this.dispatchEvent(
3368
+ new CustomEvent("tool-result-received", {
3369
+ detail: {
3370
+ toolCallId: event.toolCallId,
3371
+ toolName: foundToolName,
3372
+ result: event.result
3373
+ },
3374
+ bubbles: true,
3375
+ composed: true
3376
+ })
3377
+ );
3378
+ break;
3379
+ }
3380
+ case "error": {
3381
+ const isSoftTurnEnd = event.code === "USAGE_LIMIT";
3382
+ this._isStreaming = false;
3383
+ this._messages = this._messages.map((m2) => {
3384
+ const wasStreaming = m2.status === "streaming";
3385
+ const hasLiveTool = m2.toolCalls?.some(
3386
+ (tc) => tc.status === "args-streaming" || tc.status === "running"
3387
+ );
3388
+ if (!wasStreaming && !hasLiveTool) return m2;
3389
+ const toolCalls = hasLiveTool ? m2.toolCalls?.map((tc) => {
3390
+ if (tc.status !== "args-streaming" && tc.status !== "running") return tc;
3391
+ return isSoftTurnEnd ? { ...tc, status: "cancelled" } : { ...tc, status: "error", error: tc.error ?? event.message };
3392
+ }) : m2.toolCalls;
3393
+ const nextStatus = wasStreaming ? isSoftTurnEnd ? "complete" : "error" : m2.status;
3394
+ return { ...m2, status: nextStatus, toolCalls };
3395
+ });
3396
+ if (isSoftTurnEnd) {
3397
+ const id = `usage-limit-${this._messages.length}-${event.message.length}`;
3398
+ this._messages = [
3399
+ ...this._messages,
3400
+ {
3401
+ id,
3402
+ role: "assistant",
3403
+ content: event.message,
3404
+ timestamp: Date.now(),
3405
+ status: "complete"
3406
+ }
3407
+ ];
3408
+ } else {
3409
+ console.warn("[syntro-chat] run error:", event.message);
3410
+ this._errorMessage = "Hmm, that didn't go through. Please try again.";
3411
+ }
3043
3412
  break;
3413
+ }
3044
3414
  case "a2ui":
3045
3415
  break;
3046
3416
  }
3047
3417
  };
3048
3418
  this._onInput = (e) => {
3419
+ if (this._micListening) this._dictation?.stop();
3049
3420
  this._inputValue = e.target.value;
3050
3421
  };
3422
+ this._onMicClick = () => {
3423
+ if (this._micListening) {
3424
+ this._dictation?.stop();
3425
+ return;
3426
+ }
3427
+ this._micBase = this._inputValue;
3428
+ this._dictation = createDictation({
3429
+ onTranscript: (text2) => {
3430
+ this._micNetworkErrors = 0;
3431
+ this._inputValue = this._micBase + (this._micBase ? " " : "") + text2;
3432
+ },
3433
+ onEnd: () => {
3434
+ this._micListening = false;
3435
+ },
3436
+ onError: (code) => {
3437
+ this._micListening = false;
3438
+ if (code === "not-allowed") this._micDisabled = true;
3439
+ if (code === "network" && ++this._micNetworkErrors >= 2) this._micDisabled = true;
3440
+ }
3441
+ });
3442
+ this._dictation.start();
3443
+ this._micListening = this._dictation.listening;
3444
+ };
3051
3445
  this._onSend = () => {
3446
+ if (this._micListening) this._dictation?.stop();
3052
3447
  const text2 = this._inputValue.trim();
3053
3448
  if (!text2) return;
3054
3449
  this._appendUserMessage(text2);
@@ -3074,17 +3469,66 @@ var SyntroChat = class extends LitElement {
3074
3469
  this._onRegenerate = (messageId) => {
3075
3470
  this.transport.send({ type: "regenerate", messageId });
3076
3471
  };
3077
- this._onToolApprove = (toolCallId) => {
3472
+ this._onToolApprove = (toolCallId, payload, _retryCount = 0) => {
3473
+ const interruptId = this._findInterruptId(toolCallId);
3474
+ if (interruptId) {
3475
+ this._markToolDone(toolCallId);
3476
+ this.transport.send({
3477
+ type: "deferred-tool-resume",
3478
+ interruptId,
3479
+ decision: "approve",
3480
+ ...payload?.editedArgs ? { editedArgs: payload.editedArgs } : {}
3481
+ });
3482
+ return;
3483
+ }
3484
+ if (this._isPendingClientTool(toolCallId)) {
3485
+ this._markToolDone(toolCallId);
3486
+ this.transport.send({
3487
+ type: "tool-result",
3488
+ toolCallId,
3489
+ result: payload?.editedArgs ?? null,
3490
+ approved: true
3491
+ });
3492
+ return;
3493
+ }
3494
+ if (_retryCount < 10) {
3495
+ setTimeout(() => this._onToolApprove(toolCallId, payload, _retryCount + 1), 200);
3496
+ return;
3497
+ }
3078
3498
  this._markToolDone(toolCallId);
3079
3499
  this.transport.send({
3080
3500
  type: "tool-result",
3081
3501
  toolCallId,
3082
- result: null,
3502
+ result: payload?.editedArgs ?? null,
3083
3503
  approved: true
3084
3504
  });
3085
3505
  };
3086
- this._onToolDeny = (toolCallId) => {
3087
- this._markToolDone(toolCallId);
3506
+ this._onToolDeny = (toolCallId, _retryCount = 0) => {
3507
+ const interruptId = this._findInterruptId(toolCallId);
3508
+ if (interruptId) {
3509
+ this._markToolCancelled(toolCallId);
3510
+ this.transport.send({
3511
+ type: "deferred-tool-resume",
3512
+ interruptId,
3513
+ decision: "deny"
3514
+ });
3515
+ return;
3516
+ }
3517
+ if (this._isPendingClientTool(toolCallId)) {
3518
+ this._markToolCancelled(toolCallId);
3519
+ this.transport.send({
3520
+ type: "tool-result",
3521
+ toolCallId,
3522
+ result: null,
3523
+ approved: false
3524
+ });
3525
+ return;
3526
+ }
3527
+ if (_retryCount < 10) {
3528
+ setTimeout(() => this._onToolDeny(toolCallId, _retryCount + 1), 200);
3529
+ return;
3530
+ }
3531
+ this._markToolCancelled(toolCallId);
3088
3532
  this.transport.send({
3089
3533
  type: "tool-result",
3090
3534
  toolCallId,
@@ -3102,6 +3546,58 @@ var SyntroChat = class extends LitElement {
3102
3546
  this.sendUserMessage(text2);
3103
3547
  };
3104
3548
  }
3549
+ // ── Public API: thread navigation ──────────────────────────────────
3550
+ //
3551
+ // Composing elements (the per-surface wrappers like
3552
+ // <ap-action-plan-chat>) call these to load past threads and start
3553
+ // new ones. The chat element doesn't own the thread index — that's
3554
+ // the consumer's job (typically a sibling <syntro-chat-thread-list>
3555
+ // + the consumer's REST endpoint). This element just owns the
3556
+ // visible message state.
3557
+ /**
3558
+ * Replace the chat's message history wholesale. Used by the host
3559
+ * shell when switching threads (after fetching the new thread's
3560
+ * messages from persistence) or when restoring a session.
3561
+ *
3562
+ * Emits `chat-messages-replaced` so dependent UI (overlay cards,
3563
+ * action plan sidebar) can react.
3564
+ */
3565
+ replaceMessages(messages) {
3566
+ this._messages = [...messages];
3567
+ this._isStreaming = messages.some((m2) => m2.status === "streaming");
3568
+ this._stickToBottom = true;
3569
+ requestAnimationFrame(() => this._scrollToBottom());
3570
+ this.dispatchEvent(
3571
+ new CustomEvent("chat-messages-replaced", {
3572
+ detail: { messageCount: messages.length },
3573
+ bubbles: true,
3574
+ composed: true
3575
+ })
3576
+ );
3577
+ }
3578
+ /**
3579
+ * Clear the chat for a fresh conversation. Equivalent to
3580
+ * `replaceMessages([])` plus a state reset.
3581
+ */
3582
+ reset() {
3583
+ this._messages = [];
3584
+ this._isStreaming = false;
3585
+ this._isTyping = false;
3586
+ this._inputValue = "";
3587
+ this._stickToBottom = true;
3588
+ this._messagesBelow = 0;
3589
+ this._errorMessage = null;
3590
+ }
3591
+ /**
3592
+ * Externally drive the typing indicator. Used by the surface's
3593
+ * run-state restore (BUG-1781288061): a remounted thread whose durable
3594
+ * producer run is still in flight shows the indicator and polls — the
3595
+ * transport's own 'typing' events only exist while the SSE stream lives.
3596
+ */
3597
+ setTyping(isTyping) {
3598
+ this._isTyping = isTyping;
3599
+ if (isTyping) this._scrollToBottom();
3600
+ }
3105
3601
  createRenderRoot() {
3106
3602
  return this;
3107
3603
  }
@@ -3118,11 +3614,29 @@ var SyntroChat = class extends LitElement {
3118
3614
  this._unsubscribe?.();
3119
3615
  this._unsubscribe = null;
3120
3616
  this.transport?.disconnect();
3617
+ this._scrollContainer?.removeEventListener("scroll", this._handleScroll);
3618
+ this._scrollContainer?.removeEventListener("wheel", this._markUserScrollIntent);
3619
+ this._scrollContainer?.removeEventListener("touchmove", this._markUserScrollIntent);
3620
+ this._scrollContainer = null;
3121
3621
  }
3122
3622
  firstUpdated() {
3123
3623
  this._ensureStylesInRoot();
3624
+ this._attachScrollListener();
3625
+ }
3626
+ /** Attach the scroll listener if not already attached. Called from both
3627
+ * firstUpdated (initial mount) and updated (re-mounts — Lit never
3628
+ * re-fires firstUpdated for the same element instance, and
3629
+ * disconnectedCallback tears the listener down). */
3630
+ _attachScrollListener() {
3631
+ if (this._scrollContainer) return;
3124
3632
  this._scrollContainer = this.querySelector("[data-sc-scroll-area]");
3125
3633
  this._scrollContainer?.addEventListener("scroll", this._handleScroll);
3634
+ this._scrollContainer?.addEventListener("wheel", this._markUserScrollIntent, {
3635
+ passive: true
3636
+ });
3637
+ this._scrollContainer?.addEventListener("touchmove", this._markUserScrollIntent, {
3638
+ passive: true
3639
+ });
3126
3640
  }
3127
3641
  /**
3128
3642
  * Resolve the *effective* rendering root for this chat instance.
@@ -3157,6 +3671,7 @@ var SyntroChat = class extends LitElement {
3157
3671
  injectChatStyles(this._resolveStyleRoot());
3158
3672
  }
3159
3673
  updated() {
3674
+ this._attachScrollListener();
3160
3675
  this._injectCodeCopyButtons();
3161
3676
  }
3162
3677
  _injectCodeCopyButtons() {
@@ -3176,8 +3691,32 @@ var SyntroChat = class extends LitElement {
3176
3691
  block.appendChild(btn);
3177
3692
  }
3178
3693
  }
3694
+ _isAtBottom() {
3695
+ if (!this._scrollContainer) return true;
3696
+ const { scrollTop, scrollHeight, clientHeight } = this._scrollContainer;
3697
+ return scrollHeight - scrollTop - clientHeight <= _SyntroChat2._BOTTOM_EPSILON_PX;
3698
+ }
3699
+ /** Count messages whose bottom edge hasn't entered the viewport yet.
3700
+ * At the bottom this is 0 (pill hidden); it decrements one-by-one as the
3701
+ * visitor scrolls down past each message. */
3702
+ _countMessagesBelow() {
3703
+ const container = this._scrollContainer;
3704
+ if (!container) return 0;
3705
+ if (this._isAtBottom()) return 0;
3706
+ const viewportBottom = container.getBoundingClientRect().top + container.clientHeight;
3707
+ let count = 0;
3708
+ for (const row of this.querySelectorAll("[data-sc-msg-row]")) {
3709
+ if (row.getBoundingClientRect().bottom > viewportBottom + 4) count += 1;
3710
+ }
3711
+ return count > 0 ? count : 1;
3712
+ }
3713
+ /** Recompute `_messagesBelow` from live geometry. Called on scroll AND on
3714
+ * content growth (deltas grow scrollHeight without a scroll event). */
3715
+ _syncScrollState() {
3716
+ this._messagesBelow = this._isAtBottom() ? 0 : this._countMessagesBelow();
3717
+ }
3179
3718
  _scrollToBottom() {
3180
- if (this._userScrolledUp) return;
3719
+ if (!this._stickToBottom) return;
3181
3720
  requestAnimationFrame(() => {
3182
3721
  if (this._scrollContainer?.scrollTo) {
3183
3722
  this._scrollContainer.scrollTo({
@@ -3185,8 +3724,35 @@ var SyntroChat = class extends LitElement {
3185
3724
  behavior: "smooth"
3186
3725
  });
3187
3726
  }
3727
+ this._syncScrollState();
3188
3728
  });
3189
3729
  }
3730
+ /**
3731
+ * Anchor the view so the given (just-sent) user message sits at the TOP of
3732
+ * the scroll area: the visitor sees their message, then reads the reply
3733
+ * top-down instead of chasing an auto-scrolling tail.
3734
+ */
3735
+ _anchorToMessage(messageId) {
3736
+ this._stickToBottom = false;
3737
+ requestAnimationFrame(() => {
3738
+ const container = this._scrollContainer;
3739
+ if (!container?.scrollTo) return;
3740
+ const row = this.querySelector(`[data-sc-msg-id="${messageId}"]`);
3741
+ if (!row) return;
3742
+ const top = row.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
3743
+ container.scrollTo({ top, behavior: "smooth" });
3744
+ this._syncScrollState();
3745
+ });
3746
+ }
3747
+ /** Content grew (delta/typing/append while anchored): don't move the
3748
+ * view, but re-evaluate whether the pill should show. */
3749
+ _onContentGrowth() {
3750
+ if (this._stickToBottom) {
3751
+ this._scrollToBottom();
3752
+ return;
3753
+ }
3754
+ requestAnimationFrame(() => this._syncScrollState());
3755
+ }
3190
3756
  _appendUserMessage(text2) {
3191
3757
  const msg = {
3192
3758
  id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
@@ -3196,14 +3762,34 @@ var SyntroChat = class extends LitElement {
3196
3762
  status: "complete"
3197
3763
  };
3198
3764
  this._messages = [...this._messages, msg];
3199
- this._scrollToBottom();
3765
+ this._anchorToMessage(msg.id);
3766
+ }
3767
+ _isPendingClientTool(toolCallId) {
3768
+ for (const m2 of this._messages) {
3769
+ const tc = m2.toolCalls?.find((t) => t.id === toolCallId);
3770
+ if (tc) return tc.status === "pending" && !tc.interruptId;
3771
+ }
3772
+ return false;
3773
+ }
3774
+ _findInterruptId(toolCallId) {
3775
+ for (const m2 of this._messages) {
3776
+ const tc = m2.toolCalls?.find((t) => t.id === toolCallId);
3777
+ if (tc?.interruptId) return tc.interruptId;
3778
+ }
3779
+ return void 0;
3200
3780
  }
3201
3781
  _markToolDone(toolCallId) {
3782
+ this._markToolStatus(toolCallId, "done");
3783
+ }
3784
+ /** Deny paths mark 'cancelled' — the frozen card must READ as rejected
3785
+ * ("Rejected" label), never as the done-phase "Approved". */
3786
+ _markToolCancelled(toolCallId) {
3787
+ this._markToolStatus(toolCallId, "cancelled");
3788
+ }
3789
+ _markToolStatus(toolCallId, status) {
3202
3790
  this._messages = this._messages.map((m2) => {
3203
3791
  if (!m2.toolCalls) return m2;
3204
- const toolCalls = m2.toolCalls.map(
3205
- (tc) => tc.id === toolCallId ? { ...tc, status: "done" } : tc
3206
- );
3792
+ const toolCalls = m2.toolCalls.map((tc) => tc.id === toolCallId ? { ...tc, status } : tc);
3207
3793
  return { ...m2, toolCalls };
3208
3794
  });
3209
3795
  }
@@ -3216,6 +3802,7 @@ var SyntroChat = class extends LitElement {
3216
3802
  * duplicating logic.
3217
3803
  */
3218
3804
  sendUserMessage(text2) {
3805
+ if (this._micListening) this._dictation?.stop();
3219
3806
  const trimmed = text2.trim();
3220
3807
  if (!trimmed) return;
3221
3808
  this._appendUserMessage(trimmed);
@@ -3237,6 +3824,7 @@ var SyntroChat = class extends LitElement {
3237
3824
  isTyping: this._isTyping,
3238
3825
  toolLabels: this.toolLabels,
3239
3826
  toolRenderers: this.toolRenderers,
3827
+ toolContext: this.toolContext,
3240
3828
  defaultToolRenderer: this.defaultToolRenderer,
3241
3829
  onCopy: this._onCopy,
3242
3830
  onRegenerate: this._onRegenerate,
@@ -3246,10 +3834,7 @@ var SyntroChat = class extends LitElement {
3246
3834
  typingIndicatorSlot: this.slots?.typingIndicator
3247
3835
  })}
3248
3836
  </div>
3249
- ${this._userScrolledUp ? html7`<button data-sc-scroll-btn @click=${() => {
3250
- this._userScrolledUp = false;
3251
- this._scrollToBottom();
3252
- }} aria-label="Scroll to bottom"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>` : nothing5}
3837
+ ${this._messagesBelow > 0 ? html7`<button data-sc-scroll-btn @click=${this._resumeFollowing} aria-label="Scroll to most recent — ${this._messagesBelow} ${this._messagesBelow === 1 ? "message" : "messages"} below"><svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${this._messagesBelow} ${this._messagesBelow === 1 ? "message" : "messages"}</span></button>` : nothing5}
3253
3838
  ${this._errorMessage ? html7`<div data-sc-error role="alert">${this._errorMessage}</div>` : nothing5}
3254
3839
  ${renderChatInput({
3255
3840
  value: this._inputValue,
@@ -3265,37 +3850,161 @@ var SyntroChat = class extends LitElement {
3265
3850
  onSuggestion: this._onSuggestion,
3266
3851
  inputActionsSlot: this.slots?.inputActions,
3267
3852
  beforeInputSlot: this.slots?.beforeInput,
3268
- placeholder: this.placeholder
3853
+ // Listening is a mode the visitor must be able to see AND exit:
3854
+ // the placeholder doubles as the tap-to-stop instruction.
3855
+ placeholder: this._micListening ? "Listening \u2014 tap the mic to stop" : this.placeholder,
3856
+ showMic: this.voiceInput && isDictationSupported(),
3857
+ micListening: this._micListening,
3858
+ micDisabled: this._micDisabled,
3859
+ onMicClick: this._onMicClick
3269
3860
  })}
3270
3861
  </div>
3271
3862
  `;
3272
3863
  }
3273
3864
  };
3274
- SyntroChat.properties = {
3865
+ _SyntroChat.properties = {
3275
3866
  transport: { attribute: false },
3276
3867
  greeting: { type: String },
3277
3868
  suggestions: { attribute: false },
3278
3869
  toolLabels: { attribute: false },
3279
3870
  toolRenderers: { attribute: false },
3871
+ toolContext: { attribute: false },
3280
3872
  defaultToolRenderer: { attribute: false },
3281
3873
  autoExecTools: { attribute: false },
3282
3874
  placeholder: { type: String },
3283
3875
  slots: { attribute: false },
3876
+ voiceInput: { type: Boolean, attribute: "voice-input" },
3284
3877
  _messages: { state: true },
3878
+ _micListening: { state: true },
3879
+ _micDisabled: { state: true },
3285
3880
  _isTyping: { state: true },
3286
- _isConnected: { state: true },
3287
- _sessionId: { state: true },
3288
3881
  _inputValue: { state: true },
3289
3882
  _isComposing: { state: true },
3290
3883
  _isStreaming: { state: true },
3291
- _userScrolledUp: { state: true },
3884
+ _messagesBelow: { state: true },
3292
3885
  _errorMessage: { state: true }
3293
3886
  };
3887
+ _SyntroChat._BOTTOM_EPSILON_PX = 50;
3888
+ _SyntroChat._USER_SCROLL_INTENT_WINDOW_MS = 1200;
3889
+ var SyntroChat = _SyntroChat;
3890
+ var SyntroChatThreadList = class extends LitElement2 {
3891
+ constructor() {
3892
+ super(...arguments);
3893
+ this.threadsEndpoint = "";
3894
+ this.activeThreadId = null;
3895
+ this.newThreadLabel = "New thread";
3896
+ this.emptyStateLabel = "No threads yet";
3897
+ this._threads = [];
3898
+ this._loading = false;
3899
+ this._error = null;
3900
+ this._onThreadClick = (threadId) => {
3901
+ if (threadId === this.activeThreadId) return;
3902
+ this.dispatchEvent(
3903
+ new CustomEvent("thread-selected", {
3904
+ detail: { threadId },
3905
+ bubbles: true,
3906
+ composed: true
3907
+ })
3908
+ );
3909
+ };
3910
+ }
3911
+ createRenderRoot() {
3912
+ return this;
3913
+ }
3914
+ connectedCallback() {
3915
+ super.connectedCallback();
3916
+ if (this.threadsEndpoint) void this.refresh();
3917
+ }
3918
+ updated(changed) {
3919
+ if (changed.has("threadsEndpoint") && this.threadsEndpoint) {
3920
+ void this.refresh();
3921
+ }
3922
+ }
3923
+ /** Re-fetch the thread index. Call after creating/archiving a thread. */
3924
+ async refresh() {
3925
+ if (!this.threadsEndpoint) return;
3926
+ this._loading = true;
3927
+ this._error = null;
3928
+ try {
3929
+ const res = await fetch(this.threadsEndpoint, {
3930
+ method: "GET",
3931
+ headers: { ...this.authHeaders ?? {}, Accept: "application/json" }
3932
+ });
3933
+ if (!res.ok) {
3934
+ this._error = `Failed to load threads (${res.status})`;
3935
+ this._threads = [];
3936
+ return;
3937
+ }
3938
+ const data = await res.json();
3939
+ this._threads = (data.threads ?? []).filter((t) => t.status !== "archived");
3940
+ } catch (e) {
3941
+ this._error = e instanceof Error ? e.message : "Failed to load threads";
3942
+ this._threads = [];
3943
+ } finally {
3944
+ this._loading = false;
3945
+ }
3946
+ }
3947
+ /** Imperative: request the host to start a new thread.
3948
+ * Fires `new-thread-requested`. The host clears the chat element and
3949
+ * drops `activeThreadId` so the next user message goes to a fresh
3950
+ * backend thread.
3951
+ */
3952
+ createNewThread() {
3953
+ this.dispatchEvent(new CustomEvent("new-thread-requested", { bubbles: true, composed: true }));
3954
+ }
3955
+ render() {
3956
+ return html8`
3957
+ <div data-sc-thread-list>
3958
+ <button
3959
+ type="button"
3960
+ data-sc-new-thread-btn
3961
+ @click=${() => this.createNewThread()}
3962
+ aria-label=${this.newThreadLabel}
3963
+ >
3964
+ ${this.newThreadLabel}
3965
+ </button>
3966
+ ${this._loading ? html8`<div data-sc-thread-list-loading>Loading…</div>` : this._error ? html8`<div data-sc-thread-list-error>${this._error}</div>` : this._threads.length === 0 ? html8`<div data-sc-thread-list-empty>${this.emptyStateLabel}</div>` : html8`<ul data-sc-thread-list-items role="list">
3967
+ ${this._threads.map(
3968
+ (t) => html8`
3969
+ <li
3970
+ data-sc-thread-list-item
3971
+ ?data-sc-active=${t.remoteId === this.activeThreadId}
3972
+ >
3973
+ <button
3974
+ type="button"
3975
+ @click=${() => this._onThreadClick(t.remoteId)}
3976
+ title=${t.title ?? t.remoteId}
3977
+ >
3978
+ ${t.title || "Untitled thread"}
3979
+ </button>
3980
+ </li>
3981
+ `
3982
+ )}
3983
+ </ul>`}
3984
+ </div>
3985
+ `;
3986
+ }
3987
+ };
3988
+ SyntroChatThreadList.properties = {
3989
+ threadsEndpoint: { type: String, attribute: "threads-endpoint" },
3990
+ authHeaders: { attribute: false },
3991
+ activeThreadId: { type: String, attribute: "active-thread-id" },
3992
+ newThreadLabel: { type: String, attribute: "new-thread-label" },
3993
+ emptyStateLabel: { type: String, attribute: "empty-state-label" },
3994
+ _threads: { state: true },
3995
+ _loading: { state: true },
3996
+ _error: { state: true }
3997
+ };
3998
+ if (!customElements.get("syntro-chat-thread-list")) {
3999
+ customElements.define("syntro-chat-thread-list", SyntroChatThreadList);
4000
+ }
3294
4001
  if (!customElements.get("syntro-chat")) {
3295
4002
  customElements.define("syntro-chat", SyntroChat);
3296
4003
  }
3297
4004
 
3298
4005
  export {
4006
+ isDictationSupported,
4007
+ createDictation,
3299
4008
  renderMarkdown
3300
4009
  };
3301
4010
  /*! Bundled license information:
@@ -3303,4 +4012,4 @@ export {
3303
4012
  dompurify/dist/purify.es.mjs:
3304
4013
  (*! @license DOMPurify 3.3.1 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.3.1/LICENSE *)
3305
4014
  */
3306
- //# sourceMappingURL=chunk-UC4XU6GH.js.map
4015
+ //# sourceMappingURL=chunk-VZIUXXAM.js.map