@aparte/core 0.10.0 → 0.11.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 (82) hide show
  1. package/dist/client/aparte-client.d.ts +54 -33
  2. package/dist/client/aparte-client.d.ts.map +1 -1
  3. package/dist/client/stream-adapter.d.ts +6 -1
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts +25 -4
  6. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  7. package/dist/components/chat/aparte-chat.d.ts +8 -4
  8. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  9. package/dist/components/composer/aparte-composer-action.d.ts +5 -5
  10. package/dist/components/composer/aparte-composer-add-attachment.d.ts +5 -4
  11. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  12. package/dist/components/composer/aparte-composer-attachments.d.ts +5 -3
  13. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  14. package/dist/components/composer/aparte-composer-cancel.d.ts +2 -2
  15. package/dist/components/composer/aparte-composer-input.d.ts +8 -5
  16. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-send.d.ts +2 -2
  18. package/dist/components/composer/aparte-composer-toolbar.d.ts +3 -3
  19. package/dist/components/composer/aparte-composer.d.ts +54 -10
  20. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  21. package/dist/components/conversation-list/aparte-conversation-list.d.ts +19 -12
  22. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  23. package/dist/components/elicitation/aparte-elicitation.d.ts +12 -0
  24. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  25. package/dist/components/status/aparte-chat-status.d.ts +9 -0
  26. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  27. package/dist/components/viewport/aparte-chat-viewport.d.ts +15 -4
  28. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  29. package/dist/config/aparte-config.d.ts +20 -1
  30. package/dist/config/aparte-config.d.ts.map +1 -1
  31. package/dist/config/locale.d.ts +43 -0
  32. package/dist/config/locale.d.ts.map +1 -1
  33. package/dist/custom-elements.json +8517 -7601
  34. package/dist/elicitation/approval-panel.d.ts +42 -0
  35. package/dist/elicitation/approval-panel.d.ts.map +1 -0
  36. package/dist/elicitation/index.d.ts +4 -1
  37. package/dist/elicitation/index.d.ts.map +1 -1
  38. package/dist/elicitation/panel.d.ts +1 -1
  39. package/dist/elicitation/panel.d.ts.map +1 -1
  40. package/dist/elicitation/types.d.ts +116 -9
  41. package/dist/elicitation/types.d.ts.map +1 -1
  42. package/dist/generated/element-attributes.d.ts +173 -0
  43. package/dist/generated/element-attributes.d.ts.map +1 -0
  44. package/dist/index.css +615 -92
  45. package/dist/index.d.ts +3 -3
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +341 -254
  48. package/dist/index.js.map +1 -1
  49. package/dist/index.node.d.ts +3 -3
  50. package/dist/index.node.d.ts.map +1 -1
  51. package/dist/index.node.js +46 -44
  52. package/dist/index.node.js.map +1 -1
  53. package/dist/interop/element-props.d.ts +26 -1
  54. package/dist/interop/element-props.d.ts.map +1 -1
  55. package/dist/{index-BnKBciDG.js → is-awaiting-reply-Cb7H3YfN.js} +1448 -1544
  56. package/dist/is-awaiting-reply-Cb7H3YfN.js.map +1 -0
  57. package/dist/primitives/select/aparte-optgroup.d.ts +3 -0
  58. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  59. package/dist/primitives/select/aparte-option.d.ts +1 -0
  60. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  61. package/dist/primitives/select/aparte-select.d.ts +22 -18
  62. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  63. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  64. package/dist/renderers/segments/artifact/card.d.ts.map +1 -1
  65. package/dist/renderers/segments/pipeline-waiting.d.ts.map +1 -1
  66. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  67. package/dist/types/element-attributes.d.ts +42 -0
  68. package/dist/types/element-attributes.d.ts.map +1 -0
  69. package/dist/types/event-map.d.ts +1 -2
  70. package/dist/types/event-map.d.ts.map +1 -1
  71. package/dist/types/index.d.ts +3 -2
  72. package/dist/types/index.d.ts.map +1 -1
  73. package/dist/types/segments.d.ts +1 -8
  74. package/dist/types/segments.d.ts.map +1 -1
  75. package/dist/types/tools.d.ts +15 -21
  76. package/dist/types/tools.d.ts.map +1 -1
  77. package/dist/utils/segments.d.ts +8 -0
  78. package/dist/utils/segments.d.ts.map +1 -1
  79. package/package.json +3 -2
  80. package/dist/index-BnKBciDG.js.map +0 -1
  81. package/dist/renderers/segments/progress.d.ts +0 -3
  82. package/dist/renderers/segments/progress.d.ts.map +0 -1
@@ -75,6 +75,16 @@ const APARTE_DEFAULT_LOCALE = {
75
75
  elicitationYes: "Yes",
76
76
  elicitationNo: "No",
77
77
  elicitationAnswerLabel: "Your answer",
78
+ approvalInstructionPlaceholder: "Or tell it what to do instead…",
79
+ approvalAsk: "Run {tool}?",
80
+ approvalWaiting: "waiting for you",
81
+ toolRunning: "Running",
82
+ toolCompleted: "Done",
83
+ toolRejected: "Rejected",
84
+ toolStopped: "Stopped",
85
+ toolInput: "Input",
86
+ toolOutput: "Output",
87
+ approvalOptionsLabel: "Your decision",
78
88
  actionUpload: "Attach file",
79
89
  download: "Download",
80
90
  previewPending: "Press Preview to run this artifact.",
@@ -1024,6 +1034,23 @@ const defaultSanitizer = (html) => {
1024
1034
  sanitizeChildren(doc.body, container, doc);
1025
1035
  return container.innerHTML;
1026
1036
  };
1037
+ class AparteElicitationAbortError extends DOMException {
1038
+ /**
1039
+ * Which of the two ways it ended.
1040
+ *
1041
+ * `'no-presenter'` is a developer's setup problem and `'aborted'` is a person
1042
+ * stopping something; a host that reports them the same way reports a bug as a
1043
+ * user action. The distinction is why this carries a field at all.
1044
+ */
1045
+ reason;
1046
+ constructor(reason = "aborted") {
1047
+ super(
1048
+ reason === "no-presenter" ? "No elicitation presenter is mounted, so the request could not be shown." : "The request ended without an answer.",
1049
+ "AbortError"
1050
+ );
1051
+ this.reason = reason;
1052
+ }
1053
+ }
1027
1054
  function escapeHtml(value) {
1028
1055
  let out = "";
1029
1056
  for (let i = 0; i < value.length; i++) {
@@ -1050,6 +1077,8 @@ const APARTE_DEFAULT_HOST_HANDLERS = {
1050
1077
  artifactRedownload: false,
1051
1078
  artifactRehydrate: false
1052
1079
  };
1080
+ const NOOP = () => {
1081
+ };
1053
1082
  class AparteConfig {
1054
1083
  _markdownProvider;
1055
1084
  _streamingMarkdownProvider;
@@ -1106,6 +1135,23 @@ class AparteConfig {
1106
1135
  _elicitationPresenter;
1107
1136
  /** Warn once per config, not once per request — a tool loop can ask repeatedly. */
1108
1137
  _warnedNoPresenter = false;
1138
+ /**
1139
+ * The tail of the queue of requests waiting for the human, or `null` when none is.
1140
+ *
1141
+ * One request reaches the presenter at a time — not because a presenter could not
1142
+ * cope, but because the COMPOSER cannot: it has one panel slot, and a second
1143
+ * request used to clobber the first's DOM. The previous protection lived in the
1144
+ * default presenter, which answered the second request `cancel` on the spot; that
1145
+ * is a refusal invented for a question nobody was ever shown, the same class of
1146
+ * lie as telling the model a stopped turn was refused. WAITING is the honest
1147
+ * behaviour.
1148
+ *
1149
+ * Here rather than in the presenter because this is the only place that sees
1150
+ * EVERY request whatever raised it — a tool handler, an app's own button, and
1151
+ * (soon) the tool-approval gate. A custom presenter registered by a consumer gets
1152
+ * the same protection for free, which it previously had none of.
1153
+ */
1154
+ _elicitationQueue = null;
1109
1155
  // Tool Registry
1110
1156
  _tools = /* @__PURE__ */ new Map();
1111
1157
  _toolRenderers = /* @__PURE__ */ new Map();
@@ -1811,7 +1857,7 @@ class AparteConfig {
1811
1857
  *
1812
1858
  * @example
1813
1859
  * // Custom pill for a web-search tool
1814
- * aparteGlobalConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="tool-pill">Searching...</div>` });
1860
+ * aparteGlobalConfig.registerToolRenderer('web_search', { render: (seg) => `<div class="aparte-tool-label">Searching...</div>` });
1815
1861
  */
1816
1862
  registerToolRenderer(toolName, renderer) {
1817
1863
  this._toolRenderers.set(toolName, renderer);
@@ -1908,15 +1954,29 @@ class AparteConfig {
1908
1954
  * itself — nothing to register", which is how this failure mode was found.
1909
1955
  */
1910
1956
  requestUserInput(request) {
1957
+ const previous = this._elicitationQueue;
1958
+ const settled = previous === null ? this._present(request) : previous.then(
1959
+ () => this._present(request),
1960
+ () => this._present(request)
1961
+ );
1962
+ const mine = settled.then(NOOP, NOOP).then(() => {
1963
+ if (this._elicitationQueue === mine) this._elicitationQueue = null;
1964
+ });
1965
+ this._elicitationQueue = mine;
1966
+ return settled;
1967
+ }
1968
+ /** Hand ONE request to the presenter. Called only from the queue. */
1969
+ _present(request) {
1911
1970
  if (!this._elicitationPresenter) {
1912
1971
  if (!this._warnedNoPresenter) {
1913
1972
  this._warnedNoPresenter = true;
1914
1973
  console.warn(
1915
- "[aparte] requestUserInput() was called with no elicitation presenter, so it resolved `cancel` the model will read that as the user refusing, but nothing was ever shown. Add <aparte-elicitation></aparte-elicitation> inside your <aparte-chat>, or register your own by calling setElicitationPresenter() on the config this chat resolves — the scoped one if you passed a `config`, aparteGlobalConfig otherwise."
1974
+ "[aparte] requestUserInput() was called with no elicitation presenter, so it rejected with an AbortError nothing was ever shown to anybody. Add <aparte-elicitation></aparte-elicitation> inside your <aparte-chat>, or register your own by calling setElicitationPresenter() on the config this chat resolves — the scoped one if you passed a `config`, aparteGlobalConfig otherwise."
1916
1975
  );
1917
1976
  }
1918
- return Promise.resolve({ action: "cancel" });
1977
+ return Promise.reject(new AparteElicitationAbortError("no-presenter"));
1919
1978
  }
1979
+ if (request.signal?.aborted) return Promise.reject(new AparteElicitationAbortError("aborted"));
1920
1980
  return this._elicitationPresenter(request);
1921
1981
  }
1922
1982
  /**
@@ -1944,6 +2004,7 @@ class AparteConfig {
1944
2004
  this._keyProvider = void 0;
1945
2005
  this._conversationManager = void 0;
1946
2006
  this._elicitationPresenter = void 0;
2007
+ this._elicitationQueue = null;
1947
2008
  this._locale = APARTE_DEFAULT_LOCALE;
1948
2009
  this._actions = [];
1949
2010
  this._sanitizer = defaultSanitizer;
@@ -2094,9 +2155,9 @@ function highlightMarkdownFences(contentEl) {
2094
2155
  }
2095
2156
  const textRenderer = {
2096
2157
  type: "text",
2097
- render: (segment) => `<div class="segment segment-text" data-segment-id="${escapeHtml(segment.id)}"><div class="segment-content">${contextConfig().renderMarkdown(segment.content)}</div></div>`,
2158
+ render: (segment) => `<div class="aparte-segment aparte-segment-text" data-segment-id="${escapeHtml(segment.id)}"><div class="aparte-segment-content">${contextConfig().renderMarkdown(segment.content)}</div></div>`,
2098
2159
  update: (el2, segment) => {
2099
- const contentEl = el2.querySelector(".segment-content");
2160
+ const contentEl = el2.querySelector(".aparte-segment-content");
2100
2161
  if (!contentEl) return;
2101
2162
  writeStreamedMarkdown(
2102
2163
  el2,
@@ -2124,17 +2185,17 @@ const thinkingRenderer = {
2124
2185
  * new class of risk. With no Markdown provider registered it degrades to the
2125
2186
  * zero-dependency default (escape + `<br>`), which is what it used to be.
2126
2187
  */
2127
- render: (segment) => `<details class="segment segment-thinking" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="thinking-header"><span class="thinking-label">${escapeHtml(segment.label || contextConfig().t("thinking"))}</span><span class="thinking-toggle"></span></summary><div class="thinking-content">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
2188
+ render: (segment) => `<details class="aparte-segment aparte-segment-thinking" data-segment-id="${escapeHtml(segment.id)}" ${segment.collapsed === false ? "open" : ""}><summary class="aparte-thinking-header"><span class="aparte-thinking-label">${escapeHtml(segment.label || contextConfig().t("thinking"))}</span><span class="aparte-thinking-toggle"></span></summary><div class="aparte-thinking-content">${contextConfig().renderMarkdown(segment.content)}</div></details>`,
2128
2189
  /**
2129
2190
  * The default label is the only config-derived text here — and `segment.label`
2130
2191
  * still wins, exactly as in `render`, because that string is the app's.
2131
2192
  */
2132
2193
  relabel: (el2, segment) => {
2133
- const label = el2.querySelector(".thinking-label");
2194
+ const label = el2.querySelector(".aparte-thinking-label");
2134
2195
  if (label) label.textContent = segment.label || contextConfig().t("thinking");
2135
2196
  },
2136
2197
  update: (el2, segment) => {
2137
- const contentEl = el2.querySelector(".thinking-content");
2198
+ const contentEl = el2.querySelector(".aparte-thinking-content");
2138
2199
  if (!contentEl) return;
2139
2200
  const wasAtBottom = contentEl.scrollHeight - contentEl.scrollTop - contentEl.clientHeight <= THINKING_ANCHOR_SLACK;
2140
2201
  const streaming = segment.isStreaming !== false;
@@ -2194,38 +2255,38 @@ function streamHighlight(element, paneSelector, content, lang, segId) {
2194
2255
  const codeRenderer = {
2195
2256
  type: "code",
2196
2257
  render: (segment) => `
2197
- <div class="segment segment-code" data-segment-id="${escapeHtml(segment.id)}">
2198
- <div class="code-header">
2199
- ${segment.filename ? `<span class="code-filename">${escapeHtml(segment.filename)}</span>` : `<span class="code-header-filler"></span>`}
2200
- <span class="code-language">${escapeHtml(segment.language || "")}</span>
2201
- <button class="code-copy" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}">
2258
+ <div class="aparte-segment aparte-segment-code" data-segment-id="${escapeHtml(segment.id)}">
2259
+ <div class="aparte-code-header">
2260
+ ${segment.filename ? `<span class="aparte-code-filename">${escapeHtml(segment.filename)}</span>` : `<span class="aparte-code-header-filler"></span>`}
2261
+ <span class="aparte-code-language">${escapeHtml(segment.language || "")}</span>
2262
+ <button class="aparte-code-copy" data-action="copy" title="${escapeAttr(contextConfig().t("copy"))}">
2202
2263
  ${contextConfig().getIcon("copy")}
2203
2264
  </button>
2204
2265
  </div>
2205
- <div class="code-content-wrapper">
2266
+ <div class="aparte-code-content-wrapper">
2206
2267
  <pre><code class="language-${escapeHtml(segment.language || "text")}">${escapeHtml(segment.content)}</code></pre>
2207
2268
  </div>
2208
2269
  </div>
2209
2270
  `,
2210
2271
  /** The copy button's icon and tooltip — nothing else here comes from config. */
2211
2272
  relabel: (element) => {
2212
- const copyBtn = element.querySelector(".code-copy");
2273
+ const copyBtn = element.querySelector(".aparte-code-copy");
2213
2274
  if (!copyBtn || copyBtn.dataset.copied) return;
2214
2275
  copyBtn.setAttribute("title", contextConfig().t("copy"));
2215
2276
  copyBtn.innerHTML = contextConfig().getIcon("copy");
2216
2277
  },
2217
2278
  setup: (element, segment) => {
2218
- const wrapper = element.querySelector(".code-content-wrapper");
2279
+ const wrapper = element.querySelector(".aparte-code-content-wrapper");
2219
2280
  if (wrapper) {
2220
2281
  void contextConfig().highlightCode(segment.content, segment.language || "").then((html) => {
2221
2282
  wrapper.innerHTML = html;
2222
2283
  }).catch(() => {
2223
2284
  });
2224
2285
  }
2225
- const copyBtn = element.querySelector(".code-copy");
2286
+ const copyBtn = element.querySelector(".aparte-code-copy");
2226
2287
  if (copyBtn) {
2227
2288
  copyBtn.addEventListener("click", () => {
2228
- const source = element.querySelector(".code-content-wrapper")?.textContent ?? "";
2289
+ const source = element.querySelector(".aparte-code-content-wrapper")?.textContent ?? "";
2229
2290
  void navigator.clipboard.writeText(source).catch(() => {
2230
2291
  });
2231
2292
  copyBtn.innerHTML = contextConfig(copyBtn).getIcon("check");
@@ -2241,9 +2302,9 @@ const codeRenderer = {
2241
2302
  },
2242
2303
  update: (element, segment) => {
2243
2304
  if (segment.isStreaming) {
2244
- streamHighlight(element, ".code-content-wrapper", segment.content, segment.language || "", segment.id);
2305
+ streamHighlight(element, ".aparte-code-content-wrapper", segment.content, segment.language || "", segment.id);
2245
2306
  } else {
2246
- const wrapper = element.querySelector(".code-content-wrapper");
2307
+ const wrapper = element.querySelector(".aparte-code-content-wrapper");
2247
2308
  if (wrapper) {
2248
2309
  void contextConfig().highlightCode(segment.content, segment.language || "").then((html) => {
2249
2310
  wrapper.innerHTML = html;
@@ -2267,14 +2328,14 @@ const errorRenderer = {
2267
2328
  return out;
2268
2329
  }
2269
2330
  return `
2270
- <div class="segment segment-error" data-segment-id="${escapeHtml(segment.id)}">
2271
- <div class="error-icon-wrapper">
2331
+ <div class="aparte-segment aparte-segment-error" data-segment-id="${escapeHtml(segment.id)}">
2332
+ <div class="aparte-error-icon-wrapper">
2272
2333
  ${contextConfig().getIcon("error") || "⚠"}
2273
2334
  </div>
2274
- <div class="error-content">
2275
- <div class="error-title">${escapeHtml(contextConfig().t("error"))}</div>
2276
- <div class="error-message">${escapeHtml(segment.content)}</div>
2277
- ${segment.details ? `<div class="error-details">${escapeHtml(segment.details)}</div>` : ""}
2335
+ <div class="aparte-error-content">
2336
+ <div class="aparte-error-title">${escapeHtml(contextConfig().t("error"))}</div>
2337
+ <div class="aparte-error-message">${escapeHtml(segment.content)}</div>
2338
+ ${segment.details ? `<div class="aparte-error-details">${escapeHtml(segment.details)}</div>` : ""}
2278
2339
  </div>
2279
2340
  </div>
2280
2341
  `;
@@ -2289,171 +2350,204 @@ const errorRenderer = {
2289
2350
  * language it arrived in. Relabelling it would be inventing content.
2290
2351
  */
2291
2352
  relabel: (element) => {
2292
- const wrap = element.querySelector(".error-icon-wrapper");
2353
+ const wrap = element.querySelector(".aparte-error-icon-wrapper");
2293
2354
  if (wrap) wrap.innerHTML = contextConfig().getIcon("error") || "⚠";
2294
- const title = element.querySelector(".error-title");
2355
+ const title = element.querySelector(".aparte-error-title");
2295
2356
  if (title) title.textContent = contextConfig().t("error");
2296
2357
  },
2297
2358
  getStyles: () => ``
2298
2359
  };
2299
- const progressRenderer = {
2300
- type: "progress",
2301
- render: (segment) => {
2302
- const label = escapeHtml(segment.label || "Progress");
2303
- const pct = Math.round(segment.percent || 0);
2304
- return `<div class="segment segment-progress" data-segment-id="${escapeHtml(segment.id)}"><div class="progress-header"><span class="progress-label">${label}</span><span class="progress-value">${pct}%</span></div><div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="${label}"><div class="progress-fill" style="width: ${pct}%"></div></div></div>`;
2305
- },
2306
- getStyles: () => ``
2307
- };
2360
+ function describeInput(segment) {
2361
+ const input = segment.toolCall?.input;
2362
+ if (!input || typeof input !== "object" || Object.keys(input).length === 0) return "";
2363
+ try {
2364
+ return JSON.stringify(input, null, 2);
2365
+ } catch {
2366
+ return "";
2367
+ }
2368
+ }
2369
+ function stateBadge(segment) {
2370
+ const cfg = contextConfig();
2371
+ const status = segment.status ?? "pending";
2372
+ const word = (key) => escapeHtml(cfg.t(key));
2373
+ if (status === "awaiting-approval") return word("approvalWaiting");
2374
+ if (status === "pending") return word("toolRunning");
2375
+ if (status === "resolved") return `${cfg.getIcon("check")}${word("toolCompleted")}`;
2376
+ if (status === "rejected") return `${cfg.getIcon("close")}${word("toolRejected")}`;
2377
+ if (status === "aborted") return `${cfg.getIcon("close")}${word("toolStopped")}`;
2378
+ return "";
2379
+ }
2380
+ function setPart(element, part, text) {
2381
+ const existing = element.querySelector(`[data-part="${part}"]`);
2382
+ if (!text) {
2383
+ existing?.remove();
2384
+ return;
2385
+ }
2386
+ if (existing) {
2387
+ const code2 = existing.querySelector("code");
2388
+ if (code2 && code2.textContent !== text) code2.textContent = text;
2389
+ return;
2390
+ }
2391
+ const detail = element.querySelector(".aparte-tool-detail");
2392
+ if (!detail) return;
2393
+ const cfg = contextConfig();
2394
+ const wrap = document.createElement("div");
2395
+ wrap.className = "aparte-tool-part";
2396
+ wrap.dataset["part"] = part;
2397
+ const label = document.createElement("span");
2398
+ label.className = "aparte-tool-part-label";
2399
+ label.textContent = cfg.t(part === "input" ? "toolInput" : "toolOutput");
2400
+ const body = document.createElement("div");
2401
+ body.className = "aparte-tool-part-body";
2402
+ const pre = document.createElement("pre");
2403
+ const code = document.createElement("code");
2404
+ code.textContent = text;
2405
+ pre.appendChild(code);
2406
+ body.appendChild(pre);
2407
+ wrap.append(label, body);
2408
+ if (part === "input") detail.prepend(wrap);
2409
+ else detail.appendChild(wrap);
2410
+ }
2411
+ function highlightParts(element) {
2412
+ const cfg = contextConfig();
2413
+ for (const body of element.querySelectorAll(".aparte-tool-part-body")) {
2414
+ const text = body.querySelector("code")?.textContent ?? "";
2415
+ if (!text) continue;
2416
+ void cfg.highlightCode(text, "json").then((html) => {
2417
+ if ((body.querySelector("code")?.textContent ?? "") !== text) return;
2418
+ body.innerHTML = html;
2419
+ }).catch(() => {
2420
+ });
2421
+ }
2422
+ }
2423
+ function pillMarkup(segment, name, status) {
2424
+ const badge = stateBadge(segment);
2425
+ return `
2426
+ <span class="aparte-tool-label">
2427
+ <span class="aparte-tool-icon">${contextConfig().getIcon("tool")}</span>
2428
+ <span class="aparte-tool-name">${escapeHtml(name)}</span>
2429
+ </span>
2430
+ <span class="aparte-tool-spinner" aria-hidden="true"${status === "pending" ? "" : " hidden"}></span>
2431
+ <span class="aparte-tool-state">${badge}</span>`;
2432
+ }
2308
2433
  const toolCallRenderer = {
2309
2434
  type: "tool_call",
2310
2435
  render: (segment) => {
2311
2436
  const name = segment.toolCall?.name ?? "tool";
2312
2437
  const status = segment.status ?? "pending";
2313
2438
  const toolCallId = segment.toolCall?.id ?? "";
2314
- if (status === "awaiting-approval") {
2315
- const loc = contextConfig().getLocale();
2316
- const approve = loc.approveTool ?? "Approve";
2317
- const reject = loc.rejectTool ?? "Reject";
2318
- return `
2319
- <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="awaiting-approval" data-tool-call-id="${escapeAttr(toolCallId)}">
2320
- <span class="tool-pill">
2321
- <span class="tool-pill-icon">${contextConfig().getIcon("tool")}</span>
2322
- <span class="tool-pill-name">${escapeHtml(name)}</span>
2323
- </span>
2324
- <span class="tool-approval" role="group" aria-label="${escapeAttr(name)}">
2325
- <button type="button" class="tool-approve-btn" data-tool-decision="approve" aria-label="${escapeAttr(approve)}">${escapeHtml(approve)}</button>
2326
- <button type="button" class="tool-reject-btn" data-tool-decision="reject" aria-label="${escapeAttr(reject)}">${escapeHtml(reject)}</button>
2327
- </span>
2328
- </div>
2329
- `;
2330
- }
2331
2439
  const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
2332
2440
  if (customRenderer) {
2333
2441
  const out = customRenderer.render(segment);
2334
2442
  if (out) return out;
2335
2443
  }
2336
- const statusIcon = status === "resolved" ? contextConfig().getIcon("check") : status === "aborted" || status === "rejected" ? contextConfig().getIcon("close") : "";
2337
- const spinner = status === "pending" ? `<span class="tool-pill-spinner" aria-hidden="true"></span>` : "";
2338
- return `
2339
- <div class="segment segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}">
2340
- <span class="tool-pill">
2341
- <span class="tool-pill-icon">${contextConfig().getIcon("tool")}</span>
2342
- <span class="tool-pill-name">${escapeHtml(name)}</span>
2343
- ${spinner}
2344
- ${statusIcon ? `<span class="tool-pill-status">${statusIcon}</span>` : ""}
2345
- </span>
2444
+ const pill = pillMarkup(segment, name, status);
2445
+ const input = describeInput(segment);
2446
+ const result = segment.result;
2447
+ if (!input && !result) {
2448
+ return `
2449
+ <div class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}">${pill}
2346
2450
  </div>
2347
2451
  `;
2452
+ }
2453
+ const cfg = contextConfig();
2454
+ return `
2455
+ <details class="aparte-segment aparte-segment-tool-call" data-segment-id="${escapeHtml(segment.id)}" data-status="${escapeAttr(status)}" data-tool-call-id="${escapeAttr(toolCallId)}">
2456
+ <summary class="aparte-tool-summary"><span class="aparte-tool-toggle"></span>${pill}</summary>
2457
+ <div class="aparte-tool-detail">
2458
+ ${input ? `
2459
+ <div class="aparte-tool-part" data-part="input">
2460
+ <span class="aparte-tool-part-label">${escapeHtml(cfg.t("toolInput"))}</span>
2461
+ <div class="aparte-tool-part-body"><pre><code>${escapeHtml(input)}</code></pre></div>
2462
+ </div>` : ""}
2463
+ ${result ? `
2464
+ <div class="aparte-tool-part" data-part="output">
2465
+ <span class="aparte-tool-part-label">${escapeHtml(cfg.t("toolOutput"))}</span>
2466
+ <div class="aparte-tool-part-body"><pre><code>${escapeHtml(result)}</code></pre></div>
2467
+ </div>` : ""}
2468
+ </div>
2469
+ </details>
2470
+ `;
2348
2471
  },
2349
2472
  /**
2350
- * The pill's glyphs and — when the turn is paused for a person the two labels
2351
- * on the approval gate. Those are the highest-stakes strings in the library: a
2352
- * reader deciding whether to let a tool run should not be reading them in a
2353
- * language they did not choose.
2473
+ * The pill's glyphs, and the label it wears while a person is deciding.
2474
+ *
2475
+ * The two highest-stakes strings in the library the approval labels are no
2476
+ * longer here: they are on the panel, which relabels itself. This still matters
2477
+ * though, because the request the pill describes stays open for as long as
2478
+ * somebody takes to decide, which is exactly long enough for a language switch.
2354
2479
  *
2355
- * No child node is added or removed, so the buttons keep their listeners and,
2356
- * critically, their FOCUS — this is the one control a keyboard user may be
2357
- * sitting on when a config change fires.
2480
+ * No child node is added or removed, per the relabel contract.
2358
2481
  */
2359
2482
  relabel: (element, segment) => {
2360
2483
  const cfg = contextConfig();
2361
- const icon = element.querySelector(".tool-pill-icon");
2484
+ const icon = element.querySelector(".aparte-tool-icon");
2362
2485
  if (icon) icon.innerHTML = cfg.getIcon("tool");
2363
- const statusEl = element.querySelector(".tool-pill-status");
2486
+ const statusEl = element.querySelector(".aparte-tool-state");
2364
2487
  if (statusEl) {
2365
2488
  const s = segment.status;
2366
2489
  statusEl.innerHTML = s === "resolved" ? cfg.getIcon("check") : s === "aborted" || s === "rejected" ? cfg.getIcon("close") : "";
2367
2490
  }
2368
- const loc = cfg.getLocale();
2369
- const approve = element.querySelector(".tool-approve-btn");
2370
- if (approve) {
2371
- const label = loc.approveTool ?? "Approve";
2372
- approve.textContent = label;
2373
- approve.setAttribute("aria-label", label);
2374
- }
2375
- const reject = element.querySelector(".tool-reject-btn");
2376
- if (reject) {
2377
- const label = loc.rejectTool ?? "Reject";
2378
- reject.textContent = label;
2379
- reject.setAttribute("aria-label", label);
2491
+ if (statusEl && segment.status === "awaiting-approval") {
2492
+ statusEl.textContent = cfg.t("approvalWaiting");
2380
2493
  }
2381
2494
  },
2382
- setup: (element, segment) => {
2383
- if (segment.status === "awaiting-approval") {
2384
- const toolCallId = segment.toolCall?.id;
2385
- if (!toolCallId) return;
2386
- const host = element.closest("[data-aparte-host], aparte-chat, aparte-chat-viewport");
2387
- const decide = (approved) => element.dispatchEvent(new CustomEvent("aparte-tool-decision", {
2388
- bubbles: true,
2389
- composed: true,
2390
- detail: { toolCallId, approved, targetId: host?.id || void 0 }
2391
- }));
2392
- element.querySelector('[data-tool-decision="approve"]')?.addEventListener("click", () => decide(true));
2393
- element.querySelector('[data-tool-decision="reject"]')?.addEventListener("click", () => decide(false));
2495
+ /**
2496
+ * Patch in place. NEVER re-render, and never touch `open`.
2497
+ *
2498
+ * Without an `update` the bubble replaces the element wholesale — and a tool call
2499
+ * changes status several times per turn, so a disclosure the reader opened would
2500
+ * slam shut under them every time. The same rule the reasoning block has, for the
2501
+ * same reason: `collapsed` is honoured only when an update explicitly carries it.
2502
+ *
2503
+ * The one case that cannot be patched is a change of ELEMENT: a call that had
2504
+ * neither arguments nor a result rendered as a plain pill, and gaining a result
2505
+ * makes it a `<details>`. That falls back to a rebuild, which costs nothing because
2506
+ * there was no disclosure to lose.
2507
+ */
2508
+ update: (element, segment) => {
2509
+ const status = segment.status ?? "pending";
2510
+ const input = describeInput(segment);
2511
+ const wantsDetail = !!input || !!segment.result;
2512
+ const custom = contextConfig().getToolRenderer(segment.toolCall?.name);
2513
+ if (custom || wantsDetail !== (element.tagName === "DETAILS")) {
2514
+ const out = toolCallRenderer.render(segment);
2515
+ const tpl = document.createElement("template");
2516
+ tpl.innerHTML = typeof out === "string" ? out.trim() : "";
2517
+ const rebuilt = typeof out === "string" ? tpl.content.firstElementChild : out;
2518
+ if (rebuilt) {
2519
+ element.replaceWith(rebuilt);
2520
+ toolCallRenderer.setup?.(rebuilt, segment);
2521
+ }
2394
2522
  return;
2395
2523
  }
2524
+ element.setAttribute("data-status", status);
2525
+ const badge = element.querySelector(".aparte-tool-state");
2526
+ if (badge) badge.innerHTML = stateBadge(segment);
2527
+ element.querySelector(".aparte-tool-spinner")?.toggleAttribute("hidden", status !== "pending");
2528
+ setPart(element, "input", input);
2529
+ setPart(element, "output", segment.result ?? "");
2530
+ highlightParts(element);
2531
+ },
2532
+ setup: (element, segment) => {
2533
+ highlightParts(element);
2396
2534
  const customRenderer = contextConfig().getToolRenderer(segment.toolCall?.name);
2397
2535
  customRenderer?.setup?.(element, segment);
2398
2536
  },
2399
- getStyles: () => `
2400
- .segment-tool-call { display: flex; padding: 2px 0; }
2401
- .tool-pill {
2402
- display: inline-flex;
2403
- align-items: center;
2404
- gap: 5px;
2405
- padding: 3px 10px 3px 7px;
2406
- border-radius: 99px;
2407
- font-size: 0.78rem;
2408
- font-weight: 500;
2409
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2410
- background: var(--aparte-surface, #f8f8f8);
2411
- color: var(--aparte-text-secondary, rgba(0,0,0,0.55));
2412
- user-select: none;
2413
- }
2414
- [data-status="resolved"] .tool-pill {
2415
- border-color: var(--aparte-success-border, rgba(34,197,94,0.3));
2416
- background: var(--aparte-success-surface, rgba(34,197,94,0.06));
2417
- color: var(--aparte-success, rgb(21,128,61));
2418
- }
2419
- [data-status="aborted"] .tool-pill,
2420
- [data-status="rejected"] .tool-pill {
2421
- border-color: var(--aparte-error-border, rgba(239,68,68,0.3));
2422
- background: var(--aparte-error-surface, rgba(239,68,68,0.06));
2423
- color: var(--aparte-error, rgb(185,28,28));
2424
- }
2425
- .tool-approval { display: inline-flex; gap: 6px; margin-left: 8px; vertical-align: middle; }
2426
- .tool-approve-btn, .tool-reject-btn {
2427
- font: inherit; font-size: 0.78rem; font-weight: 600; line-height: 1;
2428
- padding: 4px 12px; border-radius: 99px; cursor: pointer;
2429
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
2430
- background: var(--aparte-surface, #f8f8f8);
2431
- }
2432
- .tool-approve-btn { color: var(--aparte-success, rgb(21,128,61)); border-color: var(--aparte-success-border, rgba(34,197,94,0.4)); }
2433
- .tool-reject-btn { color: var(--aparte-error, rgb(185,28,28)); border-color: var(--aparte-error-border, rgba(239,68,68,0.4)); }
2434
- .tool-approve-btn:hover { background: var(--aparte-success-surface, rgba(34,197,94,0.1)); }
2435
- .tool-reject-btn:hover { background: var(--aparte-error-surface, rgba(239,68,68,0.1)); }
2436
- .tool-pill-spinner {
2437
- width: 10px; height: 10px;
2438
- border: 1.5px solid currentColor;
2439
- border-top-color: transparent;
2440
- border-radius: 50%;
2441
- display: inline-block;
2442
- animation: tool-spin 0.7s linear infinite;
2443
- }
2444
- .tool-pill-status { font-size: 0.75rem; }
2445
- @keyframes tool-spin { to { transform: rotate(360deg); } }
2446
- `
2537
+ // CSS lives in styles/aparte.css — see its "Segment renderers" section for why a
2538
+ // built-in's rules belong there. `getStyles` stays for a CONSUMER's renderer, which
2539
+ // has no other way onto the page.
2540
+ getStyles: () => ""
2447
2541
  };
2448
2542
  const waitingLabel = () => contextConfig().t("generating");
2449
2543
  const pipelineWaitingRenderer = {
2450
2544
  type: "pipeline-waiting",
2451
2545
  render: (segment) => {
2452
2546
  return `
2453
- <div class="segment segment-pipeline-waiting" data-segment-id="${escapeHtml(segment.id)}" aria-label="${escapeAttr(waitingLabel())}" role="status">
2454
- <span class="pw-dot"></span>
2455
- <span class="pw-dot"></span>
2456
- <span class="pw-dot"></span>
2547
+ <div class="aparte-segment aparte-segment-pipeline-waiting" data-segment-id="${escapeHtml(segment.id)}" aria-label="${escapeAttr(waitingLabel())}" role="status">
2548
+ <span class="aparte-pw-dot"></span>
2549
+ <span class="aparte-pw-dot"></span>
2550
+ <span class="aparte-pw-dot"></span>
2457
2551
  </div>`;
2458
2552
  },
2459
2553
  update: () => {
@@ -2478,30 +2572,10 @@ const pipelineWaitingRenderer = {
2478
2572
  });
2479
2573
  observer.observe(parent, { childList: true });
2480
2574
  },
2481
- getStyles: () => `
2482
- .segment-pipeline-waiting {
2483
- display: flex;
2484
- align-items: center;
2485
- gap: 5px;
2486
- padding: 6px 2px;
2487
- min-height: 28px;
2488
- }
2489
- .pw-dot {
2490
- display: inline-block;
2491
- width: 8px;
2492
- height: 8px;
2493
- border-radius: 50%;
2494
- background: var(--aparte-text-muted, #6b7280);
2495
- opacity: 0.3;
2496
- animation: pw-pulse 1.2s ease-in-out infinite;
2497
- }
2498
- .pw-dot:nth-child(2) { animation-delay: 0.2s; }
2499
- .pw-dot:nth-child(3) { animation-delay: 0.4s; }
2500
- @keyframes pw-pulse {
2501
- 0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
2502
- 40% { opacity: 1; transform: scale(1.1); }
2503
- }
2504
- `
2575
+ // CSS lives in styles/aparte.css — see its "Segment renderers" section for why a
2576
+ // built-in's rules belong there. `getStyles` stays for a CONSUMER's renderer, which
2577
+ // has no other way onto the page.
2578
+ getStyles: () => ""
2505
2579
  };
2506
2580
  function stripCodeFences(content) {
2507
2581
  let s = content;
@@ -2704,7 +2778,7 @@ function renderBinaryFileArtifact(segment, kind) {
2704
2778
  if (cached && !isStreaming) {
2705
2779
  const previewBody = cached.previewHtml ? contextConfig().sanitizeHtml(cached.previewHtml) : `<div class="aparte-art-file__preview-empty">Preview not available for ${escapeHtml(kind)} yet</div>`;
2706
2780
  return `
2707
- <div class="segment segment-artifact-file"
2781
+ <div class="aparte-segment aparte-segment-artifact-file"
2708
2782
  data-segment-id="${escapeHtml(segment.id)}"
2709
2783
  data-artifact-type="${escapeHtml(kind)}"
2710
2784
  data-state="ready">
@@ -2730,7 +2804,7 @@ function renderBinaryFileArtifact(segment, kind) {
2730
2804
  const cleanContent = stripCodeFences(segment.content || "");
2731
2805
  const subText = isStreaming ? contextConfig().t("generating") : contextConfig().t("rebuildingPreview");
2732
2806
  return `
2733
- <div class="segment segment-artifact-file"
2807
+ <div class="aparte-segment aparte-segment-artifact-file"
2734
2808
  data-segment-id="${escapeHtml(segment.id)}"
2735
2809
  data-artifact-type="${escapeHtml(kind)}"
2736
2810
  data-state="${isStreaming ? "streaming" : "compiling"}">
@@ -2925,7 +2999,7 @@ const artifactRenderer = {
2925
2999
  const isBinary = BINARY_FILE_KINDS.has(kind);
2926
3000
  const cleanContent = stripCodeFences(segment.content || "");
2927
3001
  return `
2928
- <div class="segment segment-artifact-card"
3002
+ <div class="aparte-segment aparte-segment-artifact-card"
2929
3003
  data-segment-id="${escapeHtml(segment.id)}"
2930
3004
  data-artifact-type="${escapeHtml(kind)}"
2931
3005
  data-streaming="${isStreaming ? "true" : "false"}"
@@ -2953,7 +3027,7 @@ const artifactRenderer = {
2953
3027
  </nav>
2954
3028
  <div class="aparte-art-card__body">
2955
3029
  <div class="aparte-art-card__pane" data-pane="code">
2956
- <div class="code-content-wrapper">
3030
+ <div class="aparte-code-content-wrapper">
2957
3031
  <pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>
2958
3032
  </div>
2959
3033
  </div>
@@ -3004,7 +3078,7 @@ const artifactRenderer = {
3004
3078
  setupBinaryFileArtifact(element, segment, kind);
3005
3079
  return;
3006
3080
  }
3007
- const wrapper = element.querySelector(".code-content-wrapper");
3081
+ const wrapper = element.querySelector(".aparte-code-content-wrapper");
3008
3082
  if (wrapper) {
3009
3083
  const displayLang = languageForKind(kind);
3010
3084
  const cleanContent = stripCodeFences(segment.content || "");
@@ -3079,13 +3153,13 @@ const artifactRenderer = {
3079
3153
  const cleanContent = stripCodeFences(segment.content || "");
3080
3154
  if (isStreaming) {
3081
3155
  const segId = element.getAttribute("data-segment-id") ?? segment.id;
3082
- streamHighlight(element, ".code-content-wrapper", cleanContent, languageForKind(kind), segId);
3156
+ streamHighlight(element, ".aparte-code-content-wrapper", cleanContent, languageForKind(kind), segId);
3083
3157
  } else {
3084
- const codeEl = element.querySelector(".code-content-wrapper code");
3158
+ const codeEl = element.querySelector(".aparte-code-content-wrapper code");
3085
3159
  if (codeEl) {
3086
3160
  codeEl.textContent = cleanContent;
3087
3161
  } else {
3088
- const wrapper = element.querySelector(".code-content-wrapper");
3162
+ const wrapper = element.querySelector(".aparte-code-content-wrapper");
3089
3163
  if (wrapper) {
3090
3164
  const displayLang = languageForKind(kind);
3091
3165
  wrapper.innerHTML = `<pre><code class="language-${escapeHtml(displayLang)}">${escapeHtml(cleanContent)}</code></pre>`;
@@ -3095,7 +3169,7 @@ const artifactRenderer = {
3095
3169
  if (wasStreaming && !isStreaming) {
3096
3170
  element.setAttribute("data-streaming", "false");
3097
3171
  element.querySelector(".aparte-art-card__pulse")?.remove();
3098
- const wrapper = element.querySelector(".code-content-wrapper");
3172
+ const wrapper = element.querySelector(".aparte-code-content-wrapper");
3099
3173
  if (wrapper) {
3100
3174
  const displayLang = languageForKind(kind);
3101
3175
  void contextConfig().highlightCode(cleanContent, displayLang).then((html) => {
@@ -3108,296 +3182,10 @@ const artifactRenderer = {
3108
3182
  });
3109
3183
  }
3110
3184
  },
3111
- getStyles: () => `
3112
- .segment-artifact-card {
3113
- display: flex; flex-direction: column;
3114
- margin: 8px 0;
3115
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3116
- border-radius: 12px;
3117
- background: var(--aparte-surface, #fff);
3118
- overflow: hidden;
3119
- font: inherit;
3120
- color: var(--aparte-text, inherit);
3121
- }
3122
- .aparte-art-card__header {
3123
- display: flex; align-items: center; justify-content: space-between;
3124
- padding: 8px 10px;
3125
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3126
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3127
- min-height: 36px;
3128
- }
3129
- .aparte-art-card__title-block { display: flex; align-items: center; gap: 8px; min-width: 0; }
3130
- .aparte-art-card__kind {
3131
- font-size: 0.7rem; font-weight: 600;
3132
- text-transform: uppercase; letter-spacing: 0.04em;
3133
- padding: 2px 6px; border-radius: 4px;
3134
- background: var(--aparte-surface, #fff);
3135
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.1));
3136
- color: var(--aparte-text-muted, rgba(0,0,0,0.6));
3137
- }
3138
- .aparte-art-card__title {
3139
- font-size: 0.85rem; font-weight: 500;
3140
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
3141
- }
3142
- .aparte-art-card__pulse {
3143
- width: 8px; height: 8px; border-radius: 50%;
3144
- background: var(--aparte-accent, #3b82f6);
3145
- animation: aparte-art-card-pulse 1.2s ease-in-out infinite;
3146
- }
3147
- @keyframes aparte-art-card-pulse {
3148
- 0%, 100% { opacity: 0.35; transform: scale(0.85); }
3149
- 50% { opacity: 1; transform: scale(1.1); }
3150
- }
3151
- .aparte-art-card__actions { display: flex; gap: 4px; }
3152
- .aparte-art-card__btn {
3153
- display: inline-flex; align-items: center; justify-content: center;
3154
- width: 28px; height: 28px;
3155
- background: transparent;
3156
- border: 1px solid transparent;
3157
- border-radius: 6px;
3158
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3159
- cursor: pointer;
3160
- transition: background 0.12s, border-color 0.12s, color 0.12s;
3161
- }
3162
- .aparte-art-card__btn:hover:not(:disabled) {
3163
- background: var(--aparte-surface-hover, rgba(0,0,0,0.04));
3164
- border-color: var(--aparte-border, rgba(0,0,0,0.1));
3165
- color: var(--aparte-text, inherit);
3166
- }
3167
- .aparte-art-card__btn:disabled { opacity: 0.4; cursor: not-allowed; }
3168
- .aparte-art-card__tabs {
3169
- /* justify-content and the padding are DECLARED, not left to a default.
3170
- Core is light DOM on purpose - no shadow root, no ::part(), any selector
3171
- reaches in - and the corollary is that a component must state what its
3172
- layout depends on, because an undeclared property has nothing to override
3173
- a host rule with. A consuming page with a bare nav selector setting
3174
- justify-content: space-between and padding-top (this library's own docs
3175
- site had exactly that) otherwise pushes these two tabs to opposite ends of
3176
- the card and pads the row out. No backticks in here: this block is a JS
3177
- template literal, and one of them ended it.
3178
-
3179
- flex-end, and Code first in the DOM. The card OPENS on Code - mounting the
3180
- preview would execute model-authored code with no gesture (ratified
3181
- decision #8) - and a selected tab sitting second reads backwards. Right,
3182
- because the header above puts the artifact's identity on the left and its
3183
- copy/download buttons on the right, so this keeps every control in one
3184
- column. DOM order is also keyboard order, so the tab a reader lands on
3185
- first is the one already showing. */
3186
- display: flex; justify-content: flex-end; align-items: stretch; gap: 2px;
3187
- padding: 4px 8px 0;
3188
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3189
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3190
- }
3191
- .aparte-art-card__tabs button {
3192
- padding: 6px 12px;
3193
- font-size: 0.78rem;
3194
- background: transparent;
3195
- border: 1px solid transparent;
3196
- border-bottom: none;
3197
- border-radius: 6px 6px 0 0;
3198
- color: var(--aparte-text-muted, rgba(0,0,0,0.6));
3199
- cursor: pointer;
3200
- transition: background 0.12s, color 0.12s;
3201
- }
3202
- .aparte-art-card__tabs button[aria-selected="true"] {
3203
- background: var(--aparte-surface, #fff);
3204
- border-color: var(--aparte-border, rgba(0,0,0,0.08));
3205
- color: var(--aparte-text, inherit);
3206
- font-weight: 500;
3207
- }
3208
- .aparte-art-card__tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
3209
- /* The card's heights, as variables with ONE owner each.
3210
- They were four hardcoded numbers in the only part of this card that did not
3211
- use a variable - everything else here already reads var(--aparte-code-bg) and
3212
- friends - and two of them had to agree while a third contradicted a fourth:
3213
- the code pane repeated the body's 600px, and the "press Preview" placeholder
3214
- was 120px tall inside a body whose min-height said 80, so that minimum applied
3215
- to nothing.
3216
- The frame stays a FIXED height rather than an aspect ratio, which is what
3217
- embeds of arbitrary HTML actually do - CodeSandbox documents 500px, StackBlitz
3218
- takes a height parameter - because a frame with an opaque origin cannot be
3219
- measured and a 16/10 ratio on a wide card is enormous. The vh cap is the part
3220
- that was missing: a fixed 480px should not own a phone screen.
3221
- Each default lives in its read, as var(--x, literal), the way every other
3222
- value in this file already does - not in a declaration block on top of the
3223
- fallbacks, which would be two owners of one number again. It also means the
3224
- docs' CSS-variable generator finds them: its sweep looks for reads that
3225
- carry a fallback, so a read without one is a public knob nobody documents. */
3226
- .aparte-art-card__body {
3227
- position: relative;
3228
- /* The placeholder is the tallest thing this box can hold while empty, so it
3229
- IS the minimum - one number instead of two that disagreed. */
3230
- min-height: var(--aparte-artifact-pending-height, 120px);
3231
- max-height: var(--aparte-artifact-body-max, 600px);
3232
- overflow: hidden;
3233
- }
3234
- .aparte-art-card__pane { display: none; height: 100%; }
3235
- .segment-artifact-card[data-tab="code"] .aparte-art-card__pane[data-pane="code"] { display: block; }
3236
- .segment-artifact-card[data-tab="preview"] .aparte-art-card__pane[data-pane="preview"] { display: block; }
3237
- .aparte-art-card__pane[data-pane="code"] {
3238
- /* The body already caps this; repeating the number was the second owner. */
3239
- max-height: var(--aparte-artifact-body-max, 600px); overflow: auto;
3240
- }
3241
- .aparte-art-card__pane[data-pane="code"] pre {
3242
- margin: 0; padding: 12px;
3243
- font-size: 0.82rem;
3244
- background: var(--aparte-code-bg, #fafafa);
3245
- }
3246
- .aparte-art-card__frame {
3247
- display: block;
3248
- width: 100%;
3249
- height: min(var(--aparte-artifact-frame-height, 480px), var(--aparte-artifact-frame-max, 70vh));
3250
- border: 0;
3251
- background: #fff;
3252
- }
3253
- .aparte-art-card__pending {
3254
- display: flex; align-items: center; justify-content: center;
3255
- height: var(--aparte-artifact-pending-height, 120px);
3256
- color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3257
- font-size: 0.85rem;
3258
- font-style: italic;
3259
- }
3260
- /* ── Binary file artifact (xlsx/pdf/docx) ──────────────────── */
3261
- .segment-artifact-file {
3262
- display: flex; flex-direction: column;
3263
- margin: 8px 0;
3264
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3265
- border-radius: 12px;
3266
- background: var(--aparte-surface, #fff);
3267
- overflow: hidden;
3268
- color: var(--aparte-text, inherit);
3269
- font: inherit;
3270
- }
3271
- .aparte-art-file__card {
3272
- display: flex; align-items: center; gap: 12px;
3273
- padding: 12px 14px;
3274
- background: var(--aparte-surface-2, rgba(0,0,0,0.02));
3275
- border-bottom: 1px solid var(--aparte-border, rgba(0,0,0,0.08));
3276
- }
3277
- .aparte-art-file__body {
3278
- position: relative;
3279
- }
3280
- .aparte-art-file__code-pane {
3281
- max-height: var(--aparte-artifact-file-code-max, 360px); overflow: auto;
3282
- background: var(--aparte-code-bg, #fafafa);
3283
- }
3284
- .aparte-art-file__code-pane pre {
3285
- margin: 0; padding: 12px;
3286
- font-size: 0.82rem;
3287
- font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3288
- }
3289
- .aparte-art-file__preview-pane {
3290
- max-height: var(--aparte-artifact-file-preview-max, 460px);
3291
- overflow: auto;
3292
- background: #fff;
3293
- /* Preview is a document view — force light scheme regardless of
3294
- the app theme, with a dark text colour so cells stay readable. */
3295
- color: #1f2937;
3296
- }
3297
- .aparte-art-file__icon {
3298
- width: 40px; height: 40px;
3299
- border-radius: 8px;
3300
- display: flex; align-items: center; justify-content: center;
3301
- background: linear-gradient(135deg, #1d6f42, #0f5132);
3302
- color: #fff;
3303
- font-weight: 700;
3304
- font-size: 0.78rem;
3305
- letter-spacing: 0.04em;
3306
- flex-shrink: 0;
3307
- }
3308
- .aparte-art-file__icon[data-kind="pdf"] { background: linear-gradient(135deg, #c0392b, #7d1f17); }
3309
- .aparte-art-file__icon[data-kind="docx"] { background: linear-gradient(135deg, #1e5288, #0f3060); }
3310
- .aparte-art-file__meta { flex: 1 1 auto; min-width: 0; }
3311
- .aparte-art-file__meta-name {
3312
- font-weight: 600; font-size: 0.92rem;
3313
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
3314
- }
3315
- .aparte-art-file__meta-sub {
3316
- font-size: 0.78rem;
3317
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3318
- }
3319
- .aparte-art-file__actions { display: flex; gap: 6px; flex-shrink: 0; }
3320
- .aparte-art-file__btn {
3321
- border: 1px solid var(--aparte-border, rgba(0,0,0,0.12));
3322
- background: var(--aparte-surface, #fff);
3323
- color: var(--aparte-text, inherit);
3324
- padding: 6px 10px;
3325
- border-radius: 6px;
3326
- font-size: 0.8rem;
3327
- cursor: pointer;
3328
- transition: background 0.12s ease;
3329
- }
3330
- .aparte-art-file__btn:hover { background: var(--aparte-surface-2, rgba(0,0,0,0.05)); }
3331
- .aparte-art-file__btn--primary {
3332
- background: var(--aparte-primary, #6366f1);
3333
- color: #fff;
3334
- border-color: var(--aparte-primary, #6366f1);
3335
- }
3336
- .aparte-art-file__btn--primary:hover { filter: brightness(1.07); }
3337
- /* Hide the download button until the sandbox has produced the buffer.
3338
- Avoids confusing the user with a disabled-but-styled-primary button
3339
- during streaming / compiling. */
3340
- .segment-artifact-file:not([data-state="ready"]) .aparte-art-file__btn[data-action="download"] {
3341
- display: none;
3342
- }
3343
- .aparte-art-file__preview-pane table {
3344
- border-collapse: collapse;
3345
- width: 100%;
3346
- font-size: 0.82rem;
3347
- font-family: inherit;
3348
- }
3349
- .aparte-art-file__preview-pane th,
3350
- .aparte-art-file__preview-pane td {
3351
- border: 1px solid rgba(0,0,0,0.10);
3352
- padding: 6px 10px;
3353
- text-align: left;
3354
- white-space: nowrap;
3355
- color: #1f2937;
3356
- background: #fff;
3357
- }
3358
- .aparte-art-file__preview-pane tr:nth-child(odd) td { background: #f9fafb; }
3359
- .aparte-art-file__preview-pane tr:first-child td {
3360
- background: #f3f4f6;
3361
- font-weight: 600;
3362
- position: sticky; top: 0;
3363
- color: #111827;
3364
- }
3365
- .aparte-art-file__preview-empty {
3366
- padding: 20px; text-align: center;
3367
- color: var(--aparte-text-muted, rgba(0,0,0,0.5));
3368
- font-size: 0.85rem; font-style: italic;
3369
- }
3370
- .segment-artifact-file[data-state="error"] .aparte-art-file__icon {
3371
- background: linear-gradient(135deg, #c0392b, #7d1f17);
3372
- }
3373
- .aparte-art-file__error {
3374
- padding: 16px 18px;
3375
- background: rgba(239, 68, 68, 0.05);
3376
- border-top: 1px solid rgba(239, 68, 68, 0.2);
3377
- color: var(--aparte-text, inherit);
3378
- }
3379
- .aparte-art-file__error-title {
3380
- font-weight: 600;
3381
- font-size: 0.9rem;
3382
- margin-bottom: 6px;
3383
- color: #b91c1c;
3384
- }
3385
- .aparte-art-file__error-msg {
3386
- font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
3387
- font-size: 0.78rem;
3388
- padding: 6px 10px;
3389
- background: rgba(0,0,0,0.04);
3390
- border-radius: 4px;
3391
- margin-bottom: 8px;
3392
- color: #7f1d1d;
3393
- word-break: break-word;
3394
- }
3395
- .aparte-art-file__error-hint {
3396
- font-size: 0.78rem;
3397
- color: var(--aparte-text-muted, rgba(0,0,0,0.55));
3398
- font-style: italic;
3399
- }
3400
- `
3185
+ // CSS lives in styles/aparte.css — see its "Segment renderers" section for why a
3186
+ // built-in's rules belong there. `getStyles` stays for a CONSUMER's renderer, which
3187
+ // has no other way onto the page.
3188
+ getStyles: () => ""
3401
3189
  };
3402
3190
  function languageForKind(kind) {
3403
3191
  if (kind === "react") return "jsx";
@@ -3548,7 +3336,6 @@ const DEFAULT_RENDERERS = [
3548
3336
  thinkingRenderer,
3549
3337
  codeRenderer,
3550
3338
  errorRenderer,
3551
- progressRenderer,
3552
3339
  toolCallRenderer,
3553
3340
  artifactRenderer,
3554
3341
  pipelineWaitingRenderer
@@ -3575,7 +3362,8 @@ function adoptSegment(segments, segment, messageId) {
3575
3362
  ...segment,
3576
3363
  messageId,
3577
3364
  index: segments.length,
3578
- isStreaming: false
3365
+ isStreaming: false,
3366
+ ...segment.type === "tool_call" && segment.status === "awaiting-approval" ? { status: "aborted" } : {}
3579
3367
  };
3580
3368
  }
3581
3369
  const RESERVED = /* @__PURE__ */ new Set(["id", "type", "messageId", "index"]);
@@ -5362,344 +5150,726 @@ function readableToAsyncIterable(stream, signal) {
5362
5150
  }
5363
5151
  };
5364
5152
  }
5365
- const DEFAULT_TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5366
- function withToolTimeout(work, controller, timeoutMs) {
5367
- let timer;
5368
- const timedOut = new Promise((_resolve, reject) => {
5369
- timer = setTimeout(() => {
5370
- controller.abort();
5371
- reject(Object.assign(new Error(`tool handler exceeded ${timeoutMs}ms`), { name: "AbortError" }));
5372
- }, timeoutMs);
5373
- });
5374
- return {
5375
- raced: Promise.race([work(controller.signal), timedOut]),
5376
- done: () => clearTimeout(timer)
5377
- };
5153
+ let _relabels = null;
5154
+ function onRelabel(fn) {
5155
+ _relabels?.push(fn);
5378
5156
  }
5379
- class AparteClient {
5380
- _boundHandler = null;
5381
- _boundAbortHandler = null;
5382
- _boundCompactHandler = null;
5383
- _boundRetryHandler = null;
5384
- _boundEditHandler = null;
5385
- _activeToolControllers = /* @__PURE__ */ new Set();
5386
- _isAborted = false;
5387
- /**
5388
- * Does this window event belong to this client?
5389
- *
5390
- * One rule for all five handlers, because it used to be four near-copies and
5391
- * one omission: `aparte-compact` had no guard at all, so in the two-client
5392
- * layout the JSDoc documents, a single compact event made BOTH clients run —
5393
- * two paid summarisation calls against whichever chat the DOM scan found
5394
- * first, and a global reset that wiped the other conversation.
5395
- *
5396
- * A scoped client also answers only events ADDRESSED to it. The old guard let
5397
- * an untargeted event through to every scoped client, which turned one
5398
- * broadcast into an action on every chat on the page.
5399
- */
5400
- _isForThisInstance(e) {
5401
- if (!e) return true;
5402
- const detail = e.detail;
5403
- const scope = this.options.scopeToTargetId;
5404
- if (scope) return detail?.targetId === scope;
5405
- if (this._config === aparteGlobalConfig) return true;
5406
- const target = this._resolveTarget(detail?.targetId);
5407
- if (!target) return true;
5408
- const owner = resolveConfig(target);
5409
- return owner === this._config || owner === aparteGlobalConfig;
5157
+ function el$1(tag, className, text) {
5158
+ const node = document.createElement(tag);
5159
+ if (className) node.className = className;
5160
+ if (text != null) node.textContent = text;
5161
+ return node;
5162
+ }
5163
+ function fieldHeader(parent, field, skipTitle) {
5164
+ let titleId;
5165
+ if (field.title && field.title.trim() !== skipTitle?.trim()) {
5166
+ const title = el$1("p", "aparte-elic-title", field.title);
5167
+ titleId = `elic-title-${uuid()}`;
5168
+ title.id = titleId;
5169
+ parent.appendChild(title);
5410
5170
  }
5411
- /** Aborts the in-flight vendor/transport fetch when the user stops a stream. */
5412
- _streamController = null;
5413
- options;
5414
- /** Config read by this client — an instance config, or the global default. */
5415
- _config;
5416
- constructor(options = {}) {
5417
- this.options = {
5418
- autoRegister: true,
5419
- ...options
5420
- };
5421
- this._config = options.config ?? aparteGlobalConfig;
5422
- if (this.options.autoRegister) {
5423
- registerDefaultRenderers(this._config);
5424
- } else {
5425
- declineDefaultRenderers(this._config);
5426
- }
5427
- this._setupListeners();
5171
+ if (field.description) parent.appendChild(el$1("p", "aparte-elic-desc", field.description));
5172
+ return titleId;
5173
+ }
5174
+ function labelGroup(list, opts) {
5175
+ list.setAttribute("role", opts.multiple ? "group" : "radiogroup");
5176
+ if (opts.titleId) list.setAttribute("aria-labelledby", opts.titleId);
5177
+ else if (opts.fallbackLabel) list.setAttribute("aria-label", opts.fallbackLabel);
5178
+ }
5179
+ function buildEnumField(field, onChange, fallbackLabel) {
5180
+ const wrap = el$1("div", "aparte-elic-field aparte-elic-enum");
5181
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
5182
+ const list = el$1("div", "aparte-elic-options");
5183
+ labelGroup(list, { multiple: field.multiple, titleId, fallbackLabel });
5184
+ const name = `elic-${uuid()}`;
5185
+ const type = field.multiple ? "checkbox" : "radio";
5186
+ const allowOther = field.allowOther ?? contextConfig().getElicitationOptions().allowOther;
5187
+ const defaults = new Set(Array.isArray(field.default) ? field.default : field.default != null ? [field.default] : []);
5188
+ const buildOption = (value, label, description, recommended) => {
5189
+ const row = el$1("label", "aparte-elic-option" + (recommended ? " aparte-elic-option--recommended" : ""));
5190
+ const control = el$1("input", "aparte-elic-control");
5191
+ control.type = type;
5192
+ control.name = name;
5193
+ control.value = value;
5194
+ if (defaults.has(value)) control.checked = true;
5195
+ const body = el$1("span", "aparte-elic-option-body");
5196
+ body.appendChild(el$1("span", "aparte-elic-option-title", label));
5197
+ if (description) body.appendChild(el$1("span", "aparte-elic-option-desc", description));
5198
+ row.append(control, body);
5199
+ return row;
5200
+ };
5201
+ for (const opt of field.options) {
5202
+ list.appendChild(buildOption(opt.value, opt.label ?? opt.value, opt.description, opt.recommended));
5428
5203
  }
5429
- /**
5430
- * Sets up the event listeners.
5431
- * This is called once in the constructor.
5432
- */
5433
- _setupListeners() {
5434
- if (this._boundHandler) return;
5435
- this._boundHandler = (e) => {
5436
- const event = e;
5437
- if (event.type !== "aparte-send") return;
5438
- if (!this._isForThisInstance(event)) return;
5439
- this._isAborted = false;
5440
- for (const controller of this._activeToolControllers) {
5441
- controller.abort();
5442
- }
5443
- this._activeToolControllers.clear();
5444
- void this._handleSend(event);
5445
- };
5204
+ let otherText = null;
5205
+ if (allowOther) {
5206
+ const row = el$1("label", "aparte-elic-option aparte-elic-option--other");
5207
+ const control = el$1("input", "aparte-elic-control");
5208
+ control.type = type;
5209
+ control.name = name;
5210
+ control.value = "__other__";
5211
+ const body = el$1("span", "aparte-elic-option-body");
5212
+ const t = contextConfig();
5213
+ const otherTitle = el$1("span", "aparte-elic-option-title", t.t("elicitationOther"));
5214
+ body.appendChild(otherTitle);
5215
+ otherText = el$1("input", "aparte-elic-other-input");
5216
+ otherText.type = "text";
5217
+ otherText.placeholder = t.t("elicitationOtherPlaceholder");
5218
+ otherText.style.display = "none";
5219
+ otherText.setAttribute("aria-label", t.t("elicitationOtherLabel"));
5220
+ const otherInput = otherText;
5221
+ onRelabel(() => {
5222
+ const now = contextConfig();
5223
+ otherTitle.textContent = now.t("elicitationOther");
5224
+ otherInput.placeholder = now.t("elicitationOtherPlaceholder");
5225
+ otherInput.setAttribute("aria-label", now.t("elicitationOtherLabel"));
5226
+ });
5227
+ body.appendChild(otherText);
5228
+ row.append(control, body);
5229
+ list.appendChild(row);
5446
5230
  }
5447
- /**
5448
- * Start listening for aparte-send events on the window.
5449
- */
5450
- start() {
5451
- if (!this._boundHandler) {
5452
- this._setupListeners();
5453
- }
5454
- if (this._boundHandler) {
5455
- window.addEventListener("aparte-send", this._boundHandler);
5456
- }
5457
- if (!this._boundAbortHandler) {
5458
- this._boundAbortHandler = (e) => {
5459
- if (!this._isForThisInstance(e)) return;
5460
- this.abort();
5461
- };
5462
- }
5463
- window.addEventListener("aparte-abort", this._boundAbortHandler);
5464
- if (!this._boundCompactHandler) {
5465
- this._boundCompactHandler = (e) => {
5466
- if (!this._isForThisInstance(e)) return;
5467
- const detail = e.detail;
5468
- void this.compact(detail?.targetId);
5469
- };
5470
- }
5471
- window.addEventListener("aparte-compact", this._boundCompactHandler);
5472
- if (!this._boundRetryHandler) {
5473
- this._boundRetryHandler = (e) => {
5474
- const evt = e;
5475
- if (!this._isForThisInstance(evt)) return;
5476
- void this._handleRetry(evt);
5477
- };
5478
- }
5479
- window.addEventListener("aparte-retry", this._boundRetryHandler);
5480
- if (!this._boundEditHandler) {
5481
- this._boundEditHandler = (e) => {
5482
- const evt = e;
5483
- if (!this._isForThisInstance(evt)) return;
5484
- void this._handleEdit(evt);
5485
- };
5486
- }
5487
- window.addEventListener("aparte-edit", this._boundEditHandler);
5488
- }
5489
- /**
5490
- * Stop listening.
5491
- */
5492
- stop() {
5493
- this.abort();
5494
- if (!this._boundHandler) return;
5495
- window.removeEventListener("aparte-send", this._boundHandler);
5496
- this._boundHandler = null;
5497
- if (this._boundAbortHandler) {
5498
- window.removeEventListener("aparte-abort", this._boundAbortHandler);
5499
- this._boundAbortHandler = null;
5500
- }
5501
- if (this._boundCompactHandler) {
5502
- window.removeEventListener("aparte-compact", this._boundCompactHandler);
5503
- this._boundCompactHandler = null;
5504
- }
5505
- if (this._boundRetryHandler) {
5506
- window.removeEventListener("aparte-retry", this._boundRetryHandler);
5507
- this._boundRetryHandler = null;
5231
+ wrap.appendChild(list);
5232
+ const controls = () => Array.from(list.querySelectorAll(".aparte-elic-control"));
5233
+ const other = () => list.querySelector('input[value="__other__"]');
5234
+ list.addEventListener("change", () => {
5235
+ const o = other();
5236
+ if (o && otherText) {
5237
+ otherText.style.display = o.checked ? "" : "none";
5238
+ if (o.checked) otherText.focus();
5508
5239
  }
5509
- if (this._boundEditHandler) {
5510
- window.removeEventListener("aparte-edit", this._boundEditHandler);
5511
- this._boundEditHandler = null;
5240
+ onChange();
5241
+ });
5242
+ otherText?.addEventListener("input", onChange);
5243
+ const getValue = () => {
5244
+ const otherVal = other()?.checked && otherText?.value.trim() ? otherText.value.trim() : "";
5245
+ if (field.multiple) {
5246
+ const vals = controls().filter((c) => c.checked && c.value !== "__other__").map((c) => c.value);
5247
+ if (otherVal) vals.push(otherVal);
5248
+ return vals;
5512
5249
  }
5250
+ const checked = controls().find((c) => c.checked);
5251
+ if (!checked) return "";
5252
+ return checked.value === "__other__" ? otherVal : checked.value;
5253
+ };
5254
+ return {
5255
+ el: wrap,
5256
+ getValue,
5257
+ isComplete: () => {
5258
+ const v = getValue();
5259
+ return field.multiple ? v.length > 0 : v !== "";
5260
+ },
5261
+ focus: () => controls()[0]?.focus()
5262
+ };
5263
+ }
5264
+ function buildBooleanField(field, onChange, fallbackLabel) {
5265
+ const wrap = el$1("div", "aparte-elic-field aparte-elic-boolean");
5266
+ const titleId = fieldHeader(wrap, field, fallbackLabel);
5267
+ const list = el$1("div", "aparte-elic-options");
5268
+ labelGroup(list, { titleId, fallbackLabel });
5269
+ const name = `elic-${uuid()}`;
5270
+ const mk = (val, label, defaulted) => {
5271
+ const row = el$1("label", "aparte-elic-option");
5272
+ const control = el$1("input", "aparte-elic-control");
5273
+ control.type = "radio";
5274
+ control.name = name;
5275
+ control.value = val;
5276
+ if (field.default != null && String(field.default) === val) control.checked = true;
5277
+ const body = el$1("span", "aparte-elic-option-body");
5278
+ const title = el$1("span", "aparte-elic-option-title", label);
5279
+ if (defaulted) onRelabel(() => {
5280
+ title.textContent = contextConfig().t(defaulted);
5281
+ });
5282
+ body.appendChild(title);
5283
+ row.append(control, body);
5284
+ list.appendChild(row);
5285
+ };
5286
+ const t = contextConfig();
5287
+ mk("true", field.trueLabel ?? t.t("elicitationYes"), field.trueLabel ? void 0 : "elicitationYes");
5288
+ mk("false", field.falseLabel ?? t.t("elicitationNo"), field.falseLabel ? void 0 : "elicitationNo");
5289
+ wrap.appendChild(list);
5290
+ list.addEventListener("change", onChange);
5291
+ const checked = () => list.querySelector("input:checked");
5292
+ return {
5293
+ el: wrap,
5294
+ getValue: () => checked()?.value === "true",
5295
+ isComplete: () => checked() != null,
5296
+ focus: () => list.querySelector("input")?.focus()
5297
+ };
5298
+ }
5299
+ function buildStringField(field, onChange, fallbackLabel) {
5300
+ const wrap = el$1("div", "aparte-elic-field aparte-elic-string");
5301
+ fieldHeader(wrap, field, fallbackLabel);
5302
+ const input = field.multiline ? el$1("textarea", "aparte-elic-text") : el$1("input", "aparte-elic-text");
5303
+ if (!field.multiline) input.type = "text";
5304
+ if (field.placeholder) input.setAttribute("placeholder", field.placeholder);
5305
+ if (field.default) input.value = field.default;
5306
+ if (field.maxLength != null) input.setAttribute("maxlength", String(field.maxLength));
5307
+ const ownName = field.title || field.description || fallbackLabel || void 0;
5308
+ input.setAttribute("aria-label", ownName ?? contextConfig().t("elicitationAnswerLabel"));
5309
+ if (ownName == null) {
5310
+ const named = input;
5311
+ onRelabel(() => {
5312
+ named.setAttribute("aria-label", contextConfig().t("elicitationAnswerLabel"));
5313
+ });
5513
5314
  }
5514
- /**
5515
- * Abort the current streaming response and all active tool calls.
5516
- * Dispatches `aparte-message-aborted` on the target element.
5517
- */
5518
- abort() {
5519
- this._isAborted = true;
5520
- this._streamController?.abort();
5521
- for (const controller of this._activeToolControllers) {
5522
- controller.abort();
5523
- }
5524
- this._activeToolControllers.clear();
5315
+ wrap.appendChild(input);
5316
+ input.addEventListener("input", onChange);
5317
+ const required = field.required ?? true;
5318
+ const value = () => input.value;
5319
+ return {
5320
+ el: wrap,
5321
+ getValue: () => value(),
5322
+ isComplete: () => {
5323
+ const v = value().trim();
5324
+ if (!required && v === "") return true;
5325
+ return v.length >= (field.minLength ?? 1);
5326
+ },
5327
+ focus: () => input.focus()
5328
+ };
5329
+ }
5330
+ function buildField(field, onChange, fallbackLabel, key) {
5331
+ const custom = contextConfig().getElicitationFieldRenderer()?.(field, { key, notifyChange: onChange });
5332
+ if (custom) {
5333
+ return {
5334
+ el: custom.el,
5335
+ getValue: () => custom.getValue(),
5336
+ isComplete: () => custom.isComplete(),
5337
+ focus: () => custom.focus?.()
5338
+ };
5525
5339
  }
5526
- /**
5527
- * Human-in-the-loop: wait for an `aparte-tool-decision` event matching this
5528
- * tool call (dispatched by the built-in Approve/Reject UI or an app-level
5529
- * approval surface). Resolves `{ approved, payload }` — `approved` is `true`
5530
- * only on an explicit approve, and `payload` carries any arbitrary data a
5531
- * custom approval UI attached to the decision (the built-in gate sends
5532
- * none). The `signal` (an AbortController registered in
5533
- * `_activeToolControllers`) lets `abort()` cleanly resolve a pending
5534
- * approval to `{ approved: false }` — there is no timeout, since a human may
5535
- * take any amount of time to decide.
5536
- */
5537
- /**
5538
- * Await the human's Approve/Reject for one tool call.
5539
- *
5540
- * `target` is not decoration: it is the scope of the consent. The listener sits
5541
- * on `document` and used to accept any `aparte-tool-decision` whose
5542
- * `detail.toolCallId` matched — and that id is the tool-call id the MODEL chose.
5543
- * The built-in buttons dispatch with `bubbles: true, composed: true`, so on a
5544
- * page with two chats a click aimed at one tool could satisfy the gate awaiting
5545
- * a different tool in a different conversation. The consented action and the
5546
- * executed action came apart, which is the entire failure mode an approval gate
5547
- * exists to prevent — and the handler behind it is arbitrary consumer code.
5548
- *
5549
- * The check is DOM CONTAINMENT rather than a `targetId` string comparison. A
5550
- * model can choose an id; it cannot choose where in the tree the click happened.
5551
- * It also needs no change on the dispatch side, so a consumer's own Approve
5552
- * button keeps working as long as it fires from inside its own chat.
5553
- *
5554
- * The request half of this handshake was hardened with `targetId` for exactly
5555
- * this hazard. This is its sibling, and it was missed.
5556
- */
5557
- _awaitToolDecision(toolCallId, signal, target) {
5558
- return new Promise((resolve) => {
5559
- if (signal.aborted) {
5560
- resolve({ approved: false });
5561
- return;
5562
- }
5563
- const cleanup = () => {
5564
- document.removeEventListener("aparte-tool-decision", onDecision);
5565
- signal.removeEventListener("abort", onAbort);
5566
- };
5567
- const onDecision = (e) => {
5568
- const detail = e.detail;
5569
- if (detail?.toolCallId !== toolCallId) return;
5570
- if (target && e.target instanceof Node && e.target !== document && !target.contains(e.target)) return;
5571
- cleanup();
5572
- resolve({ approved: detail?.approved === true, payload: detail?.payload });
5573
- };
5574
- const onAbort = () => {
5575
- cleanup();
5576
- resolve({ approved: false });
5577
- };
5578
- document.addEventListener("aparte-tool-decision", onDecision);
5579
- signal.addEventListener("abort", onAbort, { once: true });
5580
- });
5340
+ switch (field.type) {
5341
+ case "enum":
5342
+ return buildEnumField(field, onChange, fallbackLabel);
5343
+ case "boolean":
5344
+ return buildBooleanField(field, onChange, fallbackLabel);
5345
+ case "string":
5346
+ return buildStringField(field, onChange, fallbackLabel);
5581
5347
  }
5582
- /**
5583
- * Resolve the auth for a provider: `options.keyResolver` takes precedence,
5584
- * then the aparteGlobalConfig key channel (`setKeyProvider`) so a key registered
5585
- * there reaches the request. One key source on the happy path.
5586
- */
5587
- async _resolveAuth(providerId) {
5588
- if (this.options.keyResolver) {
5589
- const resolved = await this.options.keyResolver(providerId);
5590
- if (resolved) return resolved;
5591
- }
5592
- const key = await this._config.getKey(providerId);
5593
- return key || void 0;
5348
+ }
5349
+ function buildElicitationPanel(message, schema, onChange) {
5350
+ const relabels = [];
5351
+ const outer = _relabels;
5352
+ _relabels = relabels;
5353
+ try {
5354
+ const built = buildPanel(message, schema, onChange);
5355
+ return { ...built, relabel: () => {
5356
+ for (const fn of relabels) fn();
5357
+ } };
5358
+ } finally {
5359
+ _relabels = outer;
5594
5360
  }
5595
- /**
5596
- * The shared tail of send / retry / edit: attach the current tools, run the
5597
- * request interceptor, honour `toolChoice: 'none'`, reset the abort flag,
5598
- * dispatch `aparte-message-start`, run the agentic `_streamLoop`, then dispatch
5599
- * `aparte-message-done` or route the error to the lifecycle handler.
5600
- *
5601
- * Callers own only their turn-specific prep (target resolution, history
5602
- * building, appending the assistant placeholder) and hand the fully-built
5603
- * `messages` here — one place for the provider→interceptor→toolChoice→stream
5604
- * sequence, so it can't drift between the three entry points.
5605
- */
5606
- async _streamTurn(targetElement, messageId, provider, messages, modelId, authConfig, opts) {
5607
- const registeredTools = this._toolsForCurrentModel();
5608
- let baseRequest = {
5609
- messages,
5610
- modelId,
5611
- stream: true,
5612
- tools: registeredTools.length ? registeredTools : void 0,
5613
- ...opts?.temperature !== void 0 ? { temperature: opts.temperature } : {}
5361
+ }
5362
+ function buildPanel(message, schema, onChange) {
5363
+ const panel = el$1("div", "aparte-elic-panel");
5364
+ const asked = typeof message === "function" ? message() : message;
5365
+ if (asked) panel.appendChild(el$1("p", "aparte-elic-message", asked));
5366
+ const body = el$1("div", "aparte-elic-body");
5367
+ panel.appendChild(body);
5368
+ const dismiss = el$1("div", "aparte-elic-dismiss");
5369
+ panel.appendChild(dismiss);
5370
+ if (schema.type === "object") {
5371
+ let show = function(index) {
5372
+ current = index;
5373
+ fields.forEach((f, i) => {
5374
+ f.field.el.hidden = i !== index;
5375
+ });
5376
+ syncNav();
5614
5377
  };
5615
- if (this.options.requestInterceptor) {
5616
- baseRequest = await this.options.requestInterceptor(baseRequest);
5617
- }
5618
- if (baseRequest.toolChoice === "none") {
5619
- baseRequest = { ...baseRequest, tools: void 0 };
5620
- }
5621
- if (this._isAborted) {
5622
- dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5623
- return;
5624
- }
5625
- dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
5626
- try {
5627
- const usage = await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig);
5628
- if (this._isAborted) {
5629
- this._updateMessage(targetElement, messageId, { status: "completed" });
5630
- } else {
5631
- dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5632
- }
5633
- } catch (error) {
5634
- if (this._isAborted) {
5635
- dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5636
- this._updateMessage(targetElement, messageId, { status: "completed" });
5637
- return;
5378
+ const entries = Object.entries(schema.properties);
5379
+ const requiredKeys = new Set(schema.required ?? entries.map(([k]) => k));
5380
+ const cfg = contextConfig();
5381
+ let syncNav = () => {
5382
+ };
5383
+ const notify = () => {
5384
+ syncNav();
5385
+ onChange();
5386
+ };
5387
+ const fields = entries.map(([key, field2]) => {
5388
+ const built = buildField(field2, notify, void 0, key);
5389
+ if (!field2.title && !built.el.querySelector(".aparte-elic-title")) {
5390
+ built.el.insertBefore(el$1("p", "aparte-elic-title", key), built.el.firstChild);
5638
5391
  }
5639
- const aparteError = AparteError.from(error, AparteErrorCode.UNKNOWN_ERROR);
5640
- this._handleLifecycleError(targetElement, messageId, aparteError);
5641
- }
5392
+ body.appendChild(built.el);
5393
+ return { key, field: built, required: requiredKeys.has(key), header: field2.header };
5394
+ });
5395
+ const isComplete = () => fields.every((f) => !f.required || f.field.isComplete());
5396
+ const api = {
5397
+ el: panel,
5398
+ dismiss,
5399
+ getContent: () => Object.fromEntries(fields.map((f) => [f.key, f.field.getValue()])),
5400
+ isComplete,
5401
+ focus: () => fields[0]?.field.focus(),
5402
+ // A form of one behaves like a single field; only a real form steps.
5403
+ mode: () => "submit",
5404
+ canProceed: isComplete,
5405
+ proceed: () => {
5406
+ }
5407
+ };
5408
+ if (fields.length < 2 || cfg.getElicitationOptions().layout !== "stepped") return api;
5409
+ panel.classList.add("aparte-elic-panel--stepped");
5410
+ let current = 0;
5411
+ const steps = el$1("div", "aparte-elic-steps");
5412
+ steps.setAttribute("role", "tablist");
5413
+ const chips = fields.map((f, i) => {
5414
+ const chip = el$1("button", "aparte-elic-step");
5415
+ chip.type = "button";
5416
+ chip.setAttribute("role", "tab");
5417
+ chip.textContent = f.header?.trim() || String(i + 1);
5418
+ chip.addEventListener("click", () => {
5419
+ show(i);
5420
+ f.field.focus();
5421
+ });
5422
+ steps.appendChild(chip);
5423
+ return chip;
5424
+ });
5425
+ panel.insertBefore(steps, body);
5426
+ syncNav = () => {
5427
+ chips.forEach((chip, i) => {
5428
+ chip.setAttribute("aria-selected", String(i === current));
5429
+ chip.toggleAttribute("data-answered", fields[i].field.isComplete());
5430
+ });
5431
+ };
5432
+ show(0);
5433
+ const last = () => current === fields.length - 1;
5434
+ return {
5435
+ ...api,
5436
+ focus: () => fields[current]?.field.focus(),
5437
+ mode: () => last() ? "submit" : "advance",
5438
+ // Advancing needs only THIS question answered; submitting needs them all,
5439
+ // which is what stops the last question from accepting a form with a hole
5440
+ // in it.
5441
+ canProceed: () => last() ? isComplete() : !!fields[current]?.field.isComplete(),
5442
+ proceed: () => {
5443
+ if (last()) return;
5444
+ show(current + 1);
5445
+ fields[current]?.field.focus();
5446
+ }
5447
+ };
5642
5448
  }
5449
+ const field = buildField(schema, onChange, asked);
5450
+ body.appendChild(field.el);
5451
+ return {
5452
+ el: panel,
5453
+ dismiss,
5454
+ getContent: () => field.getValue(),
5455
+ isComplete: () => field.isComplete(),
5456
+ focus: () => field.focus(),
5457
+ mode: () => "submit",
5458
+ canProceed: () => field.isComplete(),
5459
+ proceed: () => {
5460
+ }
5461
+ };
5462
+ }
5463
+ function el(tag, className, text) {
5464
+ const node = document.createElement(tag);
5465
+ if (className) node.className = className;
5466
+ if (text != null) node.textContent = text;
5467
+ return node;
5468
+ }
5469
+ function buildApprovalPanel(message, options, onChange) {
5470
+ const cfg = contextConfig();
5471
+ const panel = el("div", "aparte-elic-panel aparte-approval-panel");
5472
+ const askText = () => typeof message === "function" ? message() : message;
5473
+ const ask = askText() ? el("p", "aparte-elic-message", askText()) : null;
5474
+ if (ask) panel.appendChild(ask);
5475
+ const dismiss = el("div", "aparte-elic-dismiss");
5476
+ panel.appendChild(dismiss);
5477
+ let settle = null;
5478
+ let done = false;
5479
+ const fire = (answer) => {
5480
+ if (done) return;
5481
+ done = true;
5482
+ settle?.(answer);
5483
+ };
5484
+ const row = el("div", "aparte-approval-options");
5485
+ row.setAttribute("role", "group");
5486
+ row.setAttribute("aria-label", cfg.t("approvalOptionsLabel"));
5487
+ const labelled = [];
5488
+ const textOf = (option) => typeof option.label === "function" ? option.label() : option.label;
5489
+ for (const option of options) {
5490
+ const button = el("button", `aparte-approval-option aparte-approval-option--${option.tone ?? "affirm"}`);
5491
+ button.type = "button";
5492
+ button.textContent = textOf(option);
5493
+ button.addEventListener("click", () => fire({ option: option.value }));
5494
+ row.appendChild(button);
5495
+ labelled.push({ button, option });
5496
+ }
5497
+ panel.appendChild(row);
5498
+ const instruction = el("textarea", "aparte-approval-instruction");
5499
+ instruction.rows = 1;
5500
+ instruction.placeholder = cfg.t("approvalInstructionPlaceholder");
5501
+ instruction.setAttribute("aria-label", cfg.t("approvalInstructionPlaceholder"));
5502
+ instruction.addEventListener("input", onChange);
5503
+ panel.appendChild(instruction);
5504
+ const typed = () => instruction.value.trim();
5505
+ return {
5506
+ el: panel,
5507
+ dismiss,
5508
+ onSettle: (cb) => {
5509
+ settle = cb;
5510
+ },
5511
+ getContent: () => ({ instruction: typed() }),
5512
+ isComplete: () => typed().length > 0,
5513
+ focus: () => {
5514
+ panel.querySelector(".aparte-approval-option")?.focus();
5515
+ },
5516
+ relabel: () => {
5517
+ const now = contextConfig();
5518
+ const label = now.t("approvalInstructionPlaceholder");
5519
+ instruction.placeholder = label;
5520
+ instruction.setAttribute("aria-label", label);
5521
+ row.setAttribute("aria-label", now.t("approvalOptionsLabel"));
5522
+ if (ask) ask.textContent = askText();
5523
+ for (const { button, option } of labelled) button.textContent = textOf(option);
5524
+ }
5525
+ };
5526
+ }
5527
+ function requestUserInput(request) {
5528
+ return resolveConfig(request.target ?? null).requestUserInput(request);
5529
+ }
5530
+ const DEFAULT_TOOL_HANDLER_TIMEOUT_MS = 5 * 60 * 1e3;
5531
+ function withToolTimeout(work, controller, timeoutMs) {
5532
+ let timer;
5533
+ const timedOut = new Promise((_resolve, reject) => {
5534
+ timer = setTimeout(() => {
5535
+ controller.abort();
5536
+ reject(Object.assign(new Error(`tool handler exceeded ${timeoutMs}ms`), { name: "AbortError" }));
5537
+ }, timeoutMs);
5538
+ });
5539
+ return {
5540
+ raced: Promise.race([work(controller.signal), timedOut]),
5541
+ done: () => clearTimeout(timer)
5542
+ };
5543
+ }
5544
+ class AparteClient {
5545
+ _boundHandler = null;
5546
+ _boundAbortHandler = null;
5547
+ _boundCompactHandler = null;
5548
+ _boundRetryHandler = null;
5549
+ _boundEditHandler = null;
5550
+ _activeToolControllers = /* @__PURE__ */ new Set();
5551
+ _isAborted = false;
5643
5552
  /**
5644
- * Compact the current conversation: summarize all messages via the AI,
5645
- * clear the viewport, then inject the summary as a single context message.
5553
+ * Does this window event belong to this client?
5646
5554
  *
5647
- * Triggered programmatically or by dispatching `window.dispatchEvent(new CustomEvent('aparte-compact'))`.
5648
- * Dispatches `aparte-compact-done` on window when complete, or `aparte-compact-error` on failure.
5555
+ * One rule for all five handlers, because it used to be four near-copies and
5556
+ * one omission: `aparte-compact` had no guard at all, so in the two-client
5557
+ * layout the JSDoc documents, a single compact event made BOTH clients run —
5558
+ * two paid summarisation calls against whichever chat the DOM scan found
5559
+ * first, and a global reset that wiped the other conversation.
5560
+ *
5561
+ * A scoped client also answers only events ADDRESSED to it. The old guard let
5562
+ * an untargeted event through to every scoped client, which turned one
5563
+ * broadcast into an action on every chat on the page.
5649
5564
  */
5650
- async compact(targetId) {
5651
- let target = this._resolveTarget(targetId);
5652
- if (target && typeof target.getMessages !== "function") target = null;
5653
- if (!target) {
5654
- window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No aparte-chat target found" } }));
5655
- return;
5656
- }
5657
- const messages = target.getMessages?.() ?? [];
5658
- if (messages.length === 0) {
5659
- window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true } }));
5660
- return;
5565
+ _isForThisInstance(e) {
5566
+ if (!e) return true;
5567
+ const detail = e.detail;
5568
+ const scope = this.options.scopeToTargetId;
5569
+ if (scope) return detail?.targetId === scope;
5570
+ if (this._config === aparteGlobalConfig) return true;
5571
+ const target = this._resolveTarget(detail?.targetId);
5572
+ if (!target) return true;
5573
+ const owner = resolveConfig(target);
5574
+ return owner === this._config || owner === aparteGlobalConfig;
5575
+ }
5576
+ /** Aborts the in-flight vendor/transport fetch when the user stops a stream. */
5577
+ _streamController = null;
5578
+ options;
5579
+ /** Config read by this client — an instance config, or the global default. */
5580
+ _config;
5581
+ constructor(options = {}) {
5582
+ this.options = {
5583
+ autoRegister: true,
5584
+ ...options
5585
+ };
5586
+ this._config = options.config ?? aparteGlobalConfig;
5587
+ if (this.options.autoRegister) {
5588
+ registerDefaultRenderers(this._config);
5589
+ } else {
5590
+ declineDefaultRenderers(this._config);
5661
5591
  }
5662
- const selector = this.options.compactionSelector ?? ((m) => ({ keep: [], drop: m }));
5663
- const { keep, drop } = selector(messages);
5664
- if (drop.length === 0) {
5665
- window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true } }));
5666
- return;
5592
+ this._setupListeners();
5593
+ }
5594
+ /**
5595
+ * Sets up the event listeners.
5596
+ * This is called once in the constructor.
5597
+ */
5598
+ _setupListeners() {
5599
+ if (this._boundHandler) return;
5600
+ this._boundHandler = (e) => {
5601
+ const event = e;
5602
+ if (event.type !== "aparte-send") return;
5603
+ if (!this._isForThisInstance(event)) return;
5604
+ this._isAborted = false;
5605
+ for (const controller of this._activeToolControllers) {
5606
+ controller.abort();
5607
+ }
5608
+ this._activeToolControllers.clear();
5609
+ void this._handleSend(event);
5610
+ };
5611
+ }
5612
+ /**
5613
+ * Start listening for aparte-send events on the window.
5614
+ */
5615
+ start() {
5616
+ if (!this._boundHandler) {
5617
+ this._setupListeners();
5667
5618
  }
5668
- const config = this._config.getModelConfig();
5669
- const providerId = config.defaultProvider;
5670
- if (!providerId) {
5671
- window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No provider configured" } }));
5672
- return;
5619
+ if (this._boundHandler) {
5620
+ window.addEventListener("aparte-send", this._boundHandler);
5673
5621
  }
5674
- const provider = this._config.getAIProvider(providerId);
5675
- if (!provider) {
5676
- window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: `Provider '${providerId}' not found` } }));
5677
- return;
5622
+ if (!this._boundAbortHandler) {
5623
+ this._boundAbortHandler = (e) => {
5624
+ if (!this._isForThisInstance(e)) return;
5625
+ this.abort();
5626
+ };
5678
5627
  }
5679
- window.dispatchEvent(new CustomEvent("aparte-compact-start"));
5680
- try {
5681
- const authConfig = await this._resolveAuth(providerId);
5682
- const historyMessages = drop.filter((m) => m.role === "user" || m.role === "assistant" && m.status === "completed").map((m) => ({
5683
- role: m.role,
5684
- content: this._extractText(m)
5685
- })).filter((m) => contentToText(m.content).length > 0);
5686
- const summarizeRequest = {
5687
- messages: [
5688
- {
5689
- role: "system",
5690
- content: "You are a conversation summarizer. Create a concise but complete summary of the conversation below. Capture: key topics, conclusions, decisions, ongoing tasks, and any context needed to continue the conversation. Write in third person. Be factual and brief. No preamble, just the summary."
5691
- },
5692
- ...historyMessages,
5693
- {
5694
- role: "user",
5695
- content: "Please summarize this conversation."
5696
- }
5697
- ],
5698
- modelId: config.defaultModel || "",
5699
- stream: false
5628
+ window.addEventListener("aparte-abort", this._boundAbortHandler);
5629
+ if (!this._boundCompactHandler) {
5630
+ this._boundCompactHandler = (e) => {
5631
+ if (!this._isForThisInstance(e)) return;
5632
+ const detail = e.detail;
5633
+ void this.compact(detail?.targetId);
5700
5634
  };
5701
- const compactController = new AbortController();
5702
- this._streamController = compactController;
5635
+ }
5636
+ window.addEventListener("aparte-compact", this._boundCompactHandler);
5637
+ if (!this._boundRetryHandler) {
5638
+ this._boundRetryHandler = (e) => {
5639
+ const evt = e;
5640
+ if (!this._isForThisInstance(evt)) return;
5641
+ void this._handleRetry(evt);
5642
+ };
5643
+ }
5644
+ window.addEventListener("aparte-retry", this._boundRetryHandler);
5645
+ if (!this._boundEditHandler) {
5646
+ this._boundEditHandler = (e) => {
5647
+ const evt = e;
5648
+ if (!this._isForThisInstance(evt)) return;
5649
+ void this._handleEdit(evt);
5650
+ };
5651
+ }
5652
+ window.addEventListener("aparte-edit", this._boundEditHandler);
5653
+ }
5654
+ /**
5655
+ * Stop listening.
5656
+ */
5657
+ stop() {
5658
+ this.abort();
5659
+ if (!this._boundHandler) return;
5660
+ window.removeEventListener("aparte-send", this._boundHandler);
5661
+ this._boundHandler = null;
5662
+ if (this._boundAbortHandler) {
5663
+ window.removeEventListener("aparte-abort", this._boundAbortHandler);
5664
+ this._boundAbortHandler = null;
5665
+ }
5666
+ if (this._boundCompactHandler) {
5667
+ window.removeEventListener("aparte-compact", this._boundCompactHandler);
5668
+ this._boundCompactHandler = null;
5669
+ }
5670
+ if (this._boundRetryHandler) {
5671
+ window.removeEventListener("aparte-retry", this._boundRetryHandler);
5672
+ this._boundRetryHandler = null;
5673
+ }
5674
+ if (this._boundEditHandler) {
5675
+ window.removeEventListener("aparte-edit", this._boundEditHandler);
5676
+ this._boundEditHandler = null;
5677
+ }
5678
+ }
5679
+ /**
5680
+ * Abort the current streaming response and all active tool calls.
5681
+ * Dispatches `aparte-message-aborted` on the target element.
5682
+ */
5683
+ abort() {
5684
+ this._isAborted = true;
5685
+ this._streamController?.abort();
5686
+ for (const controller of this._activeToolControllers) {
5687
+ controller.abort();
5688
+ }
5689
+ this._activeToolControllers.clear();
5690
+ }
5691
+ /*
5692
+ * Two docblocks stood here, both describing `_awaitToolDecision` — the method
5693
+ * this replaced. Deleting a method and leaving its documentation is worse than
5694
+ * leaving the method: only the LAST comment before a declaration is its JSDoc,
5695
+ * so those two were floating prose, and one of them explained a DOM-containment
5696
+ * check as a live security property. It was the fix for a cross-chat hazard that
5697
+ * only existed because the decision travelled as a bubbling `document` event.
5698
+ * Routing through `requestUserInput` removed the hazard rather than guarding it,
5699
+ * so the guard is gone too — and a reader who found that comment would have
5700
+ * believed a containment check was still protecting them.
5701
+ */
5702
+ /**
5703
+ * Ask the human at the COMPOSER, and report what they decided.
5704
+ *
5705
+ * The default channel, replacing a `document` listener for `aparte-tool-decision`
5706
+ * that existed only because the buttons lived in a segment renderer with no
5707
+ * reference to this client. Routing through `requestUserInput` puts the decision
5708
+ * where every other request for the user already goes, which is the whole point:
5709
+ * one queue, one panel slot, one teardown, and the same behaviour whether the
5710
+ * request came from a tool handler or from this gate.
5711
+ *
5712
+ * The tool NAME is the question; the arguments stay in the transcript, on the pill
5713
+ * this request is anchored to. A panel capped at 50vh cannot hold a diff or a plan,
5714
+ * and the transcript is already scrollable, copyable and persisted.
5715
+ *
5716
+ * `aparte-tool-decision` is GONE with the buttons that dispatched it, and so is the
5717
+ * `document` listener that answered it. It existed only because a segment renderer
5718
+ * has no reference to this client; two seams that can disagree about who answers a
5719
+ * decision are one seam too many, and the ratified elicitation rule already said so
5720
+ * — "a typed presenter registered per config instance, never window events". A host
5721
+ * that wants to answer programmatically registers an `approvalResolver` (headless,
5722
+ * no DOM) or its own presenter, both strictly more capable than an id on an event.
5723
+ */
5724
+ async _askForApproval(event, signal, target) {
5725
+ const answer = await requestUserInput({
5726
+ kind: "approval",
5727
+ /*
5728
+ * A function, like the option labels below, and for the same reason: the
5729
+ * FRAME is locale text and follows a language switch while the question is
5730
+ * open. The tool's NAME is substituted into it and never translated — it is
5731
+ * wire format, the identifier the model called.
5732
+ */
5733
+ message: () => {
5734
+ const loc = this._config.getLocale();
5735
+ return (loc.approvalAsk ?? "Run {tool}?").replace("{tool}", event.name);
5736
+ },
5737
+ // Functions, not strings, same as the question: these follow a language
5738
+ // switch while the request is open, which a resolved string cannot.
5739
+ options: [
5740
+ { value: "allow", label: () => this._config.getLocale().approveTool ?? "Approve", tone: "affirm" },
5741
+ { value: "deny", label: () => this._config.getLocale().rejectTool ?? "Reject", tone: "deny" }
5742
+ ],
5743
+ signal,
5744
+ target
5745
+ });
5746
+ if (answer.action !== "accept") return { approved: false };
5747
+ const content = answer.content;
5748
+ const instruction = content?.instruction?.trim();
5749
+ if (instruction) return { approved: false, instruction };
5750
+ return { approved: content?.option === "allow" };
5751
+ }
5752
+ /**
5753
+ * Resolve the auth for a provider: `options.keyResolver` takes precedence,
5754
+ * then the aparteGlobalConfig key channel (`setKeyProvider`) so a key registered
5755
+ * there reaches the request. One key source on the happy path.
5756
+ */
5757
+ async _resolveAuth(providerId) {
5758
+ if (this.options.keyResolver) {
5759
+ const resolved = await this.options.keyResolver(providerId);
5760
+ if (resolved) return resolved;
5761
+ }
5762
+ const key = await this._config.getKey(providerId);
5763
+ return key || void 0;
5764
+ }
5765
+ /**
5766
+ * The shared tail of send / retry / edit: attach the current tools, run the
5767
+ * request interceptor, honour `toolChoice: 'none'`, reset the abort flag,
5768
+ * dispatch `aparte-message-start`, run the agentic `_streamLoop`, then dispatch
5769
+ * `aparte-message-done` or route the error to the lifecycle handler.
5770
+ *
5771
+ * Callers own only their turn-specific prep (target resolution, history
5772
+ * building, appending the assistant placeholder) and hand the fully-built
5773
+ * `messages` here — one place for the provider→interceptor→toolChoice→stream
5774
+ * sequence, so it can't drift between the three entry points.
5775
+ */
5776
+ async _streamTurn(targetElement, messageId, provider, messages, modelId, authConfig, opts) {
5777
+ const registeredTools = this._toolsForCurrentModel();
5778
+ let baseRequest = {
5779
+ messages,
5780
+ modelId,
5781
+ stream: true,
5782
+ tools: registeredTools.length ? registeredTools : void 0,
5783
+ ...opts?.temperature !== void 0 ? { temperature: opts.temperature } : {}
5784
+ };
5785
+ if (this.options.requestInterceptor) {
5786
+ baseRequest = await this.options.requestInterceptor(baseRequest);
5787
+ }
5788
+ if (baseRequest.toolChoice === "none") {
5789
+ baseRequest = { ...baseRequest, tools: void 0 };
5790
+ }
5791
+ if (this._isAborted) {
5792
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5793
+ return;
5794
+ }
5795
+ dispatchLifecycleEvent(targetElement, "aparte-message-start", { messageId, role: "assistant" });
5796
+ try {
5797
+ const usage = await this._streamLoop(targetElement, messageId, provider, baseRequest, authConfig);
5798
+ if (this._isAborted) {
5799
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5800
+ } else {
5801
+ dispatchLifecycleEvent(targetElement, "aparte-message-done", { messageId, role: "assistant", usage });
5802
+ }
5803
+ } catch (error) {
5804
+ if (this._isAborted) {
5805
+ dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
5806
+ this._updateMessage(targetElement, messageId, { status: "completed" });
5807
+ return;
5808
+ }
5809
+ const aparteError = AparteError.from(error, AparteErrorCode.UNKNOWN_ERROR);
5810
+ this._handleLifecycleError(targetElement, messageId, aparteError);
5811
+ }
5812
+ }
5813
+ /**
5814
+ * Compact the current conversation: summarize all messages via the AI,
5815
+ * clear the viewport, then inject the summary as a single context message.
5816
+ *
5817
+ * Triggered programmatically or by dispatching `window.dispatchEvent(new CustomEvent('aparte-compact'))`.
5818
+ * Dispatches `aparte-compact-done` on window when complete, or `aparte-compact-error` on failure.
5819
+ */
5820
+ async compact(targetId) {
5821
+ let target = this._resolveTarget(targetId);
5822
+ if (target && typeof target.getMessages !== "function") target = null;
5823
+ if (!target) {
5824
+ window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No aparte-chat target found" } }));
5825
+ return;
5826
+ }
5827
+ const messages = target.getMessages?.() ?? [];
5828
+ if (messages.length === 0) {
5829
+ window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true } }));
5830
+ return;
5831
+ }
5832
+ const selector = this.options.compactionSelector ?? ((m) => ({ keep: [], drop: m }));
5833
+ const { keep, drop } = selector(messages);
5834
+ if (drop.length === 0) {
5835
+ window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: { skipped: true } }));
5836
+ return;
5837
+ }
5838
+ const config = this._config.getModelConfig();
5839
+ const providerId = config.defaultProvider;
5840
+ if (!providerId) {
5841
+ window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: "No provider configured" } }));
5842
+ return;
5843
+ }
5844
+ const provider = this._config.getAIProvider(providerId);
5845
+ if (!provider) {
5846
+ window.dispatchEvent(new CustomEvent("aparte-compact-error", { detail: { error: `Provider '${providerId}' not found` } }));
5847
+ return;
5848
+ }
5849
+ window.dispatchEvent(new CustomEvent("aparte-compact-start"));
5850
+ try {
5851
+ const authConfig = await this._resolveAuth(providerId);
5852
+ const historyMessages = drop.filter((m) => m.role === "user" || m.role === "assistant" && m.status === "completed").map((m) => ({
5853
+ role: m.role,
5854
+ content: this._extractText(m)
5855
+ })).filter((m) => contentToText(m.content).length > 0);
5856
+ const summarizeRequest = {
5857
+ messages: [
5858
+ {
5859
+ role: "system",
5860
+ content: "You are a conversation summarizer. Create a concise but complete summary of the conversation below. Capture: key topics, conclusions, decisions, ongoing tasks, and any context needed to continue the conversation. Write in third person. Be factual and brief. No preamble, just the summary."
5861
+ },
5862
+ ...historyMessages,
5863
+ {
5864
+ role: "user",
5865
+ content: "Please summarize this conversation."
5866
+ }
5867
+ ],
5868
+ modelId: config.defaultModel || "",
5869
+ stream: false
5870
+ };
5871
+ const compactController = new AbortController();
5872
+ this._streamController = compactController;
5703
5873
  const response = await this._config.getTransport().chat(
5704
5874
  provider,
5705
5875
  summarizeRequest,
@@ -6236,16 +6406,27 @@ ${content}
6236
6406
  this._activeToolControllers.delete(controller);
6237
6407
  }
6238
6408
  }
6409
+ /**
6410
+ * What the loop does after one tool call. THREE outcomes, because a boolean
6411
+ * conflated two questions that a refusal answers differently.
6412
+ *
6413
+ * - `'continue'` — run the next tool call of this turn.
6414
+ * - `'respond'` — skip this turn's remaining calls, then take another turn so the
6415
+ * model reads what happened. A human refusal: the model asked for
6416
+ * several calls and refusing one cannot license the others, but a
6417
+ * refusal it never reads is one the user has to retype as a message.
6418
+ * - `'halt'` — the run is over: an abort, a turn limit, a missing handler.
6419
+ */
6239
6420
  /**
6240
6421
  * Handle one `tool_use` stream event from {@link _streamLoop}: the built-in
6241
6422
  * `create_artifact`, per-tool renderer selection, the human-in-the-loop
6242
6423
  * approval gate, and running the registered handler (timeout / abort).
6243
6424
  * Mutates the shared `messages` / `toolCallsThisTurn` history in place and
6244
- * returns whether the agentic loop should keep going.
6425
+ * returns what the loop should do next.
6245
6426
  */
6246
6427
  async _handleToolUseEvent(event, ctx) {
6247
6428
  const { targetElement, messageId, messages, toolCallsThisTurn, precedingText, artifactProgress, turns, globalMaxTurns } = ctx;
6248
- let continueLoop = true;
6429
+ let flow = "continue";
6249
6430
  toolCallsThisTurn.push({ id: event.id, name: event.name, input: event.input });
6250
6431
  if (event.name === "create_artifact") {
6251
6432
  const input = event.input;
@@ -6271,7 +6452,7 @@ ${content}
6271
6452
  content: "Artifact created successfully.",
6272
6453
  toolCallId: event.id
6273
6454
  });
6274
- return { continueLoop: true };
6455
+ return { flow: "continue" };
6275
6456
  }
6276
6457
  const toolSeg = {
6277
6458
  id: `tool-${event.id}`,
@@ -6305,7 +6486,7 @@ ${content}
6305
6486
  if (turns >= effectiveMaxTurns) {
6306
6487
  console.warn(`[AparteClient] Tool "${event.name}" maxTurns (${effectiveMaxTurns}) reached.`);
6307
6488
  targetElement.updateSegment?.(toolSeg.id, { status: "aborted" });
6308
- return { continueLoop: false };
6489
+ return { flow: "halt" };
6309
6490
  }
6310
6491
  const handler = this._config.getToolHandler(event.name);
6311
6492
  if (handler) {
@@ -6320,14 +6501,24 @@ ${content}
6320
6501
  input: event.input
6321
6502
  });
6322
6503
  let decision;
6323
- const resolveApproval = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
6504
+ const resolveApproval = this.options.approvalResolver ?? ((call, sig) => this._askForApproval(call, sig, targetElement));
6324
6505
  try {
6325
- decision = await resolveApproval(event.id, approvalController.signal);
6506
+ decision = await resolveApproval(event, approvalController.signal);
6507
+ } catch (err) {
6508
+ if (err?.name === "AbortError") {
6509
+ targetElement.updateSegment?.(toolSeg.id, { status: "aborted" });
6510
+ return { flow: "halt" };
6511
+ }
6512
+ throw err;
6326
6513
  } finally {
6327
6514
  this._activeToolControllers.delete(approvalController);
6328
6515
  }
6516
+ if (approvalController.signal.aborted) {
6517
+ targetElement.updateSegment?.(toolSeg.id, { status: "aborted" });
6518
+ return { flow: "halt" };
6519
+ }
6329
6520
  if (!decision.approved) {
6330
- const rejection = "Tool execution was rejected by the user.";
6521
+ const rejection = decision.instruction ? `The user rejected this tool call and said: ${decision.instruction}` : "Tool execution was rejected by the user.";
6331
6522
  targetElement.updateSegment?.(toolSeg.id, { status: "rejected", result: rejection });
6332
6523
  const existingToolCallMsg = messages.find(
6333
6524
  (m) => m.role === "tool_call" && m.toolCalls?.some((tc) => tc.id === event.id)
@@ -6341,7 +6532,7 @@ ${content}
6341
6532
  });
6342
6533
  }
6343
6534
  messages.push({ role: "tool_result", content: rejection, toolCallId: event.id });
6344
- return { continueLoop: false };
6535
+ return { flow: "respond" };
6345
6536
  }
6346
6537
  if (decision.payload && typeof decision.payload === "object" && !Array.isArray(decision.payload)) {
6347
6538
  effectiveInput = { ...event.input, ...decision.payload };
@@ -6381,7 +6572,7 @@ ${content}
6381
6572
  } catch (err) {
6382
6573
  if (err?.name === "AbortError") {
6383
6574
  targetElement.updateSegment?.(toolSeg.id, { status: "aborted" });
6384
- continueLoop = false;
6575
+ flow = "halt";
6385
6576
  } else {
6386
6577
  throw err;
6387
6578
  }
@@ -6392,9 +6583,9 @@ ${content}
6392
6583
  } else {
6393
6584
  console.warn(`[AparteClient] No handler registered for tool "${event.name}"`);
6394
6585
  targetElement.updateSegment?.(toolSeg.id, { status: "aborted" });
6395
- continueLoop = false;
6586
+ flow = "halt";
6396
6587
  }
6397
- return { continueLoop };
6588
+ return { flow };
6398
6589
  }
6399
6590
  /**
6400
6591
  * Stream loop: runs one provider.chat() call and repeats if a tool was called.
@@ -6419,6 +6610,7 @@ ${content}
6419
6610
  targetElement.addSegment?.(seg);
6420
6611
  }
6421
6612
  while (continueLoop) {
6613
+ let endTurnEarly = false;
6422
6614
  if (this._isAborted) {
6423
6615
  dispatchLifecycleEvent(targetElement, "aparte-message-aborted", { messageId });
6424
6616
  break;
@@ -6624,7 +6816,8 @@ ${content}
6624
6816
  { id: event.id, name: event.name, input: event.input },
6625
6817
  { targetElement, messageId, messages, toolCallsThisTurn, precedingText, artifactProgress, turns, globalMaxTurns }
6626
6818
  );
6627
- if (!toolResult.continueLoop) continueLoop = false;
6819
+ if (toolResult.flow !== "continue") endTurnEarly = true;
6820
+ if (toolResult.flow === "halt") continueLoop = false;
6628
6821
  break;
6629
6822
  }
6630
6823
  case "error":
@@ -6639,7 +6832,7 @@ ${content}
6639
6832
  break;
6640
6833
  }
6641
6834
  }
6642
- if (!continueLoop) break;
6835
+ if (endTurnEarly) break;
6643
6836
  }
6644
6837
  finalizeXmlArtifact(xmlCtx, { targetElement, messageId, artifactProgress, artifactXmlHint, textParser, streamingSegmentIds });
6645
6838
  const finals = textParser.finalize();
@@ -6681,769 +6874,480 @@ ${content}
6681
6874
  targetElement.addSegment?.(s);
6682
6875
  } else if ("content" in s) {
6683
6876
  targetElement.updateSegment?.(s.id, segmentContentUpdate(s));
6684
- }
6685
- if (s.type === "artifact") {
6686
- dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
6687
- }
6688
- }
6689
- if (toolCallsThisTurn.length === 0) {
6690
- if (pipeline && pipelineIndex < pipeline.length - 1) {
6691
- if (precedingText.trim()) {
6692
- messages.push({ role: "assistant", content: precedingText.trim() });
6693
- }
6694
- pipelineIndex++;
6695
- const pwId = `pw-${uuid()}`;
6696
- targetElement.addSegment?.({ id: pwId, type: "pipeline-waiting" });
6697
- } else {
6698
- continueLoop = false;
6699
- }
6700
- }
6701
- } finally {
6702
- if (!streamDrained) {
6703
- try {
6704
- await reader.cancel();
6705
- } catch {
6706
- }
6707
- }
6708
- reader.releaseLock();
6709
- }
6710
- }
6711
- this._updateMessage(targetElement, messageId, { status: "completed" });
6712
- if (lastUsage) {
6713
- try {
6714
- targetElement.setUsage?.(messageId, lastUsage);
6715
- } catch {
6716
- }
6717
- }
6718
- return lastUsage;
6719
- }
6720
- /**
6721
- * Delegate `_streamLoop`'s agentic loop to an injected {@link AparteStreamRunner},
6722
- * rendering its DOM-free events through {@link createStreamAdapter}.
6723
- * Builds the runner's dependencies from this client's config / provider /
6724
- * transport; the adapter reproduces the inline loop's `targetElement.*` calls
6725
- * (validated by the engine `stream-parity` suite). Leading writes (status
6726
- * streaming, prefix segments) mirror the inline path; the runner's `run-start`
6727
- * re-asserts `streaming` idempotently.
6728
- */
6729
- async _runViaStreamRunner(streamRunner, targetElement, messageId, provider, baseRequest, authConfig, streamController) {
6730
- const signal = streamController.signal;
6731
- this._updateMessage(targetElement, messageId, { status: "streaming" });
6732
- const prefixSegments = baseRequest._meta?.["prefixSegments"];
6733
- if (prefixSegments?.length) {
6734
- for (const seg of prefixSegments) targetElement.addSegment?.(seg);
6735
- }
6736
- const artifactHint = baseRequest._meta?.artifactHint;
6737
- const emitter = createStreamAdapter({
6738
- target: targetElement,
6739
- config: this._config,
6740
- messageId,
6741
- artifactHint
6742
- });
6743
- const transportCall = async (request) => {
6744
- const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal });
6745
- return typeof response === "string" ? response : readableToAsyncIterable(response, signal);
6746
- };
6747
- const toolLookup = (name) => {
6748
- const handler = this._config.getToolHandler(name);
6749
- if (!handler) return void 0;
6750
- const context = { target: targetElement, config: this._config };
6751
- return (call, sig) => handler(call, sig, context);
6752
- };
6753
- const toolConfigLookup = (name) => {
6754
- const tool = this._config.getTools().find((t) => t.name === name);
6755
- return tool ? { maxTurns: tool.maxTurns, needsApproval: tool.needsApproval } : void 0;
6756
- };
6757
- const approvalResolver = this.options.approvalResolver ?? ((id, sig) => this._awaitToolDecision(id, sig, targetElement));
6758
- const usage = await streamRunner({
6759
- messageId,
6760
- baseRequest,
6761
- transportCall,
6762
- toolLookup,
6763
- toolConfigLookup,
6764
- approvalResolver,
6765
- emitter,
6766
- signal,
6767
- maxTurns: this.options.maxTurns,
6768
- // Forwarded, because the option's own JSDoc promises it means one thing
6769
- // whichever loop runs — and the fix that introduced it only wired the
6770
- // inline path, so setting it was silently ignored here. Exactly the
6771
- // asymmetry the option was added to remove.
6772
- toolTimeoutMs: this.options.toolTimeoutMs,
6773
- // Match the inline loop's id conventions: prefixed artifact ids, but a
6774
- // BARE uuid for the synthetic tool (the adapter renders `tool-<id>`).
6775
- idGen: (prefix) => prefix === "synthetic-tool" ? uuid() : `${prefix}-${uuid()}`
6776
- });
6777
- return usage ?? void 0;
6778
- }
6779
- /**
6780
- * Updates an existing message or appends a new error if ID lookup fails
6781
- */
6782
- _updateMessage(target, messageId, updates) {
6783
- if (target.updateMessage) {
6784
- target.updateMessage(messageId, updates);
6785
- }
6786
- }
6787
- /**
6788
- * Gracefully handles errors by updating the current message instead of duplicating it
6789
- */
6790
- _handleLifecycleError(target, messageId, error) {
6791
- const errorSegment = {
6792
- id: `error-${uuid()}`,
6793
- type: "error",
6794
- content: error.message,
6795
- details: error.code
6796
- };
6797
- if (target.updateMessage) {
6798
- const rendered = target.getMessages?.()?.find((m) => m.id === messageId)?.segments ?? [];
6799
- target.updateMessage(messageId, {
6800
- status: "error",
6801
- segments: [...rendered, errorSegment]
6802
- });
6803
- } else if (target.appendMessage) {
6804
- target.appendMessage({
6805
- id: messageId,
6806
- role: "assistant",
6807
- status: "error",
6808
- timestamp: Date.now(),
6809
- segments: [errorSegment]
6810
- });
6811
- }
6812
- dispatchLifecycleEvent(target, "aparte-message-error", { messageId, error });
6813
- }
6814
- /** Warn once per client for an unrecognised stream event, then carry on. */
6815
- _warnedUnknownEvents = /* @__PURE__ */ new Set();
6816
- _warnUnknownStreamEvent(event) {
6817
- const type = String(event?.type ?? "undefined");
6818
- if (this._warnedUnknownEvents.has(type)) return;
6819
- this._warnedUnknownEvents.add(type);
6820
- console.warn(
6821
- `[AparteClient] Ignoring unrecognised stream event "${type}". The reply is unaffected; this usually means the provider or its SDK emits a part this version of aparté does not map yet.`
6822
- );
6823
- }
6824
- }
6825
- function findHead(node) {
6826
- if (node.next) return findHead(node.next);
6827
- if ("current" in node) return node;
6828
- return null;
6829
- }
6830
- class AparteMessageRepository {
6831
- _nodes = /* @__PURE__ */ new Map();
6832
- _head = null;
6833
- _root = { next: null, children: [] };
6834
- // ─── Public read API ────────────────────────────────────────────────────
6835
- /** ID of the currently active leaf message */
6836
- get headId() {
6837
- return this._head?.current.id ?? null;
6838
- }
6839
- /**
6840
- * Returns the messages on the currently active path, root → head.
6841
- * O(depth).
6842
- */
6843
- getMessages() {
6844
- const depth = this._head?.level ?? -1;
6845
- if (depth < 0) return [];
6846
- const messages = new Array(depth + 1);
6847
- for (let cur = this._head; cur; cur = cur.prev) {
6848
- messages[cur.level] = cur.current;
6849
- }
6850
- return messages;
6851
- }
6852
- /**
6853
- * Returns metadata about a specific message, or `undefined` if not found.
6854
- *
6855
- * Read methods (`getMessage`, `getMessageById`, `getBranches`) all return
6856
- * `undefined` / `[]` for unknown ids — never throw — so callers can do a
6857
- * single existence check at the top of a flow rather than wrapping every
6858
- * call in try/catch.
6859
- */
6860
- getMessage(id) {
6861
- const node = this._nodes.get(id);
6862
- if (!node) return void 0;
6863
- return {
6864
- parentId: node.prev?.current.id ?? null,
6865
- message: node.current,
6866
- index: node.level
6867
- };
6868
- }
6869
- /**
6870
- * Returns the mutable message reference, or `undefined` if not found.
6871
- * Mutations to the returned object are reflected in `getMessages()`.
6872
- */
6873
- getMessageById(id) {
6874
- return this._nodes.get(id)?.current;
6875
- }
6876
- /**
6877
- * Returns the IDs of all siblings of `messageId`
6878
- * (i.e. all children of its parent, including itself).
6879
- * Use this to build branch-picker UI.
6880
- */
6881
- getBranches(messageId) {
6882
- const node = this._nodes.get(messageId);
6883
- if (!node) return [];
6884
- const parent = node.prev ?? this._root;
6885
- return [...parent.children];
6886
- }
6887
- // ─── Public write API ───────────────────────────────────────────────────
6888
- /**
6889
- * Add a new message as a child of `parentId` (null = root).
6890
- * If a message with the same id already exists, updates its data
6891
- * and re-links it under the correct parent.
6892
- */
6893
- addOrUpdateMessage(parentId, message) {
6894
- const existing = this._nodes.get(message.id);
6895
- const parentNode = parentId ? this._nodes.get(parentId) : void 0;
6896
- if (parentId !== null && parentNode === void 0) {
6897
- throw new Error(`AparteMessageRepository: parent "${parentId}" not found.`);
6898
- }
6899
- const parent = parentNode ?? null;
6900
- if (existing) {
6901
- existing.current = message;
6902
- this._relink(parent, existing);
6903
- return;
6904
- }
6905
- const node = {
6906
- prev: parent,
6907
- next: null,
6908
- children: [],
6909
- current: message,
6910
- level: parent ? parent.level + 1 : 0
6911
- };
6912
- this._nodes.set(message.id, node);
6913
- this._link(parent, node);
6914
- if (this._head === parent || this._head === null && parent === null) {
6915
- this._head = node;
6916
- }
6917
- }
6918
- /**
6919
- * Switch the active branch to `messageId`.
6920
- * Updates `next` on the parent chain so getMessages() reflects the change.
6921
- * Advances `head` to the deepest active node in the new branch.
6922
- *
6923
- * Silent no-op for unknown ids — mutation methods (`switchToBranch`,
6924
- * `resetHead`, `updateMessage`, `clearChildren`) never throw.
6925
- */
6926
- switchToBranch(messageId) {
6927
- const node = this._nodes.get(messageId);
6928
- if (!node) return;
6929
- const parentOrRoot = node.prev ?? this._root;
6930
- parentOrRoot.next = node;
6931
- this._head = findHead(node);
6932
- }
6933
- /**
6934
- * Remove all descendants of `messageId` (inclusive) and set `head` to
6935
- * the parent of `messageId`. Used by edit/truncate flows.
6936
- *
6937
- * @param messageId The first message to remove
6938
- * @param newParentId Optional explicit parent to set head to
6939
- */
6940
- resetHead(messageId) {
6941
- const node = this._nodes.get(messageId);
6942
- if (!node) return;
6943
- this._deleteDescendants(node);
6944
- const parentOrRoot = node.prev ?? this._root;
6945
- parentOrRoot.children = parentOrRoot.children.filter((id) => id !== messageId);
6946
- if (parentOrRoot.next === node) {
6947
- parentOrRoot.next = null;
6877
+ }
6878
+ if (s.type === "artifact") {
6879
+ dispatchArtifactLifecycle(targetElement, messageId, s, artifactProgress, true);
6880
+ }
6881
+ }
6882
+ if (toolCallsThisTurn.length === 0) {
6883
+ if (pipeline && pipelineIndex < pipeline.length - 1) {
6884
+ if (precedingText.trim()) {
6885
+ messages.push({ role: "assistant", content: precedingText.trim() });
6886
+ }
6887
+ pipelineIndex++;
6888
+ const pwId = `pw-${uuid()}`;
6889
+ targetElement.addSegment?.({ id: pwId, type: "pipeline-waiting" });
6890
+ } else {
6891
+ continueLoop = false;
6892
+ }
6893
+ }
6894
+ } finally {
6895
+ if (!streamDrained) {
6896
+ try {
6897
+ await reader.cancel();
6898
+ } catch {
6899
+ }
6900
+ }
6901
+ reader.releaseLock();
6902
+ }
6948
6903
  }
6949
- this._nodes.delete(messageId);
6950
- this._head = node.prev;
6951
- }
6952
- /**
6953
- * Update a message's data without changing its position in the tree.
6954
- */
6955
- updateMessage(messageId, updates) {
6956
- const node = this._nodes.get(messageId);
6957
- if (!node) return;
6958
- node.current = { ...node.current, ...updates };
6904
+ this._updateMessage(targetElement, messageId, { status: "completed" });
6905
+ if (lastUsage) {
6906
+ try {
6907
+ targetElement.setUsage?.(messageId, lastUsage);
6908
+ } catch {
6909
+ }
6910
+ }
6911
+ return lastUsage;
6959
6912
  }
6960
6913
  /**
6961
- * Remove ALL children of `parentId` (and their descendants) and set head
6962
- * to `parentId`. Used by edit to discard every previous response before
6963
- * re-generating ensures the new response starts with sibling count = 1.
6914
+ * Delegate `_streamLoop`'s agentic loop to an injected {@link AparteStreamRunner},
6915
+ * rendering its DOM-free events through {@link createStreamAdapter}.
6916
+ * Builds the runner's dependencies from this client's config / provider /
6917
+ * transport; the adapter reproduces the inline loop's `targetElement.*` calls
6918
+ * (validated by the engine `stream-parity` suite). Leading writes (status
6919
+ * streaming, prefix segments) mirror the inline path; the runner's `run-start`
6920
+ * re-asserts `streaming` idempotently.
6964
6921
  */
6965
- clearChildren(parentId) {
6966
- const parent = this._nodes.get(parentId);
6967
- if (!parent) return;
6968
- for (const childId of [...parent.children]) {
6969
- const child = this._nodes.get(childId);
6970
- if (child) {
6971
- this._deleteDescendants(child);
6972
- this._nodes.delete(childId);
6973
- }
6922
+ async _runViaStreamRunner(streamRunner, targetElement, messageId, provider, baseRequest, authConfig, streamController) {
6923
+ const signal = streamController.signal;
6924
+ this._updateMessage(targetElement, messageId, { status: "streaming" });
6925
+ const prefixSegments = baseRequest._meta?.["prefixSegments"];
6926
+ if (prefixSegments?.length) {
6927
+ for (const seg of prefixSegments) targetElement.addSegment?.(seg);
6974
6928
  }
6975
- parent.children = [];
6976
- parent.next = null;
6977
- this._head = parent;
6978
- }
6979
- /** Remove all messages and reset to empty state. */
6980
- clear() {
6981
- this._nodes.clear();
6982
- this._head = null;
6983
- this._root = { next: null, children: [] };
6929
+ const artifactHint = baseRequest._meta?.artifactHint;
6930
+ const emitter = createStreamAdapter({
6931
+ target: targetElement,
6932
+ config: this._config,
6933
+ messageId,
6934
+ artifactHint
6935
+ });
6936
+ const transportCall = async (request) => {
6937
+ const response = await this._config.getTransport().chat(provider, request, authConfig, { providerId: provider.id, signal });
6938
+ return typeof response === "string" ? response : readableToAsyncIterable(response, signal);
6939
+ };
6940
+ const toolLookup = (name) => {
6941
+ const handler = this._config.getToolHandler(name);
6942
+ if (!handler) return void 0;
6943
+ const context = { target: targetElement, config: this._config };
6944
+ return (call, sig) => handler(call, sig, context);
6945
+ };
6946
+ const toolConfigLookup = (name) => {
6947
+ const tool = this._config.getTools().find((t) => t.name === name);
6948
+ return tool ? { maxTurns: tool.maxTurns, needsApproval: tool.needsApproval } : void 0;
6949
+ };
6950
+ const approvalResolver = this.options.approvalResolver ?? ((call, sig) => this._askForApproval(call, sig, targetElement));
6951
+ const usage = await streamRunner({
6952
+ messageId,
6953
+ baseRequest,
6954
+ transportCall,
6955
+ toolLookup,
6956
+ toolConfigLookup,
6957
+ approvalResolver,
6958
+ emitter,
6959
+ signal,
6960
+ maxTurns: this.options.maxTurns,
6961
+ // Forwarded, because the option's own JSDoc promises it means one thing
6962
+ // whichever loop runs — and the fix that introduced it only wired the
6963
+ // inline path, so setting it was silently ignored here. Exactly the
6964
+ // asymmetry the option was added to remove.
6965
+ toolTimeoutMs: this.options.toolTimeoutMs,
6966
+ // Match the inline loop's id conventions: prefixed artifact ids, but a
6967
+ // BARE uuid for the synthetic tool (the adapter renders `tool-<id>`).
6968
+ idGen: (prefix) => prefix === "synthetic-tool" ? uuid() : `${prefix}-${uuid()}`
6969
+ });
6970
+ return usage ?? void 0;
6984
6971
  }
6985
6972
  /**
6986
- * Import a previously-exported snapshot, rebuilding the full tree and
6987
- * restoring the active head. Replaces any existing content (calls `clear()`
6988
- * first). Messages must be in topological order (parent before child) —
6989
- * this is guaranteed by `export()` since `_nodes` is an insertion-order
6990
- * Map and parents are always inserted before their children.
6973
+ * Updates an existing message or appends a new error if ID lookup fails
6991
6974
  */
6992
- import(exported) {
6993
- this.clear();
6994
- for (const { message, parentId } of exported.messages) {
6995
- this.addOrUpdateMessage(parentId, message);
6996
- }
6997
- if (exported.headId) {
6998
- this.switchToBranch(exported.headId);
6975
+ _updateMessage(target, messageId, updates) {
6976
+ if (target.updateMessage) {
6977
+ target.updateMessage(messageId, updates);
6999
6978
  }
7000
6979
  }
7001
6980
  /**
7002
- * Export the full tree for serialisation or debugging.
7003
- * Compatible with the assistant-ui ExportedMessageRepository format.
6981
+ * Gracefully handles errors by updating the current message instead of duplicating it
7004
6982
  */
7005
- export() {
7006
- const messages = [];
7007
- this._nodes.forEach((node) => {
7008
- messages.push({
7009
- message: node.current,
7010
- parentId: node.prev?.current.id ?? null
6983
+ _handleLifecycleError(target, messageId, error) {
6984
+ const errorSegment = {
6985
+ id: `error-${uuid()}`,
6986
+ type: "error",
6987
+ content: error.message,
6988
+ details: error.code
6989
+ };
6990
+ if (target.updateMessage) {
6991
+ const rendered = target.getMessages?.()?.find((m) => m.id === messageId)?.segments ?? [];
6992
+ target.updateMessage(messageId, {
6993
+ status: "error",
6994
+ segments: [...rendered, errorSegment]
7011
6995
  });
7012
- });
7013
- return { headId: this.headId, messages };
7014
- }
7015
- // ─── Private helpers ────────────────────────────────────────────────────
7016
- _link(parent, child) {
7017
- const parentOrRoot = parent ?? this._root;
7018
- parentOrRoot.children = [...parentOrRoot.children, child.current.id];
7019
- if (parentOrRoot.next === null) {
7020
- parentOrRoot.next = child;
7021
- }
7022
- child.prev = parent;
7023
- }
7024
- _relink(newParent, child) {
7025
- const oldParentOrRoot = child.prev ?? this._root;
7026
- if (oldParentOrRoot !== (newParent ?? this._root)) {
7027
- oldParentOrRoot.children = oldParentOrRoot.children.filter((id) => id !== child.current.id);
7028
- if (oldParentOrRoot.next === child) {
7029
- const nextId = oldParentOrRoot.children[0];
7030
- oldParentOrRoot.next = nextId ? this._nodes.get(nextId) ?? null : null;
7031
- }
7032
- }
7033
- child.prev = newParent;
7034
- const newParentOrRoot = newParent ?? this._root;
7035
- if (!newParentOrRoot.children.includes(child.current.id)) {
7036
- newParentOrRoot.children = [...newParentOrRoot.children, child.current.id];
7037
- }
7038
- if (newParentOrRoot.next === null) {
7039
- newParentOrRoot.next = child;
7040
- }
7041
- this._updateLevels(child, newParent ? newParent.level + 1 : 0);
7042
- }
7043
- _updateLevels(node, newLevel) {
7044
- node.level = newLevel;
7045
- for (const childId of node.children) {
7046
- const childNode = this._nodes.get(childId);
7047
- if (childNode) this._updateLevels(childNode, newLevel + 1);
7048
- }
7049
- }
7050
- _deleteDescendants(node) {
7051
- for (const childId of node.children) {
7052
- const child = this._nodes.get(childId);
7053
- if (child) {
7054
- this._deleteDescendants(child);
7055
- this._nodes.delete(childId);
7056
- }
7057
- }
7058
- node.children = [];
7059
- node.next = null;
7060
- }
7061
- }
7062
- const APARTE_DEFAULT_UI_EVENTS = [
7063
- "aparte-send",
7064
- "aparte-action",
7065
- "aparte-retry",
7066
- "aparte-edit",
7067
- "aparte-branch-navigate",
7068
- "aparte-composer-change",
7069
- "aparte-path-changed"
7070
- ];
7071
- function applyElementProps(el2, props, transformValue = (value) => value) {
7072
- for (const [key, value] of Object.entries(props)) {
7073
- if (key.startsWith("--")) {
7074
- el2.style.setProperty(key, String(value));
7075
- } else if (key.startsWith("on")) {
7076
- el2.removeAttribute(key);
7077
- } else if (value === null || value === void 0 || value === false) {
7078
- el2.removeAttribute(key);
7079
- } else if (value === true) {
7080
- el2.setAttribute(key, "");
7081
- } else if (typeof value === "object" || typeof value === "function") {
7082
- el2[key] = transformValue(value);
7083
- } else {
7084
- el2.setAttribute(key, String(value));
7085
- }
7086
- }
7087
- }
7088
- function isAwaitingReply(message) {
7089
- if (message.status === "streaming" || message.status === "pending") return true;
7090
- return message.status === void 0 && message.role === "assistant" && !message.content?.trim() && !message.segments?.length;
7091
- }
7092
- let _relabels = null;
7093
- function onRelabel(fn) {
7094
- _relabels?.push(fn);
7095
- }
7096
- function el(tag, className, text) {
7097
- const node = document.createElement(tag);
7098
- if (className) node.className = className;
7099
- if (text != null) node.textContent = text;
7100
- return node;
7101
- }
7102
- function fieldHeader(parent, field, skipTitle) {
7103
- let titleId;
7104
- if (field.title && field.title.trim() !== skipTitle?.trim()) {
7105
- const title = el("p", "aparte-elic-title", field.title);
7106
- titleId = `elic-title-${uuid()}`;
7107
- title.id = titleId;
7108
- parent.appendChild(title);
6996
+ } else if (target.appendMessage) {
6997
+ target.appendMessage({
6998
+ id: messageId,
6999
+ role: "assistant",
7000
+ status: "error",
7001
+ timestamp: Date.now(),
7002
+ segments: [errorSegment]
7003
+ });
7004
+ }
7005
+ dispatchLifecycleEvent(target, "aparte-message-error", { messageId, error });
7006
+ }
7007
+ /** Warn once per client for an unrecognised stream event, then carry on. */
7008
+ _warnedUnknownEvents = /* @__PURE__ */ new Set();
7009
+ _warnUnknownStreamEvent(event) {
7010
+ const type = String(event?.type ?? "undefined");
7011
+ if (this._warnedUnknownEvents.has(type)) return;
7012
+ this._warnedUnknownEvents.add(type);
7013
+ console.warn(
7014
+ `[AparteClient] Ignoring unrecognised stream event "${type}". The reply is unaffected; this usually means the provider or its SDK emits a part this version of aparté does not map yet.`
7015
+ );
7109
7016
  }
7110
- if (field.description) parent.appendChild(el("p", "aparte-elic-desc", field.description));
7111
- return titleId;
7112
7017
  }
7113
- function labelGroup(list, opts) {
7114
- list.setAttribute("role", opts.multiple ? "group" : "radiogroup");
7115
- if (opts.titleId) list.setAttribute("aria-labelledby", opts.titleId);
7116
- else if (opts.fallbackLabel) list.setAttribute("aria-label", opts.fallbackLabel);
7018
+ function findHead(node) {
7019
+ if (node.next) return findHead(node.next);
7020
+ if ("current" in node) return node;
7021
+ return null;
7117
7022
  }
7118
- function buildEnumField(field, onChange, fallbackLabel) {
7119
- const wrap = el("div", "aparte-elic-field aparte-elic-enum");
7120
- const titleId = fieldHeader(wrap, field, fallbackLabel);
7121
- const list = el("div", "aparte-elic-options");
7122
- labelGroup(list, { multiple: field.multiple, titleId, fallbackLabel });
7123
- const name = `elic-${uuid()}`;
7124
- const type = field.multiple ? "checkbox" : "radio";
7125
- const allowOther = field.allowOther ?? contextConfig().getElicitationOptions().allowOther;
7126
- const defaults = new Set(Array.isArray(field.default) ? field.default : field.default != null ? [field.default] : []);
7127
- const buildOption = (value, label, description, recommended) => {
7128
- const row = el("label", "aparte-elic-option" + (recommended ? " aparte-elic-option--recommended" : ""));
7129
- const control = el("input", "aparte-elic-control");
7130
- control.type = type;
7131
- control.name = name;
7132
- control.value = value;
7133
- if (defaults.has(value)) control.checked = true;
7134
- const body = el("span", "aparte-elic-option-body");
7135
- body.appendChild(el("span", "aparte-elic-option-title", label));
7136
- if (description) body.appendChild(el("span", "aparte-elic-option-desc", description));
7137
- row.append(control, body);
7138
- return row;
7139
- };
7140
- for (const opt of field.options) {
7141
- list.appendChild(buildOption(opt.value, opt.label ?? opt.value, opt.description, opt.recommended));
7023
+ class AparteMessageRepository {
7024
+ _nodes = /* @__PURE__ */ new Map();
7025
+ _head = null;
7026
+ _root = { next: null, children: [] };
7027
+ // ─── Public read API ────────────────────────────────────────────────────
7028
+ /** ID of the currently active leaf message */
7029
+ get headId() {
7030
+ return this._head?.current.id ?? null;
7142
7031
  }
7143
- let otherText = null;
7144
- if (allowOther) {
7145
- const row = el("label", "aparte-elic-option aparte-elic-option--other");
7146
- const control = el("input", "aparte-elic-control");
7147
- control.type = type;
7148
- control.name = name;
7149
- control.value = "__other__";
7150
- const body = el("span", "aparte-elic-option-body");
7151
- const t = contextConfig();
7152
- const otherTitle = el("span", "aparte-elic-option-title", t.t("elicitationOther"));
7153
- body.appendChild(otherTitle);
7154
- otherText = el("input", "aparte-elic-other-input");
7155
- otherText.type = "text";
7156
- otherText.placeholder = t.t("elicitationOtherPlaceholder");
7157
- otherText.style.display = "none";
7158
- otherText.setAttribute("aria-label", t.t("elicitationOtherLabel"));
7159
- const otherInput = otherText;
7160
- onRelabel(() => {
7161
- const now = contextConfig();
7162
- otherTitle.textContent = now.t("elicitationOther");
7163
- otherInput.placeholder = now.t("elicitationOtherPlaceholder");
7164
- otherInput.setAttribute("aria-label", now.t("elicitationOtherLabel"));
7165
- });
7166
- body.appendChild(otherText);
7167
- row.append(control, body);
7168
- list.appendChild(row);
7032
+ /**
7033
+ * Returns the messages on the currently active path, root → head.
7034
+ * O(depth).
7035
+ */
7036
+ getMessages() {
7037
+ const depth = this._head?.level ?? -1;
7038
+ if (depth < 0) return [];
7039
+ const messages = new Array(depth + 1);
7040
+ for (let cur = this._head; cur; cur = cur.prev) {
7041
+ messages[cur.level] = cur.current;
7042
+ }
7043
+ return messages;
7169
7044
  }
7170
- wrap.appendChild(list);
7171
- const controls = () => Array.from(list.querySelectorAll(".aparte-elic-control"));
7172
- const other = () => list.querySelector('input[value="__other__"]');
7173
- list.addEventListener("change", () => {
7174
- const o = other();
7175
- if (o && otherText) {
7176
- otherText.style.display = o.checked ? "" : "none";
7177
- if (o.checked) otherText.focus();
7045
+ /**
7046
+ * Returns metadata about a specific message, or `undefined` if not found.
7047
+ *
7048
+ * Read methods (`getMessage`, `getMessageById`, `getBranches`) all return
7049
+ * `undefined` / `[]` for unknown ids — never throw — so callers can do a
7050
+ * single existence check at the top of a flow rather than wrapping every
7051
+ * call in try/catch.
7052
+ */
7053
+ getMessage(id) {
7054
+ const node = this._nodes.get(id);
7055
+ if (!node) return void 0;
7056
+ return {
7057
+ parentId: node.prev?.current.id ?? null,
7058
+ message: node.current,
7059
+ index: node.level
7060
+ };
7061
+ }
7062
+ /**
7063
+ * Returns the mutable message reference, or `undefined` if not found.
7064
+ * Mutations to the returned object are reflected in `getMessages()`.
7065
+ */
7066
+ getMessageById(id) {
7067
+ return this._nodes.get(id)?.current;
7068
+ }
7069
+ /**
7070
+ * Returns the IDs of all siblings of `messageId`
7071
+ * (i.e. all children of its parent, including itself).
7072
+ * Use this to build branch-picker UI.
7073
+ */
7074
+ getBranches(messageId) {
7075
+ const node = this._nodes.get(messageId);
7076
+ if (!node) return [];
7077
+ const parent = node.prev ?? this._root;
7078
+ return [...parent.children];
7079
+ }
7080
+ // ─── Public write API ───────────────────────────────────────────────────
7081
+ /**
7082
+ * Add a new message as a child of `parentId` (null = root).
7083
+ * If a message with the same id already exists, updates its data
7084
+ * and re-links it under the correct parent.
7085
+ */
7086
+ addOrUpdateMessage(parentId, message) {
7087
+ const existing = this._nodes.get(message.id);
7088
+ const parentNode = parentId ? this._nodes.get(parentId) : void 0;
7089
+ if (parentId !== null && parentNode === void 0) {
7090
+ throw new Error(`AparteMessageRepository: parent "${parentId}" not found.`);
7178
7091
  }
7179
- onChange();
7180
- });
7181
- otherText?.addEventListener("input", onChange);
7182
- const getValue = () => {
7183
- const otherVal = other()?.checked && otherText?.value.trim() ? otherText.value.trim() : "";
7184
- if (field.multiple) {
7185
- const vals = controls().filter((c) => c.checked && c.value !== "__other__").map((c) => c.value);
7186
- if (otherVal) vals.push(otherVal);
7187
- return vals;
7092
+ const parent = parentNode ?? null;
7093
+ if (existing) {
7094
+ existing.current = message;
7095
+ this._relink(parent, existing);
7096
+ return;
7188
7097
  }
7189
- const checked = controls().find((c) => c.checked);
7190
- if (!checked) return "";
7191
- return checked.value === "__other__" ? otherVal : checked.value;
7192
- };
7193
- return {
7194
- el: wrap,
7195
- getValue,
7196
- isComplete: () => {
7197
- const v = getValue();
7198
- return field.multiple ? v.length > 0 : v !== "";
7199
- },
7200
- focus: () => controls()[0]?.focus()
7201
- };
7202
- }
7203
- function buildBooleanField(field, onChange, fallbackLabel) {
7204
- const wrap = el("div", "aparte-elic-field aparte-elic-boolean");
7205
- const titleId = fieldHeader(wrap, field, fallbackLabel);
7206
- const list = el("div", "aparte-elic-options");
7207
- labelGroup(list, { titleId, fallbackLabel });
7208
- const name = `elic-${uuid()}`;
7209
- const mk = (val, label, defaulted) => {
7210
- const row = el("label", "aparte-elic-option");
7211
- const control = el("input", "aparte-elic-control");
7212
- control.type = "radio";
7213
- control.name = name;
7214
- control.value = val;
7215
- if (field.default != null && String(field.default) === val) control.checked = true;
7216
- const body = el("span", "aparte-elic-option-body");
7217
- const title = el("span", "aparte-elic-option-title", label);
7218
- if (defaulted) onRelabel(() => {
7219
- title.textContent = contextConfig().t(defaulted);
7220
- });
7221
- body.appendChild(title);
7222
- row.append(control, body);
7223
- list.appendChild(row);
7224
- };
7225
- const t = contextConfig();
7226
- mk("true", field.trueLabel ?? t.t("elicitationYes"), field.trueLabel ? void 0 : "elicitationYes");
7227
- mk("false", field.falseLabel ?? t.t("elicitationNo"), field.falseLabel ? void 0 : "elicitationNo");
7228
- wrap.appendChild(list);
7229
- list.addEventListener("change", onChange);
7230
- const checked = () => list.querySelector("input:checked");
7231
- return {
7232
- el: wrap,
7233
- getValue: () => checked()?.value === "true",
7234
- isComplete: () => checked() != null,
7235
- focus: () => list.querySelector("input")?.focus()
7236
- };
7237
- }
7238
- function buildStringField(field, onChange, fallbackLabel) {
7239
- const wrap = el("div", "aparte-elic-field aparte-elic-string");
7240
- fieldHeader(wrap, field, fallbackLabel);
7241
- const input = field.multiline ? el("textarea", "aparte-elic-text") : el("input", "aparte-elic-text");
7242
- if (!field.multiline) input.type = "text";
7243
- if (field.placeholder) input.setAttribute("placeholder", field.placeholder);
7244
- if (field.default) input.value = field.default;
7245
- if (field.maxLength != null) input.setAttribute("maxlength", String(field.maxLength));
7246
- const ownName = field.title || field.description || fallbackLabel || void 0;
7247
- input.setAttribute("aria-label", ownName ?? contextConfig().t("elicitationAnswerLabel"));
7248
- if (ownName == null) {
7249
- const named = input;
7250
- onRelabel(() => {
7251
- named.setAttribute("aria-label", contextConfig().t("elicitationAnswerLabel"));
7252
- });
7098
+ const node = {
7099
+ prev: parent,
7100
+ next: null,
7101
+ children: [],
7102
+ current: message,
7103
+ level: parent ? parent.level + 1 : 0
7104
+ };
7105
+ this._nodes.set(message.id, node);
7106
+ this._link(parent, node);
7107
+ if (this._head === parent || this._head === null && parent === null) {
7108
+ this._head = node;
7109
+ }
7110
+ }
7111
+ /**
7112
+ * Switch the active branch to `messageId`.
7113
+ * Updates `next` on the parent chain so getMessages() reflects the change.
7114
+ * Advances `head` to the deepest active node in the new branch.
7115
+ *
7116
+ * Silent no-op for unknown ids — mutation methods (`switchToBranch`,
7117
+ * `resetHead`, `updateMessage`, `clearChildren`) never throw.
7118
+ */
7119
+ switchToBranch(messageId) {
7120
+ const node = this._nodes.get(messageId);
7121
+ if (!node) return;
7122
+ const parentOrRoot = node.prev ?? this._root;
7123
+ parentOrRoot.next = node;
7124
+ this._head = findHead(node);
7125
+ }
7126
+ /**
7127
+ * Remove all descendants of `messageId` (inclusive) and set `head` to
7128
+ * the parent of `messageId`. Used by edit/truncate flows.
7129
+ *
7130
+ * @param messageId The first message to remove
7131
+ * @param newParentId Optional explicit parent to set head to
7132
+ */
7133
+ resetHead(messageId) {
7134
+ const node = this._nodes.get(messageId);
7135
+ if (!node) return;
7136
+ this._deleteDescendants(node);
7137
+ const parentOrRoot = node.prev ?? this._root;
7138
+ parentOrRoot.children = parentOrRoot.children.filter((id) => id !== messageId);
7139
+ if (parentOrRoot.next === node) {
7140
+ parentOrRoot.next = null;
7141
+ }
7142
+ this._nodes.delete(messageId);
7143
+ this._head = node.prev;
7144
+ }
7145
+ /**
7146
+ * Update a message's data without changing its position in the tree.
7147
+ */
7148
+ updateMessage(messageId, updates) {
7149
+ const node = this._nodes.get(messageId);
7150
+ if (!node) return;
7151
+ node.current = { ...node.current, ...updates };
7253
7152
  }
7254
- wrap.appendChild(input);
7255
- input.addEventListener("input", onChange);
7256
- const required = field.required ?? true;
7257
- const value = () => input.value;
7258
- return {
7259
- el: wrap,
7260
- getValue: () => value(),
7261
- isComplete: () => {
7262
- const v = value().trim();
7263
- if (!required && v === "") return true;
7264
- return v.length >= (field.minLength ?? 1);
7265
- },
7266
- focus: () => input.focus()
7267
- };
7268
- }
7269
- function buildField(field, onChange, fallbackLabel, key) {
7270
- const custom = contextConfig().getElicitationFieldRenderer()?.(field, { key, notifyChange: onChange });
7271
- if (custom) {
7272
- return {
7273
- el: custom.el,
7274
- getValue: () => custom.getValue(),
7275
- isComplete: () => custom.isComplete(),
7276
- focus: () => custom.focus?.()
7277
- };
7153
+ /**
7154
+ * Remove ALL children of `parentId` (and their descendants) and set head
7155
+ * to `parentId`. Used by edit to discard every previous response before
7156
+ * re-generating ensures the new response starts with sibling count = 1.
7157
+ */
7158
+ clearChildren(parentId) {
7159
+ const parent = this._nodes.get(parentId);
7160
+ if (!parent) return;
7161
+ for (const childId of [...parent.children]) {
7162
+ const child = this._nodes.get(childId);
7163
+ if (child) {
7164
+ this._deleteDescendants(child);
7165
+ this._nodes.delete(childId);
7166
+ }
7167
+ }
7168
+ parent.children = [];
7169
+ parent.next = null;
7170
+ this._head = parent;
7278
7171
  }
7279
- switch (field.type) {
7280
- case "enum":
7281
- return buildEnumField(field, onChange, fallbackLabel);
7282
- case "boolean":
7283
- return buildBooleanField(field, onChange, fallbackLabel);
7284
- case "string":
7285
- return buildStringField(field, onChange, fallbackLabel);
7172
+ /** Remove all messages and reset to empty state. */
7173
+ clear() {
7174
+ this._nodes.clear();
7175
+ this._head = null;
7176
+ this._root = { next: null, children: [] };
7286
7177
  }
7287
- }
7288
- function buildElicitationPanel(message, schema, onChange) {
7289
- const relabels = [];
7290
- const outer = _relabels;
7291
- _relabels = relabels;
7292
- try {
7293
- const built = buildPanel(message, schema, onChange);
7294
- return { ...built, relabel: () => {
7295
- for (const fn of relabels) fn();
7296
- } };
7297
- } finally {
7298
- _relabels = outer;
7178
+ /**
7179
+ * Import a previously-exported snapshot, rebuilding the full tree and
7180
+ * restoring the active head. Replaces any existing content (calls `clear()`
7181
+ * first). Messages must be in topological order (parent before child) —
7182
+ * this is guaranteed by `export()` since `_nodes` is an insertion-order
7183
+ * Map and parents are always inserted before their children.
7184
+ */
7185
+ import(exported) {
7186
+ this.clear();
7187
+ for (const { message, parentId } of exported.messages) {
7188
+ this.addOrUpdateMessage(parentId, message);
7189
+ }
7190
+ if (exported.headId) {
7191
+ this.switchToBranch(exported.headId);
7192
+ }
7299
7193
  }
7300
- }
7301
- function buildPanel(message, schema, onChange) {
7302
- const panel = el("div", "aparte-elic-panel");
7303
- if (message) panel.appendChild(el("p", "aparte-elic-message", message));
7304
- const body = el("div", "aparte-elic-body");
7305
- panel.appendChild(body);
7306
- const dismiss = el("div", "aparte-elic-dismiss");
7307
- panel.appendChild(dismiss);
7308
- if (schema.type === "object") {
7309
- let show = function(index) {
7310
- current = index;
7311
- fields.forEach((f, i) => {
7312
- f.field.el.hidden = i !== index;
7194
+ /**
7195
+ * Export the full tree for serialisation or debugging.
7196
+ * Compatible with the assistant-ui ExportedMessageRepository format.
7197
+ */
7198
+ export() {
7199
+ const messages = [];
7200
+ this._nodes.forEach((node) => {
7201
+ messages.push({
7202
+ message: node.current,
7203
+ parentId: node.prev?.current.id ?? null
7313
7204
  });
7314
- syncNav();
7315
- };
7316
- const entries = Object.entries(schema.properties);
7317
- const requiredKeys = new Set(schema.required ?? entries.map(([k]) => k));
7318
- const cfg = contextConfig();
7319
- let syncNav = () => {
7320
- };
7321
- const notify = () => {
7322
- syncNav();
7323
- onChange();
7324
- };
7325
- const fields = entries.map(([key, field2]) => {
7326
- const built = buildField(field2, notify, void 0, key);
7327
- if (!field2.title && !built.el.querySelector(".aparte-elic-title")) {
7328
- built.el.insertBefore(el("p", "aparte-elic-title", key), built.el.firstChild);
7329
- }
7330
- body.appendChild(built.el);
7331
- return { key, field: built, required: requiredKeys.has(key), header: field2.header };
7332
7205
  });
7333
- const isComplete = () => fields.every((f) => !f.required || f.field.isComplete());
7334
- const api = {
7335
- el: panel,
7336
- dismiss,
7337
- getContent: () => Object.fromEntries(fields.map((f) => [f.key, f.field.getValue()])),
7338
- isComplete,
7339
- focus: () => fields[0]?.field.focus(),
7340
- // A form of one behaves like a single field; only a real form steps.
7341
- mode: () => "submit",
7342
- canProceed: isComplete,
7343
- proceed: () => {
7206
+ return { headId: this.headId, messages };
7207
+ }
7208
+ // ─── Private helpers ────────────────────────────────────────────────────
7209
+ _link(parent, child) {
7210
+ const parentOrRoot = parent ?? this._root;
7211
+ parentOrRoot.children = [...parentOrRoot.children, child.current.id];
7212
+ if (parentOrRoot.next === null) {
7213
+ parentOrRoot.next = child;
7214
+ }
7215
+ child.prev = parent;
7216
+ }
7217
+ _relink(newParent, child) {
7218
+ const oldParentOrRoot = child.prev ?? this._root;
7219
+ if (oldParentOrRoot !== (newParent ?? this._root)) {
7220
+ oldParentOrRoot.children = oldParentOrRoot.children.filter((id) => id !== child.current.id);
7221
+ if (oldParentOrRoot.next === child) {
7222
+ const nextId = oldParentOrRoot.children[0];
7223
+ oldParentOrRoot.next = nextId ? this._nodes.get(nextId) ?? null : null;
7344
7224
  }
7345
- };
7346
- if (fields.length < 2 || cfg.getElicitationOptions().layout !== "stepped") return api;
7347
- panel.classList.add("aparte-elic-panel--stepped");
7348
- let current = 0;
7349
- const steps = el("div", "aparte-elic-steps");
7350
- steps.setAttribute("role", "tablist");
7351
- const chips = fields.map((f, i) => {
7352
- const chip = el("button", "aparte-elic-step");
7353
- chip.type = "button";
7354
- chip.setAttribute("role", "tab");
7355
- chip.textContent = f.header?.trim() || String(i + 1);
7356
- chip.addEventListener("click", () => {
7357
- show(i);
7358
- f.field.focus();
7359
- });
7360
- steps.appendChild(chip);
7361
- return chip;
7362
- });
7363
- panel.insertBefore(steps, body);
7364
- syncNav = () => {
7365
- chips.forEach((chip, i) => {
7366
- chip.setAttribute("aria-selected", String(i === current));
7367
- chip.toggleAttribute("data-answered", fields[i].field.isComplete());
7368
- });
7369
- };
7370
- show(0);
7371
- const last = () => current === fields.length - 1;
7372
- return {
7373
- ...api,
7374
- focus: () => fields[current]?.field.focus(),
7375
- mode: () => last() ? "submit" : "advance",
7376
- // Advancing needs only THIS question answered; submitting needs them all,
7377
- // which is what stops the last question from accepting a form with a hole
7378
- // in it.
7379
- canProceed: () => last() ? isComplete() : !!fields[current]?.field.isComplete(),
7380
- proceed: () => {
7381
- if (last()) return;
7382
- show(current + 1);
7383
- fields[current]?.field.focus();
7225
+ }
7226
+ child.prev = newParent;
7227
+ const newParentOrRoot = newParent ?? this._root;
7228
+ if (!newParentOrRoot.children.includes(child.current.id)) {
7229
+ newParentOrRoot.children = [...newParentOrRoot.children, child.current.id];
7230
+ }
7231
+ if (newParentOrRoot.next === null) {
7232
+ newParentOrRoot.next = child;
7233
+ }
7234
+ this._updateLevels(child, newParent ? newParent.level + 1 : 0);
7235
+ }
7236
+ _updateLevels(node, newLevel) {
7237
+ node.level = newLevel;
7238
+ for (const childId of node.children) {
7239
+ const childNode = this._nodes.get(childId);
7240
+ if (childNode) this._updateLevels(childNode, newLevel + 1);
7241
+ }
7242
+ }
7243
+ _deleteDescendants(node) {
7244
+ for (const childId of node.children) {
7245
+ const child = this._nodes.get(childId);
7246
+ if (child) {
7247
+ this._deleteDescendants(child);
7248
+ this._nodes.delete(childId);
7384
7249
  }
7385
- };
7250
+ }
7251
+ node.children = [];
7252
+ node.next = null;
7386
7253
  }
7387
- const field = buildField(schema, onChange, message);
7388
- body.appendChild(field.el);
7389
- return {
7390
- el: panel,
7391
- dismiss,
7392
- getContent: () => field.getValue(),
7393
- isComplete: () => field.isComplete(),
7394
- focus: () => field.focus(),
7395
- mode: () => "submit",
7396
- canProceed: () => field.isComplete(),
7397
- proceed: () => {
7254
+ }
7255
+ const APARTE_DEFAULT_UI_EVENTS = [
7256
+ // <aparte-composer> and its parts
7257
+ "aparte-send",
7258
+ "aparte-cancel",
7259
+ "aparte-composer-change",
7260
+ "aparte-composer-submit",
7261
+ "aparte-action-click",
7262
+ "aparte-attachment-preview",
7263
+ // <aparte-chat-bubble>
7264
+ "aparte-action",
7265
+ "aparte-retry",
7266
+ "aparte-edit",
7267
+ "aparte-feedback",
7268
+ "aparte-message-info",
7269
+ "aparte-branch-navigate",
7270
+ // <aparte-chat-viewport>
7271
+ "aparte-segment-update",
7272
+ "aparte-reset-done",
7273
+ "aparte-path-changed",
7274
+ // <aparte-conversation-list>
7275
+ "aparte-select-conversation",
7276
+ "aparte-delete-conversation",
7277
+ "aparte-archive-conversation",
7278
+ "aparte-unarchive-conversation",
7279
+ // the select primitives
7280
+ "aparte-select-change",
7281
+ "aparte-select-open",
7282
+ "aparte-select-close",
7283
+ "aparte-optgroup-toggle"
7284
+ ];
7285
+ function applyElementProps(el2, props, transformValue = (value) => value) {
7286
+ for (const [key, value] of Object.entries(props)) {
7287
+ if (key.startsWith("--")) {
7288
+ el2.style.setProperty(key, String(value));
7289
+ } else if (key.startsWith("on")) {
7290
+ el2.removeAttribute(key);
7291
+ } else if (value === null || value === void 0 || value === false) {
7292
+ el2.removeAttribute(key);
7293
+ } else if (value === true) {
7294
+ el2.setAttribute(key, "");
7295
+ } else if (typeof value === "object" || typeof value === "function") {
7296
+ el2[key] = transformValue(value);
7297
+ } else {
7298
+ el2.setAttribute(key, String(value));
7398
7299
  }
7399
- };
7300
+ }
7400
7301
  }
7401
- function requestUserInput(request) {
7402
- return resolveConfig(request.target ?? null).requestUserInput(request);
7302
+ function isAwaitingReply(message) {
7303
+ if (message.status === "streaming" || message.status === "pending") return true;
7304
+ return message.status === void 0 && message.role === "assistant" && !message.content?.trim() && !message.segments?.length;
7403
7305
  }
7404
7306
  export {
7405
- runWithConfig as $,
7307
+ resolveConfig as $,
7406
7308
  APARTE_CONFIG_CHANGE as A,
7407
- createAparteChatHandler as B,
7408
- createStreamAdapter as C,
7409
- cssEscape as D,
7410
- declineDefaultRenderers as E,
7411
- defaultSanitizer as F,
7412
- deriveArtifactKind as G,
7413
- detachConfig as H,
7414
- escapeAttr as I,
7415
- escapeHtml as J,
7416
- filesToAttachments as K,
7417
- getAllRenderers as L,
7418
- getSegmentRenderer as M,
7419
- installDefaultRenderersOnce as N,
7420
- isAwaitingReply as O,
7421
- isFormatAdapter as P,
7422
- isSafeUrl as Q,
7423
- isSegmentSettled as R,
7424
- parseAparteEventStream as S,
7425
- parseMarkdownToSegments as T,
7426
- populateBubbleFromMessage as U,
7427
- readableToAsyncIterable as V,
7428
- registerDefaultRenderers as W,
7429
- registerSegmentRenderer as X,
7430
- requestUserInput as Y,
7431
- resolveConfig as Z,
7432
- revokeAttachmentUrls as _,
7309
+ contentToText as B,
7310
+ contextConfig as C,
7311
+ createAparteChatHandler as D,
7312
+ createStreamAdapter as E,
7313
+ cssEscape as F,
7314
+ declineDefaultRenderers as G,
7315
+ defaultSanitizer as H,
7316
+ deriveArtifactKind as I,
7317
+ detachConfig as J,
7318
+ escapeAttr as K,
7319
+ escapeHtml as L,
7320
+ filesToAttachments as M,
7321
+ getAllRenderers as N,
7322
+ getSegmentRenderer as O,
7323
+ installDefaultRenderersOnce as P,
7324
+ isAwaitingReply as Q,
7325
+ isFormatAdapter as R,
7326
+ isSafeUrl as S,
7327
+ isSegmentSettled as T,
7328
+ parseAparteEventStream as U,
7329
+ parseMarkdownToSegments as V,
7330
+ populateBubbleFromMessage as W,
7331
+ readableToAsyncIterable as X,
7332
+ registerDefaultRenderers as Y,
7333
+ registerSegmentRenderer as Z,
7334
+ requestUserInput as _,
7433
7335
  APARTE_CONVERSATION_SCHEMA_VERSION as a,
7434
- segmentDuration as a0,
7435
- segmentTiming as a1,
7436
- subscribeConfigChange as a2,
7437
- unregisterSegmentRenderer as a3,
7438
- uuid as a4,
7439
- mergeSegmentUpdate as a5,
7440
- stampSegmentActivity as a6,
7441
- stampSegmentOnInsert as a7,
7442
- stampSegmentOnUpdate as a8,
7443
- renumberSegments as a9,
7444
- openSegmentIds as aa,
7445
- isTerminalStatus as ab,
7446
- adoptMessageSegments as ac,
7336
+ revokeAttachmentUrls as a0,
7337
+ runWithConfig as a1,
7338
+ segmentDuration as a2,
7339
+ segmentTiming as a3,
7340
+ subscribeConfigChange as a4,
7341
+ unregisterSegmentRenderer as a5,
7342
+ uuid as a6,
7343
+ mergeSegmentUpdate as a7,
7344
+ stampSegmentActivity as a8,
7345
+ stampSegmentOnInsert as a9,
7346
+ stampSegmentOnUpdate as aa,
7347
+ renumberSegments as ab,
7348
+ openSegmentIds as ac,
7349
+ isTerminalStatus as ad,
7350
+ adoptMessageSegments as ae,
7447
7351
  APARTE_DEFAULT_BUBBLE_ACTIONS as b,
7448
7352
  APARTE_DEFAULT_HOST_HANDLERS as c,
7449
7353
  APARTE_DEFAULT_ICON_FALLBACKS as d,
@@ -7458,16 +7362,16 @@ export {
7458
7362
  AparteConversationController as m,
7459
7363
  AparteConversationManager as n,
7460
7364
  AparteDirectTransport as o,
7461
- AparteError as p,
7462
- AparteErrorCode as q,
7463
- AparteMessageRepository as r,
7464
- AparteStreamParser as s,
7465
- aparteGlobalConfig as t,
7466
- applyElementProps as u,
7467
- attachConfig as v,
7468
- buildElicitationPanel as w,
7469
- collectRendererStyles as x,
7470
- contentToText as y,
7471
- contextConfig as z
7365
+ AparteElicitationAbortError as p,
7366
+ AparteError as q,
7367
+ AparteErrorCode as r,
7368
+ AparteMessageRepository as s,
7369
+ AparteStreamParser as t,
7370
+ aparteGlobalConfig as u,
7371
+ applyElementProps as v,
7372
+ attachConfig as w,
7373
+ buildApprovalPanel as x,
7374
+ buildElicitationPanel as y,
7375
+ collectRendererStyles as z
7472
7376
  };
7473
- //# sourceMappingURL=index-BnKBciDG.js.map
7377
+ //# sourceMappingURL=is-awaiting-reply-Cb7H3YfN.js.map