@aparte/core 0.16.10 → 0.17.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 (81) hide show
  1. package/README.md +8 -0
  2. package/dist/client/aparte-client.d.ts +42 -3
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/components/bubble/aparte-chat-bubble.d.ts +22 -0
  5. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  6. package/dist/components/chat/aparte-chat.d.ts +33 -0
  7. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  8. package/dist/components/composer/aparte-composer-action.d.ts +5 -3
  9. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  10. package/dist/components/composer/aparte-composer-toolbar.d.ts +5 -2
  11. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  12. package/dist/components/composer/aparte-composer.d.ts +27 -0
  13. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  14. package/dist/components/context/aparte-context.d.ts +8 -0
  15. package/dist/components/context/aparte-context.d.ts.map +1 -1
  16. package/dist/components/conversation-list/aparte-conversation-list.d.ts +82 -17
  17. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  18. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  19. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  20. package/dist/components/split/aparte-split.d.ts.map +1 -1
  21. package/dist/components/suggestions/aparte-suggestions.d.ts +10 -1
  22. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  23. package/dist/components/viewport/aparte-chat-viewport.d.ts +55 -1
  24. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  25. package/dist/config/action-provider.d.ts +14 -14
  26. package/dist/config/action-provider.d.ts.map +1 -1
  27. package/dist/config/aparte-config.d.ts +82 -13
  28. package/dist/config/aparte-config.d.ts.map +1 -1
  29. package/dist/config/bubble-shell-renderer.d.ts +50 -12
  30. package/dist/config/bubble-shell-renderer.d.ts.map +1 -1
  31. package/dist/config/locale.d.ts +33 -1
  32. package/dist/config/locale.d.ts.map +1 -1
  33. package/dist/config/sanitize.d.ts.map +1 -1
  34. package/dist/conversations/conversation-controller.d.ts +14 -0
  35. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  36. package/dist/conversations/conversation-manager.d.ts +57 -19
  37. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  38. package/dist/conversations/types.d.ts +18 -11
  39. package/dist/conversations/types.d.ts.map +1 -1
  40. package/dist/custom-elements.json +6841 -6085
  41. package/dist/{dialog-triggers-cYGTlfZf.js → dialog-triggers-Bb-3EkT4.js} +591 -161
  42. package/dist/dialog-triggers-Bb-3EkT4.js.map +1 -0
  43. package/dist/generated/element-attributes.d.ts +10 -4
  44. package/dist/generated/element-attributes.d.ts.map +1 -1
  45. package/dist/host/aparte-chat-host.d.ts +30 -0
  46. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  47. package/dist/index.css +1 -8393
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +550 -98
  51. package/dist/index.js.map +1 -1
  52. package/dist/index.node.d.ts +1 -1
  53. package/dist/index.node.d.ts.map +1 -1
  54. package/dist/index.node.js +1 -1
  55. package/dist/index.node.js.map +1 -1
  56. package/dist/interop/element-props.d.ts +1 -1
  57. package/dist/primitives/select/aparte-optgroup.d.ts +1 -0
  58. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  59. package/dist/primitives/select/aparte-select.d.ts +12 -1
  60. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  61. package/dist/renderers/markdown-stream.d.ts.map +1 -1
  62. package/dist/runtime/message-repository.d.ts +16 -3
  63. package/dist/runtime/message-repository.d.ts.map +1 -1
  64. package/dist/types/chat.d.ts +41 -46
  65. package/dist/types/chat.d.ts.map +1 -1
  66. package/dist/types/event-map.d.ts +7 -7
  67. package/dist/types/event-map.d.ts.map +1 -1
  68. package/dist/types/events.d.ts +7 -6
  69. package/dist/types/events.d.ts.map +1 -1
  70. package/dist/types/imperative-api.d.ts +16 -0
  71. package/dist/types/imperative-api.d.ts.map +1 -1
  72. package/dist/types/index.d.ts +2 -2
  73. package/dist/types/index.d.ts.map +1 -1
  74. package/dist/types/models.d.ts +9 -0
  75. package/dist/types/models.d.ts.map +1 -1
  76. package/dist/types/segments.d.ts +56 -13
  77. package/dist/types/segments.d.ts.map +1 -1
  78. package/dist/utils/event-target.d.ts +31 -0
  79. package/dist/utils/event-target.d.ts.map +1 -0
  80. package/package.json +2 -2
  81. package/dist/dialog-triggers-cYGTlfZf.js.map +0 -1
@@ -147,6 +147,8 @@ const APARTE_DEFAULT_LOCALE = {
147
147
  rejectTool: "Reject",
148
148
  messageInfo: "Details",
149
149
  newChat: "New Chat",
150
+ loadingConversations: "Loading conversations",
151
+ loadingConversation: "Loading the conversation",
150
152
  deleteConversation: "Delete",
151
153
  archiveConversation: "Archive",
152
154
  unarchiveConversation: "Unarchive",
@@ -165,8 +167,7 @@ const APARTE_DEFAULT_LOCALE = {
165
167
  scrollRailLabel: "Conversation outline",
166
168
  sidebarLabel: "Conversations",
167
169
  splitHandleLabel: "Resize the panes",
168
- transcript: "Transcript",
169
- direction: "ltr"
170
+ transcript: "Transcript"
170
171
  };
171
172
  function isFormatAdapter(p) {
172
173
  return typeof p.buildRequest === "function" && typeof p.parseStream === "function" && typeof p.defaultEndpoint === "string" && (typeof p.authHeaders === "function" || typeof p.authQuery === "function");
@@ -982,7 +983,7 @@ const ALLOWED_TAGS = /* @__PURE__ */ new Set([
982
983
  "picture",
983
984
  "source",
984
985
  "input",
985
- // GFM task-list checkboxes only (attributes locked down below)
986
+ // GFM task-list checkboxes only — `copyAttributes` refuses every other `type`
986
987
  "abbr",
987
988
  "details",
988
989
  "summary",
@@ -1086,6 +1087,7 @@ function copyAttributes(src, dest, tag) {
1086
1087
  if (name.startsWith("on") || name.startsWith("formaction")) continue;
1087
1088
  if (!name.startsWith("data-") && !GLOBAL_ATTRS.has(name) && !(extra && extra.has(name))) continue;
1088
1089
  if (URL_ATTRS.has(name) && !isSafeUrl(value, tag)) continue;
1090
+ if (tag === "input" && name === "type" && value.toLowerCase() !== "checkbox") continue;
1089
1091
  if (name === "srcset" && !isSafeSrcset(value, tag)) continue;
1090
1092
  if (name === "style") {
1091
1093
  const scrubbed = scrubStyle(value);
@@ -1233,6 +1235,11 @@ class AparteConfig {
1233
1235
  _iconProvider;
1234
1236
  _avatarProvider;
1235
1237
  _keyProvider;
1238
+ /**
1239
+ * Sources added with `registerKeyProvider` — consulted before `_keyProvider`,
1240
+ * newest first (see `getKey`).
1241
+ */
1242
+ _extraKeyProviders = /* @__PURE__ */ new Set();
1236
1243
  _locale = APARTE_DEFAULT_LOCALE;
1237
1244
  _actions = [];
1238
1245
  _listeners = /* @__PURE__ */ new Set();
@@ -1307,7 +1314,7 @@ class AparteConfig {
1307
1314
  * (soon) the tool-approval gate. A custom presenter registered by a consumer gets
1308
1315
  * the same protection for free, which it previously had none of.
1309
1316
  */
1310
- _elicitationQueue = null;
1317
+ _elicitationQueue = /* @__PURE__ */ new Map();
1311
1318
  // Tool Registry
1312
1319
  _tools = /* @__PURE__ */ new Map();
1313
1320
  _toolRenderers = /* @__PURE__ */ new Map();
@@ -1323,9 +1330,10 @@ class AparteConfig {
1323
1330
  // Provider Setters (Dependency Injection)
1324
1331
  // ─────────────────────────────────────────────────────────────────────────
1325
1332
  /**
1326
- * Register a custom action button. `zones` places it in the composer toolbar
1327
- * and/or the message (bubble) toolbar. Re-registering the same id overwrites
1328
- * it. Notifies mounted elements so they re-render.
1333
+ * Register a custom action button. `zones` places it in the message (bubble)
1334
+ * toolbar. Re-registering the same id overwrites it. Notifies mounted elements
1335
+ * so they re-render. A button in the composer is an `<aparte-composer-action>`
1336
+ * element you write in your markup, not a registry entry.
1329
1337
  */
1330
1338
  registerAction(action) {
1331
1339
  const existing = this._actions.findIndex((a) => a.id === action.id);
@@ -1347,17 +1355,6 @@ class AparteConfig {
1347
1355
  this._actions = this._actions.filter((a) => a.id !== id);
1348
1356
  if (this._actions.length !== before) this._notify();
1349
1357
  }
1350
- /**
1351
- * Show or hide a composer action button by id.
1352
- * Triggers a config update so all mounted composer elements react immediately.
1353
- */
1354
- setActionHidden(id, hidden) {
1355
- const action = this._actions.find((a) => a.id === id);
1356
- if (action) {
1357
- action.composer = { ...action.composer, hidden };
1358
- this._notify();
1359
- }
1360
- }
1361
1358
  /**
1362
1359
  * Configure which action buttons appear in message bubbles.
1363
1360
  * Unset keys keep their defaults — see {@link APARTE_DEFAULT_BUBBLE_ACTIONS}: `copy`
@@ -1418,8 +1415,11 @@ class AparteConfig {
1418
1415
  * Set an incremental (streaming) Markdown renderer provider. Optional —
1419
1416
  * when set, the chat bubble uses it to render the assistant message
1420
1417
  * token-by-token DURING streaming (incremental parse + DOM append, O(n)),
1421
- * instead of re-parsing the whole string on every token. The one-shot
1422
- * `setMarkdownProvider` is still used for finished / re-rendered messages.
1418
+ * instead of re-parsing the whole string on every token. When the turn ends,
1419
+ * a registered one-shot `setMarkdownProvider` re-renders the finished message
1420
+ * at full fidelity; with no one-shot provider registered, what the streaming
1421
+ * renderer drew is kept (sanitised), rather than replaced by the built-in
1422
+ * escape-and-line-break fallback.
1423
1423
  */
1424
1424
  setStreamingMarkdownProvider(fn) {
1425
1425
  this._streamingMarkdownProvider = fn;
@@ -1676,11 +1676,57 @@ class AparteConfig {
1676
1676
  this._keyProvider = provider;
1677
1677
  }
1678
1678
  /**
1679
- * Get API key for a provider
1679
+ * Add a key source, without replacing the one `setKeyProvider` holds. Returns a
1680
+ * teardown.
1681
+ *
1682
+ * This is how `AparteClient({ keyResolver })` — the channel the docs teach as
1683
+ * primary — reaches everything that needs a key rather than the chat alone. It
1684
+ * used to reach the chat ONLY, so the model selector, whose one data path is
1685
+ * `refreshProviderModels`, showed an empty list on every cloud provider while
1686
+ * the chat worked: `fetchModels` returns `[]` without a key, so there was
1687
+ * nothing to see and nothing said.
1688
+ *
1689
+ * The direction matters (decision #9): the client registers a source ON the
1690
+ * config, so the capability is never hostage to the client — a page that only
1691
+ * ever calls `setKeyProvider` is unchanged, and a page that constructs no
1692
+ * client at all still lists models.
1693
+ *
1694
+ * Every call is its own source, even when the same function is handed in
1695
+ * twice: two panes sharing one module-level resolver are two mounts, and the
1696
+ * first one to unmount must not take the second one's key with it. A `Set`
1697
+ * keyed by the function itself made them one entry, which either teardown
1698
+ * removed.
1699
+ */
1700
+ registerKeyProvider(provider) {
1701
+ const entry = (providerId) => provider(providerId);
1702
+ this._extraKeyProviders.add(entry);
1703
+ return () => {
1704
+ this._extraKeyProviders.delete(entry);
1705
+ };
1706
+ }
1707
+ /**
1708
+ * The credentials for a provider: a key, or the `{ apiKey, endpoint }` record
1709
+ * the transport and `fetchModels` both read.
1710
+ *
1711
+ * ONE resolution order, so the chat and the model list cannot disagree about
1712
+ * what an endpoint is: a registered resolver (`AparteClient`'s `keyResolver`)
1713
+ * first — which is the precedence the client has always documented — then
1714
+ * `setKeyProvider`.
1715
+ *
1716
+ * Registered sources answer NEWEST first. A page that mounts a second chat, or
1717
+ * remounts one to swap its options — the swap the wrappers document — leaves
1718
+ * more than one source here, and insertion order made the first resolver the
1719
+ * page ever registered answer for every later one, so the key could not be
1720
+ * changed. The snapshot is deliberate too: a source torn down while an earlier
1721
+ * one is being awaited must not shift the iteration underneath it.
1680
1722
  */
1681
1723
  async getKey(providerId) {
1724
+ for (const provider of [...this._extraKeyProviders].reverse()) {
1725
+ const resolved = await provider(providerId);
1726
+ if (resolved) return resolved;
1727
+ }
1682
1728
  if (this._keyProvider) {
1683
- return await this._keyProvider(providerId);
1729
+ return await this._keyProvider(providerId) || void 0;
1684
1730
  }
1685
1731
  return void 0;
1686
1732
  }
@@ -1693,8 +1739,8 @@ class AparteConfig {
1693
1739
  const provider = this._aiProviders.get(providerId);
1694
1740
  if (!provider || !provider.fetchModels) return [];
1695
1741
  try {
1696
- const apiKey = await this.getKey(providerId);
1697
- const models = await provider.fetchModels(apiKey);
1742
+ const auth = await this.getKey(providerId);
1743
+ const models = await provider.fetchModels(auth);
1698
1744
  this._fetchedModels.set(providerId, models);
1699
1745
  return models;
1700
1746
  } catch (error) {
@@ -2064,6 +2110,13 @@ class AparteConfig {
2064
2110
  * Controls what appears in the chat bubble when the AI calls this tool.
2065
2111
  * Use this instead of the generic `tool_call` segment renderer for tool-specific UI.
2066
2112
  *
2113
+ * A renderer that needs a popover (a `<aparte-select>`, a menu) cannot open one
2114
+ * inside the row: the bubble is `content-visibility: auto`, which makes it the
2115
+ * containing block for `position: fixed` descendants and clips a popover to its
2116
+ * own box. Mount it on `document.body` and position it from the trigger's rect
2117
+ * instead — see [Custom tool
2118
+ * renderer](https://apartejs.dev/guides/tools/#custom-tool-renderer).
2119
+ *
2067
2120
  * @example
2068
2121
  * // Hide the segment entirely (UI-only tool like ask_user)
2069
2122
  * aparteGlobalConfig.registerToolRenderer('ask_user', { render: () => '' });
@@ -2257,17 +2310,32 @@ class AparteConfig {
2257
2310
  * "installs itself — nothing to register", which is how that was found.
2258
2311
  */
2259
2312
  requestUserInput(request) {
2260
- const previous = this._elicitationQueue;
2261
- const settled = previous === null ? this._present(request) : previous.then(
2313
+ const queue = this._elicitationQueueKey(request);
2314
+ const previous = this._elicitationQueue.get(queue);
2315
+ const settled = previous === void 0 ? this._present(request) : previous.then(
2262
2316
  () => this._present(request),
2263
2317
  () => this._present(request)
2264
2318
  );
2265
2319
  const mine = settled.then(NOOP, NOOP).then(() => {
2266
- if (this._elicitationQueue === mine) this._elicitationQueue = null;
2320
+ if (this._elicitationQueue.get(queue) === mine) this._elicitationQueue.delete(queue);
2267
2321
  });
2268
- this._elicitationQueue = mine;
2322
+ this._elicitationQueue.set(queue, mine);
2269
2323
  return settled;
2270
2324
  }
2325
+ /**
2326
+ * Which queue a request waits in: its own chat's.
2327
+ *
2328
+ * The wait exists so a second question cannot take the panel away from the one on
2329
+ * screen — a property of ONE panel, and `_present` routes by target, so a chat is
2330
+ * the right unit. It used to be one queue for the whole config: two chats on the
2331
+ * global config shared it, and an approval waiting for an answer in the first (which
2332
+ * is all an approval does) held the second's question behind it for ever, with
2333
+ * nothing on screen to answer. Requests that name no chat keep sharing the `null`
2334
+ * queue, because they all reach the same presenter — the top of the stack.
2335
+ */
2336
+ _elicitationQueueKey(request) {
2337
+ return chatBoundaryOf(request.target);
2338
+ }
2271
2339
  /** Hand ONE request to the presenter. Called only from the queue. */
2272
2340
  _present(request) {
2273
2341
  const presenter = this._presenterFor(request.target);
@@ -2306,7 +2374,7 @@ class AparteConfig {
2306
2374
  this._keyProvider = void 0;
2307
2375
  this._conversationManager = void 0;
2308
2376
  this._elicitationPresenters = [];
2309
- this._elicitationQueue = null;
2377
+ this._elicitationQueue.clear();
2310
2378
  this._locale = APARTE_DEFAULT_LOCALE;
2311
2379
  this._actions = [];
2312
2380
  this._sanitizer = defaultSanitizer;
@@ -2399,6 +2467,10 @@ function subscribeConfigChange(el2, handler) {
2399
2467
  window.addEventListener(APARTE_CONFIG_CHANGE, listener);
2400
2468
  return () => window.removeEventListener(APARTE_CONFIG_CHANGE, listener);
2401
2469
  }
2470
+ const ONE_SHOT_PROBE = "<i>a</i>";
2471
+ function hasOneShotMarkdownProvider() {
2472
+ return contextConfig().renderMarkdown(ONE_SHOT_PROBE) !== escapeHtml(ONE_SHOT_PROBE);
2473
+ }
2402
2474
  function writeStreamedMarkdown(host, contentEl, content, streaming) {
2403
2475
  if (streaming) {
2404
2476
  if (host._aparteSmd === void 0) {
@@ -2420,8 +2492,18 @@ function writeStreamedMarkdown(host, contentEl, content, streaming) {
2420
2492
  return;
2421
2493
  }
2422
2494
  } else if (host._aparteSmd) {
2423
- host._aparteSmd.renderer.end();
2495
+ const smd = host._aparteSmd;
2496
+ const tail = content.slice(smd.written);
2497
+ if (tail) {
2498
+ smd.renderer.write(tail);
2499
+ smd.written = content.length;
2500
+ }
2501
+ smd.renderer.end();
2424
2502
  host._aparteSmd = void 0;
2503
+ if (!hasOneShotMarkdownProvider()) {
2504
+ contentEl.innerHTML = contextConfig().sanitizeHtml(contentEl.innerHTML);
2505
+ return;
2506
+ }
2425
2507
  }
2426
2508
  contentEl.innerHTML = contextConfig().renderMarkdown(content);
2427
2509
  }
@@ -3115,6 +3197,7 @@ function populateBubbleFromMessage(bubble, message, siblingInfo) {
3115
3197
  }
3116
3198
  }
3117
3199
  const APARTE_CONVERSATION_SCHEMA_VERSION = 2;
3200
+ const APARTE_DERIVED_SEGMENTS = Symbol.for("aparte.derivedSegments");
3118
3201
  function applyRetention(messages, tree, maxMessages) {
3119
3202
  if (maxMessages <= 0 || messages.length <= maxMessages) return { messages, tree };
3120
3203
  const trimmedFlat = messages.slice(messages.length - maxMessages);
@@ -3144,6 +3227,20 @@ function applyRetention(messages, tree, maxMessages) {
3144
3227
  };
3145
3228
  return { messages: trimmedFlat, tree: trimmedTree };
3146
3229
  }
3230
+ function withoutDerived(message) {
3231
+ if (!message[APARTE_DERIVED_SEGMENTS]) return message;
3232
+ const { segments: _derived, ...rest } = message;
3233
+ return rest;
3234
+ }
3235
+ function withoutDerivedSegments(messages) {
3236
+ return messages.map(withoutDerived);
3237
+ }
3238
+ function withoutDerivedTree(tree) {
3239
+ return {
3240
+ ...tree,
3241
+ messages: tree.messages.map((entry) => ({ ...entry, message: withoutDerived(entry.message) }))
3242
+ };
3243
+ }
3147
3244
  class AparteConversationManager {
3148
3245
  _adapter;
3149
3246
  _conversations = [];
@@ -3152,6 +3249,14 @@ class AparteConversationManager {
3152
3249
  _initialized = false;
3153
3250
  _retention = null;
3154
3251
  _titleProvider = null;
3252
+ /** Ids whose messages are in memory. Every id, after a loadAll(); only fetched ones after loadMeta(). */
3253
+ _loaded = /* @__PURE__ */ new Set();
3254
+ /** One fetch per id at a time: a second ensureFull(id) while the first is in flight joins it. */
3255
+ _fetching = /* @__PURE__ */ new Map();
3256
+ /** One append per id at a time: the next addMessage(id) reads what the previous one wrote. */
3257
+ _appending = /* @__PURE__ */ new Map();
3258
+ /** The list on its way: a conversation created meanwhile waits for it, or the list would replace it. */
3259
+ _initPromise = null;
3155
3260
  constructor(adapter, options) {
3156
3261
  this._adapter = adapter;
3157
3262
  this._retention = options?.retention ?? null;
@@ -3171,11 +3276,86 @@ class AparteConversationManager {
3171
3276
  return this._titleProvider;
3172
3277
  }
3173
3278
  // ─── Initialisation ────────────────────────────────────────────────────
3174
- /** Load all conversations from the adapter. Call once at app startup. */
3279
+ /**
3280
+ * Load all conversations from the adapter. Call once at app startup.
3281
+ *
3282
+ * A later call re-reads the list from the store: rows it no longer lists are
3283
+ * dropped, and a conversation whose messages are already in memory keeps them —
3284
+ * so the one on screen goes on being persisted.
3285
+ */
3175
3286
  async init() {
3176
- this._conversations = await this._adapter.loadAll();
3177
- this._initialized = true;
3178
- this._notify();
3287
+ const load = (async () => {
3288
+ const split = !!(this._adapter.loadMeta && this._adapter.loadFull);
3289
+ if (this._adapter.loadMeta && !this._adapter.loadFull) {
3290
+ console.warn("[AparteConversationManager] the adapter lists through loadMeta() but has no loadFull(): everything is loaded through loadAll() instead.");
3291
+ }
3292
+ const held = new Map(this._conversations.filter((c) => this._loaded.has(c.id)).map((c) => [c.id, c]));
3293
+ this._loaded.clear();
3294
+ const rows = split ? (await this._adapter.loadMeta()).map((m) => {
3295
+ const kept = held.get(m.id);
3296
+ return kept ? { ...m, messages: kept.messages, ...kept.tree ? { tree: kept.tree } : {} } : { ...m, messages: [] };
3297
+ }) : (await this._adapter.loadAll()).map((c) => ({ ...c, messages: c.messages ?? [] }));
3298
+ const seen = /* @__PURE__ */ new Set();
3299
+ const unique = rows.filter((c) => !seen.has(c.id) && (seen.add(c.id), true));
3300
+ if (unique.length !== rows.length) {
3301
+ console.warn(`[AparteConversationManager] ${rows.length - unique.length} repeated id(s) in the list; the first row wins.`);
3302
+ }
3303
+ this._conversations = unique;
3304
+ for (const c of unique) if (!split || held.has(c.id)) this._loaded.add(c.id);
3305
+ this._initialized = true;
3306
+ this._notify();
3307
+ })();
3308
+ this._initPromise = load;
3309
+ try {
3310
+ await load;
3311
+ } finally {
3312
+ if (this._initPromise === load) this._initPromise = null;
3313
+ }
3314
+ }
3315
+ /** Whether a conversation's messages are in memory (always, without split storage). */
3316
+ isLoaded(id) {
3317
+ return this._loaded.has(id);
3318
+ }
3319
+ /**
3320
+ * Fetch a conversation's messages through the adapter's loadFull(), the first time
3321
+ * they are needed; later calls resolve at once. The row keeps its place and its meta;
3322
+ * the messages (and the tree) come from the full record, and a message written to
3323
+ * the row while the fetch ran goes after them. Resolves `false` when the store has no
3324
+ * such record (or answers with another id) — the row is then NOT loaded, and says so,
3325
+ * rather than counting as an empty conversation.
3326
+ */
3327
+ async ensureFull(id) {
3328
+ if (this._loaded.has(id)) return true;
3329
+ if (!this._adapter.loadFull) return false;
3330
+ const inFlight = this._fetching.get(id);
3331
+ if (inFlight) return inFlight;
3332
+ const fetch2 = (async () => {
3333
+ const full = await this._adapter.loadFull(id);
3334
+ const row = this._find(id);
3335
+ if (!row || !full) return false;
3336
+ if (full.id !== id) {
3337
+ console.warn(`[AparteConversationManager] loadFull('${id}') answered with '${full.id}': ignored.`);
3338
+ return false;
3339
+ }
3340
+ this._replace({ ...row, ...full, id, messages: full.messages ?? [] });
3341
+ this._loaded.add(id);
3342
+ this._notify();
3343
+ return true;
3344
+ })().finally(() => this._fetching.delete(id));
3345
+ this._fetching.set(id, fetch2);
3346
+ return fetch2;
3347
+ }
3348
+ /**
3349
+ * The row with its messages in memory — fetched first when they are not, so a
3350
+ * metadata change never writes a row back without them. `null` when the id is
3351
+ * unknown or the store has no record to fetch.
3352
+ */
3353
+ async _full(id) {
3354
+ if (!this._loaded.has(id) && this._adapter.loadFull) {
3355
+ const loaded = await this.ensureFull(id);
3356
+ if (!loaded) return null;
3357
+ }
3358
+ return this._find(id) ?? null;
3179
3359
  }
3180
3360
  /**
3181
3361
  * Whether `init()` has completed at least once. Consumers use this to
@@ -3207,8 +3387,12 @@ class AparteConversationManager {
3207
3387
  return this._conversations.filter((c) => !!c.archivedAt).sort((a, b) => b.updatedAt - a.updatedAt);
3208
3388
  }
3209
3389
  // ─── Mutations ──────────────────────────────────────────────────────────
3210
- /** Create a new empty conversation, persist it, and make it active. */
3211
- async createNew(title = "New Chat") {
3390
+ /**
3391
+ * Create a new empty conversation, persist it, and make it active. Untitled by
3392
+ * default: the list shows its locale's `newChat` word for an empty title, so the
3393
+ * store never carries an English string a French UI would then display.
3394
+ */
3395
+ async createNew(title = "") {
3212
3396
  const now = Date.now();
3213
3397
  const conv = {
3214
3398
  id: uuid(),
@@ -3218,9 +3402,16 @@ class AparteConversationManager {
3218
3402
  messages: [],
3219
3403
  schemaVersion: APARTE_CONVERSATION_SCHEMA_VERSION
3220
3404
  };
3221
- this._conversations = [conv, ...this._conversations];
3222
3405
  this._activeId = conv.id;
3223
- await this._adapter.save(conv);
3406
+ this._loaded.add(conv.id);
3407
+ if (this._initPromise) await this._initPromise.catch(() => {
3408
+ });
3409
+ this._conversations = [conv, ...this._conversations];
3410
+ try {
3411
+ await this._adapter.save(conv);
3412
+ } catch (err) {
3413
+ console.warn("[AparteConversationManager] save() failed for a new conversation; it stays in memory and the next write retries.", err);
3414
+ }
3224
3415
  this._notify();
3225
3416
  return conv;
3226
3417
  }
@@ -3235,17 +3426,41 @@ class AparteConversationManager {
3235
3426
  this._activeId = null;
3236
3427
  this._notify();
3237
3428
  }
3238
- /** Append a message to a conversation. Auto-generates title from first user message. */
3429
+ /**
3430
+ * Append a message to a conversation. Auto-generates title from first user message.
3431
+ *
3432
+ * One append at a time per conversation: two sends that overlap (a suggestion and a
3433
+ * quick type, coalesced into one new conversation) both suspend before reading the
3434
+ * record — for the messages, then for the title provider — so both used to read a
3435
+ * conversation with no user message in it yet, and the second one's text became the
3436
+ * title.
3437
+ */
3239
3438
  async addMessage(convId, msg) {
3240
- const conv = this._find(convId);
3439
+ const previous = this._appending.get(convId);
3440
+ const run = previous ? previous.then(() => this._append(convId, msg)) : this._append(convId, msg);
3441
+ const tail = run.catch(() => {
3442
+ });
3443
+ this._appending.set(convId, tail);
3444
+ void tail.then(() => {
3445
+ if (this._appending.get(convId) === tail) this._appending.delete(convId);
3446
+ });
3447
+ return run;
3448
+ }
3449
+ async _append(convId, msg) {
3450
+ const conv = await this._full(convId);
3241
3451
  if (!conv) return;
3242
3452
  const isFirstUserMsg = msg.role === "user" && conv.messages.every((m) => m.role !== "user");
3453
+ const title = isFirstUserMsg ? await this._title(msg) : null;
3454
+ const fresh = this._find(convId);
3455
+ if (!fresh) return;
3456
+ const present = fresh.messages.some((m) => m.id === msg.id);
3243
3457
  const updated = {
3244
- ...conv,
3245
- messages: [...conv.messages, msg],
3458
+ ...fresh,
3459
+ messages: present ? fresh.messages : [...fresh.messages, msg],
3246
3460
  updatedAt: Date.now(),
3247
- title: isFirstUserMsg ? await this._title(msg) : conv.title
3461
+ title: title ?? fresh.title
3248
3462
  };
3463
+ if (isFirstUserMsg) updated.autoTitle = true;
3249
3464
  this._replace(updated);
3250
3465
  await this._adapter.save(updated);
3251
3466
  this._notify();
@@ -3258,20 +3473,44 @@ class AparteConversationManager {
3258
3473
  async updateMessages(convId, messages, tree) {
3259
3474
  const conv = this._find(convId);
3260
3475
  if (!conv) return;
3476
+ if (!this._loaded.has(convId)) {
3477
+ console.warn(`[AparteConversationManager] updateMessages('${convId}') before its messages were loaded: ignored.`);
3478
+ return;
3479
+ }
3480
+ const firstUser = messages.find((m) => m.role === "user");
3261
3481
  if (this._retention) {
3262
3482
  ({ messages, tree } = applyRetention(messages, tree, this._retention.maxMessages));
3263
3483
  }
3484
+ const storedMessages = withoutDerivedSegments(messages);
3485
+ const storedTree = tree === void 0 ? void 0 : withoutDerivedTree(tree);
3264
3486
  let contentChanged = true;
3487
+ let treeChanged = tree !== void 0;
3265
3488
  try {
3266
- contentChanged = JSON.stringify(messages) !== JSON.stringify(conv.messages);
3489
+ contentChanged = JSON.stringify(storedMessages) !== JSON.stringify(withoutDerivedSegments(conv.messages));
3490
+ if (storedTree !== void 0) treeChanged = JSON.stringify(storedTree) !== JSON.stringify(conv.tree);
3267
3491
  } catch {
3268
3492
  }
3493
+ let title;
3494
+ if (conv.autoTitle) {
3495
+ const before = conv.messages.find((m) => m.role === "user");
3496
+ if (firstUser && (firstUser.content ?? "") !== (before?.content ?? "")) {
3497
+ title = await this._title(firstUser);
3498
+ }
3499
+ }
3500
+ if (!contentChanged && !treeChanged && title === void 0) return;
3501
+ const fresh = this._find(convId);
3502
+ if (!fresh) return;
3269
3503
  const updated = {
3270
- ...conv,
3271
- messages,
3272
- updatedAt: contentChanged ? Date.now() : conv.updatedAt
3504
+ ...fresh,
3505
+ // What is stored is what the caller sent: segments a viewport DERIVED on
3506
+ // display are not the app's content. Stored, they would fill the record with
3507
+ // core-generated ids and freeze the parse as it stood the day it was saved —
3508
+ // a block grammar registered later would never apply to that message.
3509
+ messages: storedMessages,
3510
+ updatedAt: contentChanged ? Date.now() : fresh.updatedAt
3273
3511
  };
3274
- if (tree !== void 0) updated.tree = tree;
3512
+ if (storedTree !== void 0) updated.tree = storedTree;
3513
+ if (title !== void 0) updated.title = title;
3275
3514
  this._replace(updated);
3276
3515
  await this._adapter.save(updated);
3277
3516
  this._notify();
@@ -3279,35 +3518,41 @@ class AparteConversationManager {
3279
3518
  /** Permanently delete a conversation. */
3280
3519
  async delete(id) {
3281
3520
  this._conversations = this._conversations.filter((c) => c.id !== id);
3521
+ this._loaded.delete(id);
3282
3522
  if (this._activeId === id) this._activeId = null;
3283
3523
  await this._adapter.delete(id);
3284
3524
  this._notify();
3285
3525
  }
3286
3526
  /** Archive a conversation (soft-delete). */
3287
3527
  async archive(id) {
3288
- const conv = this._find(id);
3289
- if (!conv) return;
3290
- const updated = { ...conv, archivedAt: Date.now() };
3291
- this._replace(updated);
3292
- if (this._adapter.archive) {
3293
- await this._adapter.archive(id);
3294
- } else {
3295
- await this._adapter.save(updated);
3296
- }
3528
+ if (!await this._meta(id, (conv) => ({ ...conv, archivedAt: Date.now() }), this._adapter.archive)) return;
3297
3529
  if (this._activeId === id) this._activeId = null;
3298
3530
  this._notify();
3299
3531
  }
3532
+ /**
3533
+ * A metadata change, through the adapter's own hook when it has one (the row is
3534
+ * updated in memory, nothing else travels), else through `save()` — with the
3535
+ * messages fetched first, so a row the list came with (`messages: []`) is never
3536
+ * written back over the stored ones.
3537
+ */
3538
+ async _meta(id, change, hook) {
3539
+ if (hook) {
3540
+ const conv2 = this._find(id);
3541
+ if (!conv2) return false;
3542
+ this._replace(change(conv2));
3543
+ await hook.call(this._adapter, id);
3544
+ return true;
3545
+ }
3546
+ const conv = await this._full(id);
3547
+ if (!conv) return false;
3548
+ const updated = change(conv);
3549
+ this._replace(updated);
3550
+ await this._adapter.save(updated);
3551
+ return true;
3552
+ }
3300
3553
  /** Restore an archived conversation. */
3301
3554
  async unarchive(id) {
3302
- const conv = this._find(id);
3303
- if (!conv) return;
3304
- const updated = { ...conv, archivedAt: void 0 };
3305
- this._replace(updated);
3306
- if (this._adapter.unarchive) {
3307
- await this._adapter.unarchive(id);
3308
- } else {
3309
- await this._adapter.save(updated);
3310
- }
3555
+ if (!await this._meta(id, (conv) => ({ ...conv, archivedAt: void 0 }), this._adapter.unarchive)) return;
3311
3556
  this._notify();
3312
3557
  }
3313
3558
  /**
@@ -3317,28 +3562,12 @@ class AparteConversationManager {
3317
3562
  * does not gets the whole record through `save()`.
3318
3563
  */
3319
3564
  async pin(id) {
3320
- const conv = this._find(id);
3321
- if (!conv) return;
3322
- const updated = { ...conv, pinnedAt: Date.now() };
3323
- this._replace(updated);
3324
- if (this._adapter.pin) {
3325
- await this._adapter.pin(id);
3326
- } else {
3327
- await this._adapter.save(updated);
3328
- }
3565
+ if (!await this._meta(id, (conv) => ({ ...conv, pinnedAt: Date.now() }), this._adapter.pin)) return;
3329
3566
  this._notify();
3330
3567
  }
3331
3568
  /** Unpin a conversation. Metadata only — see `pin()`. */
3332
3569
  async unpin(id) {
3333
- const conv = this._find(id);
3334
- if (!conv) return;
3335
- const updated = { ...conv, pinnedAt: void 0 };
3336
- this._replace(updated);
3337
- if (this._adapter.unpin) {
3338
- await this._adapter.unpin(id);
3339
- } else {
3340
- await this._adapter.save(updated);
3341
- }
3570
+ if (!await this._meta(id, (conv) => ({ ...conv, pinnedAt: void 0 }), this._adapter.unpin)) return;
3342
3571
  this._notify();
3343
3572
  }
3344
3573
  /** Update the title manually. The full input is preserved — UI surfaces
@@ -3346,12 +3575,17 @@ class AparteConversationManager {
3346
3575
  * (`min-w-0` + `truncate`). Auto-titles produced internally by
3347
3576
  * `_autoTitle()` remain capped at a sensible length on input. */
3348
3577
  async updateTitle(id, title) {
3349
- const conv = this._find(id);
3350
- if (!conv) return;
3351
- const updated = { ...conv, title: title.trim(), updatedAt: Date.now() };
3352
- this._replace(updated);
3353
- await this._adapter.save(updated);
3354
- this._notify();
3578
+ if (!this._find(id)) {
3579
+ console.warn(`[AparteConversationManager] updateTitle('${id}'): unknown conversation.`);
3580
+ return;
3581
+ }
3582
+ const next = title.trim();
3583
+ const done = await this._meta(
3584
+ id,
3585
+ (conv) => ({ ...conv, title: next, autoTitle: false }),
3586
+ this._adapter.rename ? (convId) => this._adapter.rename(convId, next) : void 0
3587
+ );
3588
+ if (done) this._notify();
3355
3589
  }
3356
3590
  // ─── Observer ───────────────────────────────────────────────────────────
3357
3591
  subscribe(listener) {
@@ -3378,8 +3612,9 @@ class AparteConversationManager {
3378
3612
  });
3379
3613
  }
3380
3614
  /**
3381
- * The title of a conversation, decided once, on its first user message: the
3382
- * provider's answer when one is registered and answers, else the default.
3615
+ * The title of a conversation, decided on its first user message — and again if
3616
+ * that message is edited while the title is still the manager's: the provider's
3617
+ * answer when one is registered and answers, else the default.
3383
3618
  */
3384
3619
  async _title(msg) {
3385
3620
  const fallback = this._autoTitle(msg);
@@ -3393,7 +3628,7 @@ class AparteConversationManager {
3393
3628
  }
3394
3629
  }
3395
3630
  _autoTitle(msg) {
3396
- return (msg.content ?? "").trim() || "New Chat";
3631
+ return (msg.content ?? "").trim();
3397
3632
  }
3398
3633
  }
3399
3634
  function filesToAttachments(files) {
@@ -3432,6 +3667,8 @@ class AparteConversationController {
3432
3667
  _subscribedTo = null;
3433
3668
  /** Between bind() and unbind(): the only time a manager subscription may be installed. */
3434
3669
  _bound = false;
3670
+ /** The binding shows the wait this controller asked for — see `_showWait`. */
3671
+ _waitShown = false;
3435
3672
  /**
3436
3673
  * React to an external mutation of the manager (another component deleting or
3437
3674
  * archiving the active conversation). Without this the binding would keep
@@ -3452,6 +3689,7 @@ class AparteConversationController {
3452
3689
  }
3453
3690
  this._activeId = null;
3454
3691
  this._binding.clearMessages();
3692
+ this._showWait(false);
3455
3693
  }
3456
3694
  };
3457
3695
  constructor(binding, options = {}) {
@@ -3558,7 +3796,7 @@ class AparteConversationController {
3558
3796
  void this.setConversationId(evt.detail?.id ?? null);
3559
3797
  };
3560
3798
  if (typeof window !== "undefined") {
3561
- window.addEventListener("aparte-select-conversation", this._onSelectConversation);
3799
+ window.addEventListener("aparte-conversation-select", this._onSelectConversation);
3562
3800
  }
3563
3801
  this._bound = true;
3564
3802
  this._manager();
@@ -3599,7 +3837,7 @@ class AparteConversationController {
3599
3837
  this._onMessageAborted = null;
3600
3838
  }
3601
3839
  if (this._onSelectConversation && typeof window !== "undefined") {
3602
- window.removeEventListener("aparte-select-conversation", this._onSelectConversation);
3840
+ window.removeEventListener("aparte-conversation-select", this._onSelectConversation);
3603
3841
  this._onSelectConversation = null;
3604
3842
  }
3605
3843
  if (this._unsubscribeManager) {
@@ -3645,6 +3883,7 @@ class AparteConversationController {
3645
3883
  this._activeId = null;
3646
3884
  manager?.clearActive();
3647
3885
  if (isSwitch) this._binding.clearMessages();
3886
+ this._showWait(false);
3648
3887
  return;
3649
3888
  }
3650
3889
  const conv = manager?.conversations.find((c) => c.id === id);
@@ -3660,29 +3899,68 @@ class AparteConversationController {
3660
3899
  console.warn("[ConversationController] id still unknown after hydration — clearing. id:", id);
3661
3900
  manager.clearActive();
3662
3901
  this._binding.clearMessages();
3902
+ this._showWait(false);
3663
3903
  }
3664
3904
  });
3665
3905
  this._stopHydrationRetry = stop;
3906
+ this._showWait(false);
3666
3907
  return;
3667
3908
  }
3668
3909
  console.warn("[ConversationController] unknown id or no manager — clearing. manager:", !!manager, "conv:", !!conv);
3669
3910
  this._activeId = null;
3670
3911
  manager?.clearActive();
3671
3912
  this._binding.clearMessages();
3913
+ this._showWait(false);
3672
3914
  return;
3673
3915
  }
3674
3916
  this._activeId = id;
3675
3917
  manager.select(id);
3918
+ let record = conv;
3919
+ const fetching = !manager.isLoaded(id);
3920
+ if (fetching) {
3921
+ this._showWait(true);
3922
+ this._binding.clearMessages();
3923
+ let loaded = false;
3924
+ try {
3925
+ loaded = await manager.ensureFull(id);
3926
+ } catch (err) {
3927
+ console.warn("[ConversationController] the conversation could not be loaded:", id, err);
3928
+ }
3929
+ if (this._activeId !== id) return;
3930
+ if (!loaded) {
3931
+ if (manager.conversations.some((c) => c.id === id)) {
3932
+ console.warn("[ConversationController] no messages came back for", id, "— leaving it.");
3933
+ }
3934
+ this._activeId = null;
3935
+ manager.clearActive();
3936
+ this._binding.clearMessages();
3937
+ this._showWait(false);
3938
+ return;
3939
+ }
3940
+ record = manager.conversations.find((c) => c.id === id) ?? conv;
3941
+ }
3676
3942
  this._isLoadingConversation = true;
3677
3943
  try {
3678
- this._binding.setMessages([...conv.messages]);
3679
- if (conv.tree && this._binding.importTree) {
3680
- this._binding.importTree(conv.tree);
3944
+ this._binding.setMessages([...record.messages ?? []]);
3945
+ if (record.tree && this._binding.importTree) {
3946
+ this._binding.importTree(record.tree);
3681
3947
  }
3682
3948
  } finally {
3683
3949
  this._isLoadingConversation = false;
3950
+ this._showWait(false);
3684
3951
  }
3685
3952
  }
3953
+ /**
3954
+ * The binding's wait, owned here: whoever runs `setConversationId` last clears it
3955
+ * — a loaded conversation or a new chat that replaces a fetch, a delete of the
3956
+ * one being fetched — so a wait shown for one call never outlives the call that
3957
+ * superseded it.
3958
+ */
3959
+ _showWait(on) {
3960
+ if (this._waitShown === on) return;
3961
+ this._waitShown = on;
3962
+ this._binding.setLoading?.(on);
3963
+ }
3686
3964
  // ─────────────────────────────────────────────────────────────────────
3687
3965
  // Internal
3688
3966
  // ─────────────────────────────────────────────────────────────────────
@@ -3700,6 +3978,7 @@ class AparteConversationController {
3700
3978
  if (!this._activeId) return;
3701
3979
  const manager = this._manager();
3702
3980
  if (!manager) return;
3981
+ if (!manager.isLoaded(this._activeId)) return;
3703
3982
  const msgs = this._binding.getMessages().map(
3704
3983
  (m) => m.status === "streaming" || m.status === "pending" ? { ...m, status: "completed" } : m
3705
3984
  );
@@ -3729,12 +4008,12 @@ class AparteConversationController {
3729
4008
  }
3730
4009
  if (!convId) {
3731
4010
  if (!this._ensureInFlight) {
3732
- const text = (userMsg.content ?? "").toString().trim() || "New Chat";
4011
+ const text = (userMsg.content ?? "").toString().trim();
3733
4012
  this._ensureInFlight = manager.createNew(text).then((conv) => conv.id);
3734
4013
  }
3735
4014
  convId = await this._ensureInFlight;
3736
4015
  this._ensureInFlight = null;
3737
- if (this._activeId !== wasActiveId) {
4016
+ if (this._activeId !== wasActiveId && this._activeId !== convId) {
3738
4017
  void manager.delete(convId).catch(() => {
3739
4018
  });
3740
4019
  return;
@@ -3872,7 +4151,9 @@ class AparteChatHost {
3872
4151
  setMessages: (msgs) => {
3873
4152
  this._beginConversationSwap();
3874
4153
  this._vp()?.resetSpacer?.();
3875
- this.binding.setMessages(msgs.map(adoptMessageSegments));
4154
+ const adopted = msgs.map(adoptMessageSegments);
4155
+ this.binding.setMessages(adopted);
4156
+ this._vp()?.setMessages?.(adopted);
3876
4157
  },
3877
4158
  appendMessage: (msg) => this.appendMessage(msg),
3878
4159
  getMessages: () => this.binding.getMessages(),
@@ -3887,6 +4168,17 @@ class AparteChatHost {
3887
4168
  },
3888
4169
  importTree: (tree) => {
3889
4170
  this._vp()?.importTree?.(tree);
4171
+ },
4172
+ // One writer for the wait. A wrapper that hears `onLoadingChange` draws the
4173
+ // wait itself and writes the viewport's attribute from its own
4174
+ // `loading || hostLoading` — so the host writing it too meant that, with the
4175
+ // consumer's own prop on, a `setLoading(false)` here removed the attribute
4176
+ // (and `aria-busy`) while the wrapper still drew the skeleton and still held
4177
+ // the composer shut. A binding that cannot hear it has no other way to know,
4178
+ // and there the attribute stays the host's to write.
4179
+ setLoading: (on) => {
4180
+ if (this.binding.onLoadingChange) this.binding.onLoadingChange(on);
4181
+ else this._vp()?.setLoading?.(on);
3890
4182
  }
3891
4183
  };
3892
4184
  this._controller = new AparteConversationController(convBinding, {
@@ -3992,7 +4284,7 @@ class AparteChatHost {
3992
4284
  // defaults rather than the page's.
3993
4285
  resolveConfig(this.binding.host).getSegmentDefaults(segment.type)
3994
4286
  ) : segment;
3995
- this._lastBubble()?.addSegment?.(stamped);
4287
+ this._streamBubble(last?.id)?.addSegment?.(stamped);
3996
4288
  if (!last) return;
3997
4289
  const segments = [...last.segments || [], stamped];
3998
4290
  const next = [...msgs.slice(0, -1), { ...last, segments }];
@@ -4007,7 +4299,7 @@ class AparteChatHost {
4007
4299
  if (last && this._isOrphan(last.id)) return;
4008
4300
  const current = last?.segments?.find((s) => s.id === segmentId);
4009
4301
  const stamped = current ? stampSegmentOnUpdate(current, updates) : updates;
4010
- this._lastBubble()?.updateSegment?.(segmentId, stamped);
4302
+ this._streamBubble(last?.id)?.updateSegment?.(segmentId, stamped);
4011
4303
  if (!last || !last.segments) return;
4012
4304
  const segments = last.segments.map(
4013
4305
  (s) => s.id === segmentId ? mergeSegmentUpdate(s, stamped) : s
@@ -4019,7 +4311,7 @@ class AparteChatHost {
4019
4311
  /** Remove a transient segment (a status row the host added itself). */
4020
4312
  removeSegment(segmentId) {
4021
4313
  this._flushStreamState();
4022
- this._lastBubble()?.removeSegment?.(segmentId);
4314
+ this._streamBubble(this.binding.getMessages().at(-1)?.id)?.removeSegment?.(segmentId);
4023
4315
  const msgs = this.binding.getMessages();
4024
4316
  const last = msgs[msgs.length - 1];
4025
4317
  if (!last?.segments) return;
@@ -4331,6 +4623,27 @@ class AparteChatHost {
4331
4623
  `aparte-chat-bubble[message-id="${cssEscape(id)}"]`
4332
4624
  );
4333
4625
  }
4626
+ /**
4627
+ * The bubble the streaming writes below belong to: the one carrying that message's
4628
+ * id, and NOTHING when it is not on the page yet.
4629
+ *
4630
+ * It used to be "the last bubble in the viewport", which is a different message
4631
+ * whenever the DOM is a render behind the list — which is every framework here.
4632
+ * `appendMessage` puts the new reply in React's state and React paints it on its own
4633
+ * schedule, so a first chunk arriving in between went into the PREVIOUS reply's
4634
+ * bubble; `syncBubbles` then wrote the same segment onto the real one, and the same
4635
+ * `data-segment-id` was on two bubbles.
4636
+ *
4637
+ * Skipping the paint is safe because it is only ever a paint: the message list is
4638
+ * written either way, and `syncBubbles` — called by the framework after each render,
4639
+ * always re-syncing the last message — puts it on screen as soon as the bubble
4640
+ * exists. Without an id there is nothing to resolve, so the last bubble stands: that
4641
+ * is a host writing into a transcript it has not appended to.
4642
+ */
4643
+ _streamBubble(messageId) {
4644
+ if (messageId) return this._bubbleById(messageId);
4645
+ return this._lastBubble();
4646
+ }
4334
4647
  _lastBubble() {
4335
4648
  const vp = this.binding.viewport;
4336
4649
  if (!vp) return null;
@@ -4357,9 +4670,15 @@ class AparteChatHost {
4357
4670
  const d = e?.detail;
4358
4671
  if (d?.messageId) this._setStreamingId(d.messageId);
4359
4672
  };
4673
+ const endsThisTurn = (e) => {
4674
+ const id = e?.detail?.messageId;
4675
+ return !id || this._streamingId === null || id === this._streamingId;
4676
+ };
4360
4677
  const onDone = (e) => {
4361
- this.binding.onTypingChange?.(false);
4362
- this._setStreamingId(null);
4678
+ if (endsThisTurn(e)) {
4679
+ this.binding.onTypingChange?.(false);
4680
+ this._setStreamingId(null);
4681
+ }
4363
4682
  const d = e?.detail;
4364
4683
  if (!d?.messageId || !d.usage) return;
4365
4684
  const next = this.binding.getMessages().map(
@@ -4369,7 +4688,8 @@ class AparteChatHost {
4369
4688
  this.binding.onMessagesChange?.(next);
4370
4689
  this._bubbleById(d.messageId)?.setUsage?.(d.usage);
4371
4690
  };
4372
- const onEnd = () => {
4691
+ const onEnd = (e) => {
4692
+ if (!endsThisTurn(e)) return;
4373
4693
  this.binding.onTypingChange?.(false);
4374
4694
  this._setStreamingId(null);
4375
4695
  };
@@ -4589,6 +4909,15 @@ function readableToAsyncIterable(stream, signal) {
4589
4909
  }
4590
4910
  };
4591
4911
  }
4912
+ function eventOrigin(e) {
4913
+ return e.composedPath?.()[0] ?? e.target;
4914
+ }
4915
+ function rootOf(node) {
4916
+ return node.getRootNode();
4917
+ }
4918
+ function activeElementIn(node) {
4919
+ return node.getRootNode().activeElement ?? document.activeElement;
4920
+ }
4592
4921
  let steppedPanelSeq = 0;
4593
4922
  let _relabels = null;
4594
4923
  function onRelabel(fn) {
@@ -5110,11 +5439,14 @@ function requestUserInput(request) {
5110
5439
  return resolveConfig(request.target ?? null).requestUserInput(request);
5111
5440
  }
5112
5441
  const COMPACTION_PREAMBLE = "The earlier part of this conversation was compacted. This is a summary of what came before; continue from it.";
5442
+ const warnedUninlinedFiles = /* @__PURE__ */ new Set();
5443
+ const CHAT_SELECTOR = "aparte-chat, aparte-chat-viewport, [data-aparte-chat]";
5113
5444
  class AparteClient {
5114
5445
  _boundHandler = null;
5115
5446
  _boundAbortHandler = null;
5116
5447
  _boundRetryHandler = null;
5117
5448
  _boundEditHandler = null;
5449
+ _releaseKeyResolver = null;
5118
5450
  _isAborted = false;
5119
5451
  /**
5120
5452
  * Does this window event belong to this client?
@@ -5135,7 +5467,7 @@ class AparteClient {
5135
5467
  const scope = this.options.scopeToTargetId;
5136
5468
  if (scope) return detail?.targetId === scope;
5137
5469
  if (this._config === aparteGlobalConfig) return true;
5138
- const target = this._resolveTarget(detail?.targetId);
5470
+ const target = this._resolveTarget(detail?.targetId, e);
5139
5471
  if (!target) return true;
5140
5472
  const owner = resolveConfig(target);
5141
5473
  return owner === this._config || owner === aparteGlobalConfig;
@@ -5151,6 +5483,7 @@ class AparteClient {
5151
5483
  ...options
5152
5484
  };
5153
5485
  this._config = options.config ?? aparteGlobalConfig;
5486
+ this._registerKeyResolver();
5154
5487
  if (this.options.autoRegister) {
5155
5488
  registerDefaultRenderers(this._config);
5156
5489
  } else {
@@ -5176,6 +5509,20 @@ class AparteClient {
5176
5509
  _beginUserTurn() {
5177
5510
  this._isAborted = false;
5178
5511
  }
5512
+ /**
5513
+ * Put this client's `keyResolver` on its config, and hold the teardown so
5514
+ * `stop()` can take it back off.
5515
+ *
5516
+ * The registration used to be fire-and-forget, so it outlived the client that
5517
+ * made it: a stopped client kept signing model-list requests, and a remount —
5518
+ * the way the wrappers document swapping options — added a source behind the
5519
+ * one it meant to replace instead of replacing it. Idempotent, so the usual
5520
+ * `new AparteClient({…}).start()` registers once.
5521
+ */
5522
+ _registerKeyResolver() {
5523
+ if (this._releaseKeyResolver || !this.options.keyResolver) return;
5524
+ this._releaseKeyResolver = this._config.registerKeyProvider(this.options.keyResolver);
5525
+ }
5179
5526
  /**
5180
5527
  * Sets up the event listeners.
5181
5528
  * This is called once in the constructor.
@@ -5194,6 +5541,7 @@ class AparteClient {
5194
5541
  * Start listening for aparte-send events on the window.
5195
5542
  */
5196
5543
  start() {
5544
+ this._registerKeyResolver();
5197
5545
  if (!this._boundHandler) {
5198
5546
  this._setupListeners();
5199
5547
  }
@@ -5229,6 +5577,8 @@ class AparteClient {
5229
5577
  */
5230
5578
  stop() {
5231
5579
  this.abort();
5580
+ this._releaseKeyResolver?.();
5581
+ this._releaseKeyResolver = null;
5232
5582
  if (!this._boundHandler) return;
5233
5583
  window.removeEventListener("aparte-send", this._boundHandler);
5234
5584
  this._boundHandler = null;
@@ -5429,7 +5779,7 @@ class AparteClient {
5429
5779
  const { messageId, targetId } = event.detail ?? {};
5430
5780
  this._beginUserTurn();
5431
5781
  if (!messageId) return;
5432
- const targetElement = this._resolveTarget(targetId);
5782
+ const targetElement = this._resolveTarget(targetId, event);
5433
5783
  if (!targetElement) {
5434
5784
  console.warn("[AparteClient] aparte-retry — no target found");
5435
5785
  return;
@@ -5490,7 +5840,7 @@ class AparteClient {
5490
5840
  const { messageId, content: newContent, targetId } = event.detail ?? {};
5491
5841
  this._beginUserTurn();
5492
5842
  if (!messageId || newContent === void 0) return;
5493
- const targetElement = this._resolveTarget(targetId);
5843
+ const targetElement = this._resolveTarget(targetId, event);
5494
5844
  if (!targetElement) {
5495
5845
  console.warn("[AparteClient] aparte-edit — no target found");
5496
5846
  return;
@@ -5532,24 +5882,83 @@ class AparteClient {
5532
5882
  authConfig
5533
5883
  );
5534
5884
  }
5885
+ /**
5886
+ * The chain the gesture really came up, nearest node first.
5887
+ *
5888
+ * Read from a `window` listener, `event.target` is the shadow HOST when the event
5889
+ * left a tree of the consumer's own, and `parentElement` stops at that tree's root —
5890
+ * so a walk from either starts, or ends, outside the chat the person typed into.
5891
+ * `composedPath()` is the one chain that crosses. The fallback is for an event
5892
+ * dispatched by hand, which has no path.
5893
+ */
5894
+ _originChain(event) {
5895
+ const path = event?.composedPath?.() ?? [];
5896
+ if (path.length > 0) return path;
5897
+ const chain = [];
5898
+ let node = event ? eventOrigin(event) : null;
5899
+ while (node) {
5900
+ chain.push(node);
5901
+ node = node.parentElement;
5902
+ }
5903
+ return chain;
5904
+ }
5905
+ /**
5906
+ * The trees a lookup must search, the one the gesture came from FIRST.
5907
+ *
5908
+ * `document.getElementById` and `document.querySelectorAll` cannot see into a shadow
5909
+ * root, so a chat mounted in one — the arrangement the theming guide endorses — was
5910
+ * invisible to both: alone, the send was dropped with a warning; on a page that also
5911
+ * holds a chat in the light DOM, the scan answered with the WRONG chat, so the
5912
+ * person's message AND the model's reply landed in a transcript they never typed
5913
+ * into. Nearest tree first is what keeps it from swapping the other way round.
5914
+ */
5915
+ _rootsFor(event) {
5916
+ const roots = [];
5917
+ for (const node of this._originChain(event)) {
5918
+ if (typeof node.getRootNode !== "function") continue;
5919
+ const root = rootOf(node);
5920
+ if (!roots.includes(root)) roots.push(root);
5921
+ }
5922
+ if (!roots.includes(document)) roots.push(document);
5923
+ return roots;
5924
+ }
5925
+ /** The element an id names, looked up in the gesture's own tree before the document. */
5926
+ _byId(targetId, event) {
5927
+ for (const root of this._rootsFor(event)) {
5928
+ const el2 = root.getElementById?.(targetId);
5929
+ if (el2) return el2;
5930
+ }
5931
+ return null;
5932
+ }
5933
+ /** The first chat that can render, own tree first — the path when nothing names one. */
5934
+ _scanForTarget(event) {
5935
+ for (const root of this._rootsFor(event)) {
5936
+ for (const candidate of root.querySelectorAll(CHAT_SELECTOR)) {
5937
+ const target = this._asRenderTarget(candidate);
5938
+ if (target) return target;
5939
+ }
5940
+ }
5941
+ return null;
5942
+ }
5535
5943
  /**
5536
5944
  * Resolve a target element by id (from event detail.targetId) or via targetResolver / DOM scan.
5537
5945
  */
5538
- _resolveTarget(targetId) {
5946
+ _resolveTarget(targetId, event) {
5539
5947
  if (targetId) {
5540
- const el2 = document.getElementById(targetId);
5948
+ const el2 = this._byId(targetId, event);
5541
5949
  if (el2) return this._asRenderTarget(el2) ?? el2;
5542
5950
  }
5543
5951
  if (this.options.targetResolver) {
5544
5952
  const el2 = this.options.targetResolver();
5545
5953
  if (el2) return this._asRenderTarget(el2) ?? el2;
5546
5954
  }
5547
- const candidates = document.querySelectorAll("aparte-chat, aparte-chat-viewport, [data-aparte-chat]");
5548
- for (const candidate of candidates) {
5549
- const target = this._asRenderTarget(candidate);
5550
- if (target) return target;
5955
+ const scanned = this._scanForTarget(event);
5956
+ if (scanned) return scanned;
5957
+ for (const root of this._rootsFor(event)) {
5958
+ const first = root.querySelector(CHAT_SELECTOR);
5959
+ if (first) return first;
5551
5960
  }
5552
- return candidates[0] ?? null;
5961
+ return null;
5553
5962
  }
5554
5963
  /**
5555
5964
  * Resolve an element to a usable render target: itself when it exposes
@@ -5640,7 +6049,7 @@ class AparteClient {
5640
6049
  let targetElement = null;
5641
6050
  const targetId = event.detail?.targetId;
5642
6051
  if (targetId) {
5643
- const byId = document.getElementById(targetId);
6052
+ const byId = this._byId(targetId, event);
5644
6053
  const resolved = this._asRenderTarget(byId);
5645
6054
  if (resolved) {
5646
6055
  targetElement = resolved;
@@ -5649,32 +6058,22 @@ class AparteClient {
5649
6058
  }
5650
6059
  }
5651
6060
  if (!targetElement && this.options.targetResolver) {
5652
- const resolved = this.options.targetResolver();
5653
- if (resolved && typeof resolved.appendMessage === "function") {
5654
- targetElement = resolved;
5655
- }
5656
- }
5657
- if (!targetElement) {
5658
- let walker = event.target;
5659
- while (walker && typeof walker.appendMessage !== "function") {
5660
- walker = walker.parentElement;
5661
- }
5662
- if (walker) {
5663
- targetElement = walker;
5664
- }
6061
+ targetElement = this._asRenderTarget(
6062
+ this.options.targetResolver()
6063
+ );
5665
6064
  }
5666
6065
  if (!targetElement) {
5667
- const candidates = document.querySelectorAll(
5668
- "aparte-chat, aparte-chat-viewport, [data-aparte-chat]"
5669
- );
5670
- for (const candidate of candidates) {
5671
- const resolved = this._asRenderTarget(candidate);
5672
- if (resolved) {
5673
- targetElement = resolved;
6066
+ for (const node of this._originChain(event)) {
6067
+ const walker = node;
6068
+ if (typeof walker?.appendMessage === "function") {
6069
+ targetElement = walker;
5674
6070
  break;
5675
6071
  }
5676
6072
  }
5677
6073
  }
6074
+ if (!targetElement) {
6075
+ targetElement = this._scanForTarget(event);
6076
+ }
5678
6077
  if (!targetElement) {
5679
6078
  console.warn("[AparteClient] ⚠️ No target element found with appendMessage support. Provide a targetResolver in AparteClientOptions or ensure the composer has a `target` attribute.");
5680
6079
  return;
@@ -5841,7 +6240,8 @@ ${text}` : text;
5841
6240
  *
5842
6241
  * `thinking` and `tool_call` stay out on purpose — the first is the model's own
5843
6242
  * scratchpad, and most APIs neither want it back nor bill for it kindly; the
5844
- * second is already in the history as a call and a result.
6243
+ * second was already carried, within the turn, as the assistant's calls and the
6244
+ * `tool` messages answering them.
5845
6245
  */
5846
6246
  _segmentsToText(segments) {
5847
6247
  if (!segments?.length) return "";
@@ -5885,7 +6285,8 @@ ${fence}`);
5885
6285
  * - Images → AparteImagePart (base64 data URL)
5886
6286
  * - Text files (txt, md, json, csv, xml, html, css, js, ts, …) → AparteTextPart
5887
6287
  * injected as a fenced block so all models (including local) can read them.
5888
- * - Other binary files → silently ignored.
6288
+ * - Other binary files → no content part, and a one-time `console.warn` naming the
6289
+ * file (see the branch below for why it is not silent).
5889
6290
  */
5890
6291
  async _filesToContentParts(files) {
5891
6292
  const TEXT_TYPES = /^(text\/|application\/(json|xml|javascript|typescript|x-yaml|yaml|toml|csv|markdown))/i;
@@ -5918,6 +6319,12 @@ ${content}
5918
6319
  reader.readAsText(file);
5919
6320
  });
5920
6321
  }
6322
+ if (!warnedUninlinedFiles.has(file.name)) {
6323
+ warnedUninlinedFiles.add(file.name);
6324
+ console.warn(
6325
+ `[aparte] ${file.name} (${file.type || "unknown type"}) is attached to the message but is not sent to the model: aparté inlines images and text files only. Handle it in your app (RAG, an upload) — the chip stays visible either way.`
6326
+ );
6327
+ }
5921
6328
  return Promise.resolve(null);
5922
6329
  })
5923
6330
  );
@@ -6107,6 +6514,20 @@ class AparteMessageRepository {
6107
6514
  getMessageById(id) {
6108
6515
  return this._nodes.get(id)?.current;
6109
6516
  }
6517
+ /**
6518
+ * Every message the tree holds — every branch, not just the active path.
6519
+ *
6520
+ * `getMessages()` walks head → root, so a question about the whole conversation
6521
+ * ("is anything still streaming?") answered from it could not see a turn that a
6522
+ * retry had just taken off the path — and the viewport wrote that turn's remaining
6523
+ * segments into the bubble which replaced it. No order is promised beyond
6524
+ * insertion: a caller that needs the topology reads `export()`.
6525
+ */
6526
+ getAllMessages() {
6527
+ const all = [];
6528
+ for (const node of this._nodes.values()) all.push(node.current);
6529
+ return all;
6530
+ }
6110
6531
  /**
6111
6532
  * Returns the IDs of all siblings of `messageId`
6112
6533
  * (i.e. all children of its parent, including itself).
@@ -6165,15 +6586,19 @@ class AparteMessageRepository {
6165
6586
  this._head = findHead(node);
6166
6587
  }
6167
6588
  /**
6168
- * Remove all descendants of `messageId` (inclusive) and set `head` to
6169
- * the parent of `messageId`. Used by edit/truncate flows.
6589
+ * Remove all descendants of `messageId` (inclusive) and, when the head was among
6590
+ * them, set `head` to the parent of `messageId`. Used by edit/truncate flows.
6591
+ *
6592
+ * A head in a branch this call did not touch stays where it is: it used to be
6593
+ * relocated regardless, which took the active path out of the version the reader was
6594
+ * on for a truncate somewhere else entirely.
6170
6595
  *
6171
6596
  * @param messageId The first message to remove
6172
- * @param newParentId Optional explicit parent to set head to
6173
6597
  */
6174
6598
  resetHead(messageId) {
6175
6599
  const node = this._nodes.get(messageId);
6176
6600
  if (!node) return;
6601
+ const headBefore = this._head;
6177
6602
  this._deleteDescendants(node);
6178
6603
  const parentOrRoot = node.prev ?? this._root;
6179
6604
  parentOrRoot.children = parentOrRoot.children.filter((id) => id !== messageId);
@@ -6181,7 +6606,8 @@ class AparteMessageRepository {
6181
6606
  parentOrRoot.next = null;
6182
6607
  }
6183
6608
  this._nodes.delete(messageId);
6184
- this._head = node.prev;
6609
+ const headSurvived = headBefore !== null && headBefore !== node && this._nodes.get(headBefore.current.id) === headBefore;
6610
+ if (!headSurvived) this._head = node.prev;
6185
6611
  }
6186
6612
  /**
6187
6613
  * Update a message's data without changing its position in the tree.
@@ -6336,13 +6762,13 @@ const APARTE_DEFAULT_UI_EVENTS = [
6336
6762
  "aparte-reset-done",
6337
6763
  "aparte-path-changed",
6338
6764
  // <aparte-conversation-list>
6339
- "aparte-select-conversation",
6340
- "aparte-delete-conversation",
6341
- "aparte-archive-conversation",
6342
- "aparte-unarchive-conversation",
6343
- "aparte-rename-conversation",
6344
- "aparte-pin-conversation",
6345
- "aparte-unpin-conversation",
6765
+ "aparte-conversation-select",
6766
+ "aparte-conversation-delete",
6767
+ "aparte-conversation-archive",
6768
+ "aparte-conversation-unarchive",
6769
+ "aparte-conversation-rename",
6770
+ "aparte-conversation-pin",
6771
+ "aparte-conversation-unpin",
6346
6772
  // the select primitives
6347
6773
  "aparte-select-change",
6348
6774
  "aparte-select-open",
@@ -6470,15 +6896,19 @@ export {
6470
6896
  subscribeConfigChange as a4,
6471
6897
  unregisterSegmentRenderer as a5,
6472
6898
  uuid as a6,
6473
- mergeSegmentUpdate as a7,
6474
- writeStreamedMarkdown as a8,
6475
- stampSegmentActivity as a9,
6476
- stampSegmentOnInsert as aa,
6477
- stampSegmentOnUpdate as ab,
6478
- renumberSegments as ac,
6479
- openSegmentIds as ad,
6480
- isTerminalStatus as ae,
6481
- adoptMessageSegments as af,
6899
+ eventOrigin as a7,
6900
+ activeElementIn as a8,
6901
+ mergeSegmentUpdate as a9,
6902
+ writeStreamedMarkdown as aa,
6903
+ stampSegmentActivity as ab,
6904
+ stampSegmentOnInsert as ac,
6905
+ stampSegmentOnUpdate as ad,
6906
+ renumberSegments as ae,
6907
+ openSegmentIds as af,
6908
+ isTerminalStatus as ag,
6909
+ adoptMessageSegments as ah,
6910
+ APARTE_DERIVED_SEGMENTS as ai,
6911
+ rootOf as aj,
6482
6912
  APARTE_DEFAULT_BUBBLE_ACTIONS as b,
6483
6913
  APARTE_DEFAULT_HOST_HANDLERS as c,
6484
6914
  APARTE_DEFAULT_ICON_FALLBACKS as d,
@@ -6505,4 +6935,4 @@ export {
6505
6935
  collectRendererStyles as y,
6506
6936
  contentToText as z
6507
6937
  };
6508
- //# sourceMappingURL=dialog-triggers-cYGTlfZf.js.map
6938
+ //# sourceMappingURL=dialog-triggers-Bb-3EkT4.js.map