@aparte/core 0.24.0 → 0.26.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 (106) hide show
  1. package/dist/client/agent-runner.d.ts.map +1 -1
  2. package/dist/client/aparte-client.d.ts +38 -76
  3. package/dist/client/aparte-client.d.ts.map +1 -1
  4. package/dist/client/stream-adapter.d.ts.map +1 -1
  5. package/dist/client/wire-history.d.ts +78 -0
  6. package/dist/client/wire-history.d.ts.map +1 -1
  7. package/dist/components/bubble/aparte-chat-bubble.d.ts +152 -2
  8. package/dist/components/bubble/aparte-chat-bubble.d.ts.map +1 -1
  9. package/dist/components/bubble/bubble-sync.d.ts +5 -1
  10. package/dist/components/bubble/bubble-sync.d.ts.map +1 -1
  11. package/dist/components/bubble/checkpoint-separator.d.ts +40 -0
  12. package/dist/components/bubble/checkpoint-separator.d.ts.map +1 -0
  13. package/dist/components/chat/aparte-chat.d.ts.map +1 -1
  14. package/dist/components/composer/aparte-composer-action.d.ts.map +1 -1
  15. package/dist/components/composer/aparte-composer-add-attachment.d.ts.map +1 -1
  16. package/dist/components/composer/aparte-composer-attachments.d.ts.map +1 -1
  17. package/dist/components/composer/aparte-composer-cancel.d.ts.map +1 -1
  18. package/dist/components/composer/aparte-composer-input.d.ts.map +1 -1
  19. package/dist/components/composer/aparte-composer-send.d.ts.map +1 -1
  20. package/dist/components/composer/aparte-composer-toolbar.d.ts.map +1 -1
  21. package/dist/components/composer/aparte-composer.d.ts.map +1 -1
  22. package/dist/components/context/aparte-context.d.ts +157 -25
  23. package/dist/components/context/aparte-context.d.ts.map +1 -1
  24. package/dist/components/conversation-list/aparte-conversation-list.d.ts.map +1 -1
  25. package/dist/components/elicitation/aparte-elicitation.d.ts.map +1 -1
  26. package/dist/components/scroll-rail/aparte-scroll-rail.d.ts.map +1 -1
  27. package/dist/components/sidebar/aparte-sidebar.d.ts.map +1 -1
  28. package/dist/components/split/aparte-split.d.ts.map +1 -1
  29. package/dist/components/status/aparte-chat-status.d.ts.map +1 -1
  30. package/dist/components/suggestions/aparte-suggestions.d.ts.map +1 -1
  31. package/dist/components/viewport/aparte-chat-viewport.d.ts +7 -0
  32. package/dist/components/viewport/aparte-chat-viewport.d.ts.map +1 -1
  33. package/dist/config/aparte-config.d.ts +47 -1
  34. package/dist/config/aparte-config.d.ts.map +1 -1
  35. package/dist/config/locale.d.ts +62 -4
  36. package/dist/config/locale.d.ts.map +1 -1
  37. package/dist/context/checkpoint-state.d.ts +70 -0
  38. package/dist/context/checkpoint-state.d.ts.map +1 -0
  39. package/dist/context/checkpoint.d.ts +28 -0
  40. package/dist/context/checkpoint.d.ts.map +1 -0
  41. package/dist/context/measure.d.ts +148 -0
  42. package/dist/context/measure.d.ts.map +1 -0
  43. package/dist/context/restore.d.ts +24 -0
  44. package/dist/context/restore.d.ts.map +1 -0
  45. package/dist/conversations/conversation-controller.d.ts +13 -4
  46. package/dist/conversations/conversation-controller.d.ts.map +1 -1
  47. package/dist/conversations/conversation-manager.d.ts +6 -0
  48. package/dist/conversations/conversation-manager.d.ts.map +1 -1
  49. package/dist/custom-elements.json +15347 -12878
  50. package/dist/{dialog-triggers-BqXRdHWZ.js → defer-hydration-8vI8xNNx.js} +1179 -374
  51. package/dist/defer-hydration-8vI8xNNx.js.map +1 -0
  52. package/dist/generated/element-attributes.d.ts +8 -2
  53. package/dist/generated/element-attributes.d.ts.map +1 -1
  54. package/dist/{glyphs-C8z2oJUg.js → glyphs-2bym5jl5.js} +1 -1
  55. package/dist/{glyphs-C8z2oJUg.js.map → glyphs-2bym5jl5.js.map} +1 -1
  56. package/dist/host/aparte-chat-host.d.ts +41 -2
  57. package/dist/host/aparte-chat-host.d.ts.map +1 -1
  58. package/dist/icons.js +2 -2
  59. package/dist/index.css +1 -1
  60. package/dist/index.d.ts +7 -2
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +1059 -95
  63. package/dist/index.js.map +1 -1
  64. package/dist/index.node.d.ts +6 -2
  65. package/dist/index.node.d.ts.map +1 -1
  66. package/dist/index.node.js +39 -29
  67. package/dist/index.node.js.map +1 -1
  68. package/dist/interop/defer-hydration.d.ts +34 -0
  69. package/dist/interop/defer-hydration.d.ts.map +1 -0
  70. package/dist/primitives/icon/aparte-icon.d.ts.map +1 -1
  71. package/dist/primitives/progress-spinner/aparte-progress-spinner.d.ts.map +1 -1
  72. package/dist/primitives/select/aparte-optgroup.d.ts.map +1 -1
  73. package/dist/primitives/select/aparte-option.d.ts.map +1 -1
  74. package/dist/primitives/select/aparte-select.d.ts.map +1 -1
  75. package/dist/renderers/index.d.ts +2 -1
  76. package/dist/renderers/index.d.ts.map +1 -1
  77. package/dist/renderers/mounted-renderer.d.ts +34 -0
  78. package/dist/renderers/mounted-renderer.d.ts.map +1 -0
  79. package/dist/renderers/segment-renderers.d.ts +14 -1
  80. package/dist/renderers/segment-renderers.d.ts.map +1 -1
  81. package/dist/renderers/segments/tool-call.d.ts +2 -1
  82. package/dist/renderers/segments/tool-call.d.ts.map +1 -1
  83. package/dist/transport/agent-handler.d.ts.map +1 -1
  84. package/dist/transport/backend-handler.d.ts.map +1 -1
  85. package/dist/transport/types.d.ts +24 -4
  86. package/dist/transport/types.d.ts.map +1 -1
  87. package/dist/types/chat.d.ts +16 -1
  88. package/dist/types/chat.d.ts.map +1 -1
  89. package/dist/types/compactor.d.ts +104 -0
  90. package/dist/types/compactor.d.ts.map +1 -0
  91. package/dist/types/errors.d.ts +2 -0
  92. package/dist/types/errors.d.ts.map +1 -1
  93. package/dist/types/events.d.ts +50 -3
  94. package/dist/types/events.d.ts.map +1 -1
  95. package/dist/types/index.d.ts +5 -4
  96. package/dist/types/index.d.ts.map +1 -1
  97. package/dist/types/model-provider.d.ts +3 -0
  98. package/dist/types/model-provider.d.ts.map +1 -1
  99. package/dist/types/models.d.ts +43 -3
  100. package/dist/types/models.d.ts.map +1 -1
  101. package/dist/types/segments.d.ts +22 -4
  102. package/dist/types/segments.d.ts.map +1 -1
  103. package/dist/types/tools.d.ts +56 -0
  104. package/dist/types/tools.d.ts.map +1 -1
  105. package/package.json +2 -2
  106. package/dist/dialog-triggers-BqXRdHWZ.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a1 as resolveConfig, aa as eventOrigin, ab as activeElementIn, a6 as subscribeConfigChange, a3 as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, ac as gateGlide, L as escapeAttr, ad as renderingVersionOf, H as cssEscape, ae as mergeSegmentUpdate, M as escapeHtml, V as isSegmentSettled, af as writeStreamedMarkdown, U as isSafeUrl, C as copyText, P as getSegmentRenderer, Q as installDefaultRenderersOnce, r as AparteMessageRepository, ag as stampSegmentActivity, ah as stampSegmentOnInsert, ai as stampSegmentOnUpdate, aj as renumberSegments, ak as openSegmentIds, al as isTerminalStatus, am as adoptMessageSegments, S as isAwaitingReply, Y as populateBubbleFromMessage, a9 as uuid, a2 as revokeAttachmentUrls, X as parseMarkdownToSegments, an as APARTE_DERIVED_SEGMENTS, ao as rememberConversationStatus, ap as tellConversationStatus, aq as conversationStatusOf, e as APARTE_DEFAULT_LOCALE, ar as chatBoundaryOf, as as rootOf, R as installDialogTriggersOnce } from "./dialog-triggers-BqXRdHWZ.js";
2
- import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, q, s, t, u, v, y, z, B, D, E, F, G, I, J, K, N, O, T, W, Z, _, $, a0, a4, a5, a7, a8 } from "./dialog-triggers-BqXRdHWZ.js";
1
+ import { K as deferHydrationOf, aa as resolveConfig, ak as eventOrigin, al as activeElementIn, af as subscribeConfigChange, ac as runWithConfig, o as AparteElicitationAbortError, w as buildApprovalPanel, x as buildElicitationPanel, am as gateGlide, O as escapeAttr, an as chatBoundaryOf, ao as toolCallRenderer, ap as toolRendererThatDrew, t as aparteGlobalConfig, aq as isMountedRenderer, ar as renderingVersionOf, H as cssEscape, as as onRenderersChange, at as mergeSegmentUpdate, P as escapeHtml, $ as isSegmentSettled, au as writeStreamedMarkdown, _ as isSafeUrl, C as copyText, U as getSegmentRenderer, W as installDefaultRenderersOnce, r as AparteMessageRepository, av as CheckpointStateWatcher, aw as chatIdOf, ax as stampSegmentActivity, ay as stampSegmentOnInsert, az as stampSegmentOnUpdate, aA as renumberSegments, aB as openSegmentIds, aC as isTerminalStatus, aD as adoptMessageSegments, Y as isAwaitingReply, a4 as populateBubbleFromMessage, aj as uuid, ab as revokeAttachmentUrls, a3 as parseMarkdownToSegments, aE as APARTE_DERIVED_SEGMENTS, aF as rememberConversationStatus, aG as tellConversationStatus, aH as conversationStatusOf, e as APARTE_DEFAULT_LOCALE, aI as usageTokens, q as AparteErrorCode, a0 as measureContext, aJ as rootOf, X as installDialogTriggersOnce } from "./defer-hydration-8vI8xNNx.js";
2
+ import { A, a, b, c, d, f, g, h, i, j, k, l, m, n, p, s, u, v, y, z, B, D, E, F, G, I, J, L, M, N, Q, R, S, T, V, Z, a1, a2, a5, a6, a7, a8, a9, ad, ae, ag, ah, ai } from "./defer-hydration-8vI8xNNx.js";
3
3
  function presenceOn(value) {
4
4
  return value === "" ? true : Boolean(value);
5
5
  }
@@ -64,6 +64,7 @@ class AparteOption extends HTMLElement {
64
64
  dot.setAttribute("data-status", status);
65
65
  }
66
66
  }
67
+ deferHydrationOf(AparteOption);
67
68
  if (!customElements.get("aparte-option")) {
68
69
  customElements.define("aparte-option", AparteOption);
69
70
  }
@@ -173,6 +174,7 @@ class AparteOptgroup extends HTMLElement {
173
174
  });
174
175
  }
175
176
  }
177
+ deferHydrationOf(AparteOptgroup);
176
178
  if (!customElements.get("aparte-optgroup")) {
177
179
  customElements.define("aparte-optgroup", AparteOptgroup);
178
180
  }
@@ -726,6 +728,7 @@ function optionLabelText(option) {
726
728
  }
727
729
  return text.trim();
728
730
  }
731
+ deferHydrationOf(AparteSelect);
729
732
  if (!customElements.get("aparte-select")) {
730
733
  customElements.define("aparte-select", AparteSelect);
731
734
  }
@@ -760,6 +763,7 @@ class AparteProgressSpinner extends HTMLElement {
760
763
  this.innerHTML = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle class="aparte-spinner-track" cx="12" cy="12" r="${this._r}"/><circle class="aparte-spinner-fill" cx="12" cy="12" r="${this._r}" stroke-dasharray="${dasharray}" stroke-dashoffset="${dashoffset.toFixed(2)}"/></svg>`;
761
764
  }
762
765
  }
766
+ deferHydrationOf(AparteProgressSpinner);
763
767
  if (!customElements.get("aparte-progress-spinner")) {
764
768
  customElements.define("aparte-progress-spinner", AparteProgressSpinner);
765
769
  }
@@ -786,6 +790,7 @@ class AparteIcon extends HTMLElement {
786
790
  this.firstElementChild?.setAttribute("aria-hidden", "true");
787
791
  }
788
792
  }
793
+ deferHydrationOf(AparteIcon);
789
794
  if (!customElements.get("aparte-icon")) {
790
795
  customElements.define("aparte-icon", AparteIcon);
791
796
  }
@@ -1100,6 +1105,7 @@ class AparteElicitation extends HTMLElement {
1100
1105
  }
1101
1106
  }
1102
1107
  if (typeof customElements !== "undefined" && !customElements.get("aparte-elicitation")) {
1108
+ deferHydrationOf(AparteElicitation);
1103
1109
  customElements.define("aparte-elicitation", AparteElicitation);
1104
1110
  }
1105
1111
  function isTranscriptEmpty(viewport) {
@@ -1297,9 +1303,100 @@ class AparteChat extends HTMLElement {
1297
1303
  this.toggleAttribute("data-empty", !viewport || isTranscriptEmpty(viewport));
1298
1304
  }
1299
1305
  }
1306
+ deferHydrationOf(AparteChat);
1300
1307
  if (!customElements.get("aparte-chat")) {
1301
1308
  customElements.define("aparte-chat", AparteChat);
1302
1309
  }
1310
+ const isStore = (candidate) => !!candidate && typeof candidate.getMessages === "function" && typeof candidate.updateMessage === "function";
1311
+ function storeOf(from) {
1312
+ const boundary = chatBoundaryOf(from);
1313
+ if (isStore(boundary)) return boundary;
1314
+ const viewport = from.closest("aparte-chat-viewport");
1315
+ return isStore(viewport) ? viewport : null;
1316
+ }
1317
+ function restoreContext(from, detail) {
1318
+ const store = storeOf(from);
1319
+ if (!store) return 0;
1320
+ const carrying = store.getMessages().filter((m2) => m2.contextSummary !== void 0).map((m2) => m2.id);
1321
+ for (const id of carrying) store.updateMessage(id, { contextSummary: void 0 });
1322
+ const done = { restored: true };
1323
+ if (detail.messageId) done.messageId = detail.messageId;
1324
+ if (detail.targetId) done.targetId = detail.targetId;
1325
+ window.dispatchEvent(new CustomEvent("aparte-compact-done", { detail: done }));
1326
+ return carrying.length;
1327
+ }
1328
+ function el$1(tag, className, text) {
1329
+ const node = document.createElement(tag);
1330
+ node.className = className;
1331
+ if (text !== void 0) node.textContent = text;
1332
+ return node;
1333
+ }
1334
+ function action(name, label) {
1335
+ const button = el$1("button", "aparte-btn aparte-btn--ghost aparte-btn--sm aparte-checkpoint__action", label);
1336
+ button.type = "button";
1337
+ button.setAttribute("data-checkpoint-action", name);
1338
+ return button;
1339
+ }
1340
+ function buildCheckpointSeparator(options) {
1341
+ const { kind, locale } = options;
1342
+ const root = el$1("div", "aparte-checkpoint");
1343
+ root.setAttribute("role", "group");
1344
+ root.setAttribute("aria-label", locale.checkpointGroup ?? "Context summary");
1345
+ root.setAttribute("data-state", kind);
1346
+ const row = el$1("div", "aparte-checkpoint__row aparte-divider aparte-divider--label");
1347
+ const label = el$1("div", "aparte-checkpoint__label");
1348
+ row.appendChild(label);
1349
+ root.appendChild(row);
1350
+ let sentence;
1351
+ if (kind === "done") {
1352
+ const n2 = options.count ?? 0;
1353
+ sentence = n2 === 1 ? locale.checkpointCountOne ?? "1 message summarised for the model" : (locale.checkpointCount ?? "{n} messages summarised for the model").replace("{n}", () => String(n2));
1354
+ } else if (kind === "legacy") {
1355
+ sentence = locale.checkpointLegacy ?? "Earlier messages were summarised";
1356
+ } else if (kind === "pending") {
1357
+ sentence = locale.checkpointPending ?? "Summarising the context…";
1358
+ } else {
1359
+ sentence = locale.checkpointFailed ?? "Couldn't summarise the context";
1360
+ }
1361
+ const text = el$1("span", "aparte-checkpoint__text", sentence);
1362
+ if (kind === "pending") {
1363
+ text.classList.add("aparte-shimmer");
1364
+ text.setAttribute("role", "status");
1365
+ } else if (kind === "failed") {
1366
+ text.setAttribute("role", "status");
1367
+ if (options.reason) {
1368
+ text.title = options.reason;
1369
+ text.appendChild(el$1("span", "aparte-sr-only", `: ${options.reason}`));
1370
+ }
1371
+ }
1372
+ label.appendChild(text);
1373
+ if (kind === "done" || kind === "legacy") {
1374
+ if (options.confirming && kind === "done") {
1375
+ const confirm = el$1("span", "aparte-checkpoint__confirm");
1376
+ const question = el$1("span", "aparte-checkpoint__question", locale.checkpointRestoreConfirm ?? "Send the original messages to the model again?");
1377
+ question.id = `${options.summaryId}-question`;
1378
+ const yes = action("restore-confirm", locale.checkpointRestore ?? "Restore");
1379
+ yes.setAttribute("aria-describedby", question.id);
1380
+ confirm.append(question, yes, action("restore-cancel", locale.cancel ?? "Cancel"));
1381
+ label.appendChild(confirm);
1382
+ } else {
1383
+ const toggle = action("toggle", options.open ? locale.checkpointHide ?? "Hide summary" : locale.checkpointShow ?? "Show summary");
1384
+ toggle.setAttribute("aria-expanded", String(options.open));
1385
+ toggle.setAttribute("aria-controls", options.summaryId);
1386
+ label.appendChild(toggle);
1387
+ if (kind === "done") label.appendChild(action("restore", locale.checkpointRestore ?? "Restore"));
1388
+ }
1389
+ } else if (kind === "failed") {
1390
+ label.appendChild(action("retry", locale.retry ?? "Retry"));
1391
+ }
1392
+ if (kind === "done" || kind === "legacy") {
1393
+ const panel = el$1("div", "aparte-checkpoint__summary aparte-segment-content");
1394
+ panel.id = options.summaryId;
1395
+ if (!options.open) panel.hidden = true;
1396
+ root.appendChild(panel);
1397
+ }
1398
+ return root;
1399
+ }
1303
1400
  let _warnedNoRenderer = false;
1304
1401
  function warnMissingRenderer(type) {
1305
1402
  if (_warnedNoRenderer) return;
@@ -1342,6 +1439,7 @@ function flushAvatarMarks() {
1342
1439
  });
1343
1440
  batch.forEach(([avatar], i2) => avatar.toggleAttribute("data-image", shown[i2]));
1344
1441
  }
1442
+ let checkpointIds = 0;
1345
1443
  class AparteChatBubble extends HTMLElement {
1346
1444
  _contentEl = null;
1347
1445
  _segmentsEl = null;
@@ -1355,6 +1453,23 @@ class AparteChatBubble extends HTMLElement {
1355
1453
  _role = "assistant";
1356
1454
  _attachments = [];
1357
1455
  _usage = null;
1456
+ _finishReason = null;
1457
+ /** The checkpoint this message carries: the separator above it is drawn from it. */
1458
+ _contextSummary = null;
1459
+ /**
1460
+ * This message is one an older compaction wrote (`compaction: true`), as the host told
1461
+ * us. `null` until told: the `data-kind="compaction"` attribute the viewport and the
1462
+ * wrappers stamp from the same flag answers meanwhile.
1463
+ */
1464
+ _legacyCompaction = null;
1465
+ /** The summary panel under the separator is open. Kept here, not in the DOM, because a redraw rebuilds the DOM. */
1466
+ _checkpointOpen = false;
1467
+ /** Restore asked for confirmation and has not been answered. */
1468
+ _confirmingRestore = false;
1469
+ /** What the separator was last drawn from, so a re-sync that changes nothing leaves it (and a focused button) alone. */
1470
+ _checkpointDrawn = "";
1471
+ /** Names the separator's panel for `aria-controls`, unique on the page without needing a message id. */
1472
+ _checkpointId = `aparte-checkpoint-${++checkpointIds}`;
1358
1473
  /** Cleanup returned by the avatar provider — called on disconnect/re-render. */
1359
1474
  _avatarCleanup = null;
1360
1475
  /** Whether the avatar slot holds what a provider drew, which goes when the provider does. */
@@ -1384,7 +1499,7 @@ class AparteChatBubble extends HTMLElement {
1384
1499
  */
1385
1500
  _editReturnAction = null;
1386
1501
  static get observedAttributes() {
1387
- return ["data-role", "content", "timestamp", "message-id", "streaming", "name"];
1502
+ return ["data-role", "content", "timestamp", "message-id", "streaming", "name", "data-checkpoint", "data-checkpoint-reason"];
1388
1503
  }
1389
1504
  constructor() {
1390
1505
  super();
@@ -1399,7 +1514,11 @@ class AparteChatBubble extends HTMLElement {
1399
1514
  this._updateName();
1400
1515
  this._updateLocalizedLabels();
1401
1516
  this._updateWaiting();
1517
+ this._updateFinishNote();
1518
+ this._checkpointDrawn = "";
1519
+ this._updateCheckpoint();
1402
1520
  this._renderAvatar();
1521
+ this._redrawSegmentsWhoseRendererChanged();
1403
1522
  this._relabelSegments();
1404
1523
  this._updateTimestamp(this.getAttribute("timestamp"));
1405
1524
  if (!this._redrawQueued) {
@@ -1414,16 +1533,151 @@ class AparteChatBubble extends HTMLElement {
1414
1533
  _redrawQueued = false;
1415
1534
  /** The {@link renderingVersionOf} count the content was last drawn with. */
1416
1535
  _drawnWithVersion = 0;
1536
+ /** Every segment on screen, by id: see {@link DrawnSegment}. */
1537
+ _drawn = /* @__PURE__ */ new Map();
1538
+ /** The segments were released because the bubble was still out of the page at the next microtask. */
1539
+ _releasedWhileDetached = false;
1540
+ /** A {@link _scheduleReleaseCheck} is queued. */
1541
+ _releaseCheckQueued = false;
1542
+ /** Unsubscribes from the renderer registries' channel; set while connected. */
1543
+ _offRenderers = null;
1544
+ /** The tool renderer in effect for a call: none for a hidden call or another segment. */
1545
+ _toolRendererFor(segment) {
1546
+ if (segment.type !== "tool_call") return null;
1547
+ const cfg = this._cfg;
1548
+ if (resolveSegmentRenderer("tool_call", cfg) !== toolCallRenderer) return null;
1549
+ const name = segment.toolCall?.name;
1550
+ if (!name || cfg.isToolHidden(name)) return null;
1551
+ return cfg.getToolRenderer(name) ?? null;
1552
+ }
1553
+ /**
1554
+ * Record a segment as drawn, set it up, then read back which tool renderer is behind the
1555
+ * row (tool-call.ts records it as the row is set up — one record, not a second lookup).
1556
+ * Recorded BEFORE `setup`: a setup that throws (a framework mount) leaves an element that
1557
+ * will still be cleaned up. The same id drawn twice releases the first element.
1558
+ */
1559
+ _install(segment, el2, renderer) {
1560
+ const previous = this._drawn.get(segment.id);
1561
+ if (previous && previous.el !== el2) this._release(segment.id);
1562
+ const drawn = { el: el2, renderer, tool: null, segment };
1563
+ this._drawn.set(segment.id, drawn);
1564
+ if (!this.isConnected) this._scheduleReleaseCheck();
1565
+ if (renderer?.setup) runWithConfig(this._cfg, () => renderer.setup(el2, segment));
1566
+ if (segment.type === "tool_call") drawn.tool = toolRendererThatDrew(el2);
1567
+ }
1568
+ /**
1569
+ * The segment's element leaves for good: its renderer's `cleanup`, once. A cleanup that
1570
+ * throws is reported and costs nothing else — the element still goes, and the others
1571
+ * are still released.
1572
+ */
1573
+ _release(id) {
1574
+ const drawn = this._drawn.get(id);
1575
+ if (!drawn) return;
1576
+ this._drawn.delete(id);
1577
+ const cleanup = drawn.renderer?.cleanup;
1578
+ if (!cleanup) return;
1579
+ try {
1580
+ runWithConfig(this._cfg, () => cleanup.call(drawn.renderer, drawn.el, drawn.segment));
1581
+ } catch (err) {
1582
+ console.error("[aparte] a segment renderer's cleanup threw", err);
1583
+ }
1584
+ }
1585
+ _releaseAll() {
1586
+ for (const id of [...this._drawn.keys()]) this._release(id);
1587
+ }
1588
+ /**
1589
+ * Gone for good, or only moved? A keyed list moving a row takes it out and puts it back
1590
+ * before the next microtask, and must not unmount what its segments hold: decide then.
1591
+ */
1592
+ _scheduleReleaseCheck() {
1593
+ if (this._releaseCheckQueued) return;
1594
+ this._releaseCheckQueued = true;
1595
+ queueMicrotask(() => {
1596
+ this._releaseCheckQueued = false;
1597
+ if (this.isConnected || this._drawn.size === 0) return;
1598
+ this._releaseAll();
1599
+ this._releasedWhileDetached = true;
1600
+ });
1601
+ }
1602
+ /** One segment drawn again in place: its renderer cleans up, the current one draws. False when it was never drawn. */
1603
+ _redrawSegment(segment) {
1604
+ const drawn = this._drawn.get(segment.id);
1605
+ if (!drawn) return false;
1606
+ const cfg = this._cfg;
1607
+ const renderer = resolveSegmentRenderer(segment.type, cfg);
1608
+ const old = drawn.el;
1609
+ this._release(segment.id);
1610
+ const el2 = renderer ? segmentRenderResultToElement(runWithConfig(cfg, () => renderer.render(segment)), segment) : unrenderedSegment(segment);
1611
+ if (!el2) {
1612
+ old.remove();
1613
+ return true;
1614
+ }
1615
+ old.replaceWith(el2);
1616
+ this._install(segment, el2, renderer);
1617
+ return true;
1618
+ }
1619
+ /** The renderer that drew this segment is still the one in effect (for a tool call, the tool renderer too). */
1620
+ _stillDrawnByCurrent(segment, drawn) {
1621
+ return resolveSegmentRenderer(segment.type, this._cfg) === drawn.renderer && this._toolRendererFor(segment) === drawn.tool;
1622
+ }
1623
+ /**
1624
+ * A renderer registered, unregistered or overridden since these segments were drawn: draw
1625
+ * again only the segments whose renderer is no longer the one that drew them (for a tool
1626
+ * call, the tool renderer counts), in place. The others keep their elements — and with
1627
+ * them anything mounted in them, which a redraw of the whole message would unmount.
1628
+ */
1629
+ _redrawSegmentsWhoseRendererChanged() {
1630
+ if (!this.isConnected || !this._segmentsEl || this._drawn.size === 0) return;
1631
+ for (const segment of this._segments) {
1632
+ const drawn = this._drawn.get(segment.id);
1633
+ if (!drawn || this._stillDrawnByCurrent(segment, drawn)) continue;
1634
+ this._redrawSegment(segment);
1635
+ }
1636
+ }
1637
+ /**
1638
+ * The registries' channel: a renderer changed on our config — or on the global one, whose
1639
+ * segment renderers a chat with its own config inherits.
1640
+ */
1641
+ _onRenderersChange = (cfg) => {
1642
+ if (cfg !== this._cfg && cfg !== aparteGlobalConfig) return;
1643
+ this._redrawSegmentsWhoseRendererChanged();
1644
+ };
1645
+ /**
1646
+ * A card a mounted renderer keeps (a framework component): its drawing reads none of the
1647
+ * providers a rendering-version bump announces, so it stays mounted through one — as long
1648
+ * as that renderer is still the one in effect.
1649
+ */
1650
+ _keepsItsDrawing(segment, drawn) {
1651
+ const mounted = isMountedRenderer(drawn.renderer) || segment.type === "tool_call" && isMountedRenderer(drawn.tool);
1652
+ return mounted && this._stillDrawnByCurrent(segment, drawn);
1653
+ }
1417
1654
  /**
1418
1655
  * A Markdown or highlight provider set after this message was drawn: draw it again.
1419
1656
  * Not mid-stream — the stream reads the providers as it goes, and its end redraws — and
1420
1657
  * not mid-edit, where the drawn message is hidden behind the editor and drawing it would
1421
1658
  * show it again beside the editor; leaving the edit calls this.
1659
+ *
1660
+ * Segment by segment, keeping the cards a mounted renderer holds: a highlighter that loads
1661
+ * after the first answer must not remount every card on the page and lose its state.
1422
1662
  */
1423
1663
  _redrawIfStale() {
1424
1664
  if (this._streaming || this._editing || renderingVersionOf(this._cfg) === this._drawnWithVersion) return;
1425
- if (this._segments.length > 0) this._renderSegments();
1426
- else this._updateContent();
1665
+ if (this._segments.length === 0) {
1666
+ this._updateContent();
1667
+ return;
1668
+ }
1669
+ if (this._segments.some((s2) => !this._drawn.has(s2.id))) {
1670
+ this._renderSegments();
1671
+ return;
1672
+ }
1673
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1674
+ for (const segment of this._segments) {
1675
+ const drawn = this._drawn.get(segment.id);
1676
+ if (this._keepsItsDrawing(segment, drawn)) continue;
1677
+ this._redrawSegment(segment);
1678
+ }
1679
+ this._reflectError();
1680
+ this._syncCopyAction();
1427
1681
  }
1428
1682
  /**
1429
1683
  * Ask every rendered segment to re-read its config-derived text.
@@ -1482,9 +1736,16 @@ class AparteChatBubble extends HTMLElement {
1482
1736
  this._render();
1483
1737
  this._updateContent();
1484
1738
  this._updateTimestamp(this.getAttribute("timestamp"));
1739
+ if (this._releasedWhileDetached) {
1740
+ this._releasedWhileDetached = false;
1741
+ if (this._segments.length > 0) this._renderSegments();
1742
+ }
1485
1743
  window.addEventListener("aparte-config-change", this._onConfigChange);
1744
+ this._offRenderers ??= onRenderersChange(this._onRenderersChange);
1486
1745
  this.addEventListener("click", this._onBranchPickerClick);
1487
1746
  this.addEventListener("click", this._onLinkClick);
1747
+ this.addEventListener("click", this._onCheckpointClick);
1748
+ this.addEventListener("keydown", this._onCheckpointKeydown);
1488
1749
  }
1489
1750
  /**
1490
1751
  * The boundary above this bubble changed — under every wrapper the chat's own config is
@@ -1499,7 +1760,10 @@ class AparteChatBubble extends HTMLElement {
1499
1760
  * into one.
1500
1761
  */
1501
1762
  aparteConfigChanged() {
1502
- queueMicrotask(() => this._redrawFrameIfChanged());
1763
+ queueMicrotask(() => {
1764
+ this._redrawFrameIfChanged();
1765
+ this._redrawSegmentsWhoseRendererChanged();
1766
+ });
1503
1767
  }
1504
1768
  _redrawFrameIfChanged() {
1505
1769
  if (!this.isConnected || this._editing) return;
@@ -1512,6 +1776,7 @@ class AparteChatBubble extends HTMLElement {
1512
1776
  this._avatarCleanup = null;
1513
1777
  }
1514
1778
  this._resetMarkdownStream();
1779
+ this._releaseAll();
1515
1780
  this.replaceChildren();
1516
1781
  this._render();
1517
1782
  this._updateContent();
@@ -1519,11 +1784,16 @@ class AparteChatBubble extends HTMLElement {
1519
1784
  this._updateAttachments();
1520
1785
  this._updateTimestamp(this.getAttribute("timestamp"));
1521
1786
  this._updateBranchPicker();
1787
+ this._updateCheckpoint();
1522
1788
  }
1523
1789
  disconnectedCallback() {
1524
1790
  window.removeEventListener("aparte-config-change", this._onConfigChange);
1791
+ this._offRenderers?.();
1792
+ this._offRenderers = null;
1525
1793
  this.removeEventListener("click", this._onBranchPickerClick);
1526
1794
  this.removeEventListener("click", this._onLinkClick);
1795
+ this.removeEventListener("click", this._onCheckpointClick);
1796
+ this.removeEventListener("keydown", this._onCheckpointKeydown);
1527
1797
  if (this._avatarCleanup) {
1528
1798
  try {
1529
1799
  this._avatarCleanup();
@@ -1531,6 +1801,7 @@ class AparteChatBubble extends HTMLElement {
1531
1801
  }
1532
1802
  this._avatarCleanup = null;
1533
1803
  }
1804
+ this._scheduleReleaseCheck();
1534
1805
  }
1535
1806
  attributeChangedCallback(name, oldValue, newValue) {
1536
1807
  if (oldValue === newValue) return;
@@ -1549,6 +1820,12 @@ class AparteChatBubble extends HTMLElement {
1549
1820
  case "timestamp":
1550
1821
  this._updateTimestamp(newValue);
1551
1822
  break;
1823
+ case "data-checkpoint":
1824
+ this._updateCheckpoint();
1825
+ break;
1826
+ case "data-checkpoint-reason":
1827
+ if (this.getAttribute("data-checkpoint") === "failed") this._updateCheckpoint();
1828
+ break;
1552
1829
  case "streaming":
1553
1830
  this._updateStreaming(newValue !== null && newValue !== "false");
1554
1831
  break;
@@ -1591,10 +1868,38 @@ class AparteChatBubble extends HTMLElement {
1591
1868
  }
1592
1869
  /** Set segments for rich content */
1593
1870
  setSegments(segments) {
1594
- this._segments = [...segments];
1595
- this._renderSegments();
1871
+ this._replaceSegments(segments);
1596
1872
  this._updateWaiting();
1597
1873
  }
1874
+ /** A whole new list, from `setSegments` or `updateMessage({ segments })`: copied in, then drawn. */
1875
+ _replaceSegments(segments) {
1876
+ const next = [...segments];
1877
+ if (this._resyncKeepingCards(next)) return;
1878
+ this._segments = next;
1879
+ this._renderSegments();
1880
+ }
1881
+ /**
1882
+ * The same segments again, in the same order — the host re-syncing a message when its turn
1883
+ * ends — while a mounted renderer holds one of them: the cards it holds are handed the new
1884
+ * state, and only the other segments are drawn again, in place. Drawing everything again
1885
+ * unmounted every framework card at the end of every turn and lost its state. False, and
1886
+ * nothing touched, in every other case: those keep the full redraw.
1887
+ */
1888
+ _resyncKeepingCards(next) {
1889
+ if (!this._segmentsEl || next.length !== this._segments.length) return false;
1890
+ if (next.some((s2, i2) => s2.id !== this._segments[i2].id || !this._drawn.has(s2.id))) return false;
1891
+ const kept = new Set(next.filter((s2) => this._keepsItsDrawing(s2, this._drawn.get(s2.id))).map((s2) => s2.id));
1892
+ if (kept.size === 0) return false;
1893
+ this._segments = next;
1894
+ this._drawnWithVersion = renderingVersionOf(this._cfg);
1895
+ for (const segment of next) {
1896
+ if (kept.has(segment.id)) this._applySegmentUpdate(segment.id, segment, segment);
1897
+ else this._redrawSegment(segment);
1898
+ }
1899
+ this._reflectError();
1900
+ this._syncCopyAction();
1901
+ return true;
1902
+ }
1598
1903
  /** Add a segment */
1599
1904
  addSegment(segment) {
1600
1905
  this._segments.push(segment);
@@ -1645,6 +1950,7 @@ class AparteChatBubble extends HTMLElement {
1645
1950
  if (index !== -1) {
1646
1951
  this._segments.splice(index, 1);
1647
1952
  }
1953
+ this._release(segmentId);
1648
1954
  const el2 = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
1649
1955
  el2?.remove();
1650
1956
  this._updateWaiting();
@@ -1669,6 +1975,38 @@ class AparteChatBubble extends HTMLElement {
1669
1975
  this._usage = usage ?? null;
1670
1976
  this._updateActionBar();
1671
1977
  }
1978
+ /**
1979
+ * Say why the model stopped writing this reply (assistant only).
1980
+ *
1981
+ * Only `'length'` draws anything: a quiet note after the content that the reply was cut
1982
+ * off, because otherwise a truncated answer reads as a finished one. Every other reason
1983
+ * is the ordinary end of a turn and says nothing. The note waits for the turn to settle.
1984
+ */
1985
+ setFinishReason(reason) {
1986
+ this._finishReason = reason ?? null;
1987
+ this._updateFinishNote();
1988
+ }
1989
+ /**
1990
+ * Say that the messages before this one were summarised for the model, or that they no
1991
+ * longer are (`null`).
1992
+ *
1993
+ * Draws, or removes, the separator above the message: "{n} messages summarised for the
1994
+ * model", with Show summary and Restore. The separator sits OUTSIDE `.aparte-message`
1995
+ * and inside this element, so it is not part of the message's frame and survives the
1996
+ * frame being drawn again. `legacy: true` says the message is itself the summary an
1997
+ * older compaction wrote (`compaction: true`): it is replaced by the separator, which
1998
+ * then has no count and no Restore. Omit `legacy` to leave what the host last said (or
1999
+ * the `data-kind="compaction"` attribute) in force.
2000
+ */
2001
+ setContextSummary(summary, options) {
2002
+ this._contextSummary = summary ?? null;
2003
+ if (options?.legacy !== void 0) this._legacyCompaction = options.legacy;
2004
+ if (!this._contextSummary) {
2005
+ this._confirmingRestore = false;
2006
+ if (!this._isLegacyCompaction()) this._checkpointOpen = false;
2007
+ }
2008
+ this._updateCheckpoint();
2009
+ }
1672
2010
  /**
1673
2011
  * Update the branch picker UI for tree-based navigation.
1674
2012
  * The viewport calls this after a branch switch or re-render.
@@ -1693,8 +2031,7 @@ class AparteChatBubble extends HTMLElement {
1693
2031
  this._updateContent();
1694
2032
  }
1695
2033
  if ("segments" in updates) {
1696
- this._segments = [...updates.segments];
1697
- this._renderSegments();
2034
+ this._replaceSegments(updates.segments);
1698
2035
  }
1699
2036
  if ("timestamp" in updates) {
1700
2037
  this._updateTimestamp(updates.timestamp);
@@ -1708,7 +2045,170 @@ class AparteChatBubble extends HTMLElement {
1708
2045
  this._updateAttachments();
1709
2046
  this._updateWaiting();
1710
2047
  }
2048
+ if ("finishReason" in updates) this.setFinishReason(updates.finishReason);
2049
+ if ("contextSummary" in updates || "compaction" in updates) {
2050
+ this.setContextSummary(
2051
+ "contextSummary" in updates ? updates.contextSummary : this._contextSummary,
2052
+ "compaction" in updates ? { legacy: updates.compaction === true } : void 0
2053
+ );
2054
+ }
2055
+ }
2056
+ /**
2057
+ * Draw, or remove, the note under a reply cut off at the model's length limit.
2058
+ *
2059
+ * Created here rather than in the shell markup: a custom shell written from the contract
2060
+ * has no such region, and the note is only ever wanted next to the content it qualifies.
2061
+ * A shell with no content region simply shows none (the same null-guarded degradation as
2062
+ * the other regions).
2063
+ */
2064
+ _updateFinishNote() {
2065
+ const show = this._finishReason === "length" && !this._streaming && this._role !== "user";
2066
+ let note = this.querySelector(".aparte-finish-note");
2067
+ if (!show) {
2068
+ note?.remove();
2069
+ return;
2070
+ }
2071
+ if (!note) {
2072
+ const region = this.querySelector(".aparte-message-content");
2073
+ if (!region) return;
2074
+ note = document.createElement("div");
2075
+ note.className = "aparte-finish-note";
2076
+ region.after(note);
2077
+ }
2078
+ note.textContent = this._cfg.getLocale().responseCutOff ?? "Cut off at the model's length limit.";
2079
+ }
2080
+ /** This message was written by an older compaction, as the host said or as its `data-kind` stamp says. */
2081
+ _isLegacyCompaction() {
2082
+ return this._legacyCompaction ?? this.getAttribute("data-kind") === "compaction";
2083
+ }
2084
+ /**
2085
+ * What the separator should say, or `null` for none. A compaction in progress or
2086
+ * failed outranks what the message already carries: it is what is happening now.
2087
+ */
2088
+ _checkpointKind() {
2089
+ const run = this.getAttribute("data-checkpoint");
2090
+ if (run === "pending" || run === "failed") return run;
2091
+ if (this._contextSummary) return "done";
2092
+ return this._isLegacyCompaction() ? "legacy" : null;
2093
+ }
2094
+ /** The text the summary panel shows: the checkpoint's summary, or the older compaction message itself. */
2095
+ _checkpointSummaryText() {
2096
+ return this._contextSummary ? this._contextSummary.summary : this._content;
1711
2097
  }
2098
+ /**
2099
+ * Draw the separator above the message, or take it off.
2100
+ *
2101
+ * Written into the light DOM as the FIRST child of the bubble, outside `.aparte-message`:
2102
+ * a custom shell roots at that class and is free to be anything, and the separator is not
2103
+ * its to carry. Drawn from `_render` (so every frame gets it) and from every setter that
2104
+ * changes what it says. A re-sync that changes nothing — the framework host calls this on
2105
+ * every render of every bubble — finds the same signature and the element in place and
2106
+ * leaves both alone, so a keyboard user's focus and a scrolled summary are not lost to a
2107
+ * rebuild; one that does change something rebuilds it and puts the focus back on the
2108
+ * button that had it (or on `focus`, which a click that swaps the buttons names).
2109
+ *
2110
+ * A message an older compaction wrote is marked `data-superseded` on its own row: it
2111
+ * stays in the DOM and does not paint (bubble.css), the separator speaking for it.
2112
+ */
2113
+ _updateCheckpoint(focus) {
2114
+ const frame = this.querySelector(".aparte-message");
2115
+ if (!frame) return;
2116
+ const kind = this._checkpointKind();
2117
+ frame.toggleAttribute("data-superseded", this._isLegacyCompaction() && !this._contextSummary);
2118
+ const current = this.querySelector(":scope > .aparte-checkpoint");
2119
+ if (!kind) {
2120
+ const holdsFocus = current && document.activeElement && current.contains(document.activeElement);
2121
+ current?.remove();
2122
+ this._checkpointDrawn = "";
2123
+ if (holdsFocus) {
2124
+ if (!frame.hasAttribute("tabindex")) frame.setAttribute("tabindex", "-1");
2125
+ frame.focus({ preventScroll: true });
2126
+ }
2127
+ return;
2128
+ }
2129
+ const reason = this.getAttribute("data-checkpoint-reason") ?? "";
2130
+ const summary = this._checkpointSummaryText();
2131
+ const signature = JSON.stringify([
2132
+ kind,
2133
+ this._contextSummary?.count,
2134
+ this._contextSummary?.createdAt,
2135
+ reason,
2136
+ this._checkpointOpen,
2137
+ this._confirmingRestore,
2138
+ this._checkpointOpen ? summary : ""
2139
+ ]);
2140
+ if (current && !focus && signature === this._checkpointDrawn) return;
2141
+ const active = document.activeElement;
2142
+ const hadFocus = current && active && current.contains(active) ? active.getAttribute("data-checkpoint-action") : null;
2143
+ const next = buildCheckpointSeparator({
2144
+ kind,
2145
+ count: this._contextSummary?.count,
2146
+ reason,
2147
+ open: this._checkpointOpen,
2148
+ confirming: this._confirmingRestore,
2149
+ summaryId: this._checkpointId,
2150
+ locale: this._cfg.getLocale()
2151
+ });
2152
+ if (this._checkpointOpen && (kind === "done" || kind === "legacy")) {
2153
+ const panel = next.querySelector(".aparte-checkpoint__summary");
2154
+ if (panel) panel.innerHTML = this._cfg.renderMarkdown(summary);
2155
+ }
2156
+ if (current) current.replaceWith(next);
2157
+ else this.prepend(next);
2158
+ this._checkpointDrawn = signature;
2159
+ const wanted = focus ?? hadFocus;
2160
+ if (wanted) next.querySelector(`[data-checkpoint-action="${cssEscape(wanted)}"]`)?.focus();
2161
+ }
2162
+ /**
2163
+ * The separator's buttons. One delegated listener on the bubble, for the reason the branch
2164
+ * picker has one: the separator is rebuilt on every change, and a listener per button would
2165
+ * be lost with it.
2166
+ */
2167
+ _onCheckpointClick = (event) => {
2168
+ const button = event.target?.closest?.("[data-checkpoint-action]");
2169
+ const separator = button?.closest(".aparte-checkpoint");
2170
+ if (!button || !separator || separator.parentElement !== this) return;
2171
+ switch (button.getAttribute("data-checkpoint-action")) {
2172
+ case "toggle":
2173
+ this._checkpointOpen = !this._checkpointOpen;
2174
+ this._updateCheckpoint("toggle");
2175
+ break;
2176
+ case "restore":
2177
+ this._confirmingRestore = true;
2178
+ this._updateCheckpoint("restore-confirm");
2179
+ break;
2180
+ case "restore-cancel":
2181
+ this._confirmingRestore = false;
2182
+ this._updateCheckpoint("restore");
2183
+ break;
2184
+ case "restore-confirm": {
2185
+ this._confirmingRestore = false;
2186
+ const messageId = this.getAttribute("message-id") ?? void 0;
2187
+ restoreContext(this, { messageId, targetId: this._resolveTargetId() });
2188
+ this.setContextSummary(null);
2189
+ break;
2190
+ }
2191
+ case "retry": {
2192
+ const targetId = this._resolveTargetId();
2193
+ const compactor = this._cfg.getCompactor();
2194
+ if (compactor) {
2195
+ void compactor.compact({ target: chatBoundaryOf(this), ...targetId ? { targetId } : {}, force: true, reason: "retry" });
2196
+ } else {
2197
+ window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId } }));
2198
+ }
2199
+ break;
2200
+ }
2201
+ }
2202
+ };
2203
+ /** Escape withdraws Restore's question, the way it closes every other inline prompt here. */
2204
+ _onCheckpointKeydown = (event) => {
2205
+ if (event.key !== "Escape" || !this._confirmingRestore) return;
2206
+ const separator = event.target?.closest?.(".aparte-checkpoint");
2207
+ if (!separator || separator.parentElement !== this) return;
2208
+ event.stopPropagation();
2209
+ this._confirmingRestore = false;
2210
+ this._updateCheckpoint("restore");
2211
+ };
1712
2212
  // ─────────────────────────────────────────────────────────────────────────
1713
2213
  // Private Methods
1714
2214
  // ─────────────────────────────────────────────────────────────────────────
@@ -1723,10 +2223,12 @@ class AparteChatBubble extends HTMLElement {
1723
2223
  const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1724
2224
  if (el2) {
1725
2225
  this._segmentsEl.appendChild(el2);
1726
- runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
2226
+ this._install(segment, el2, renderer);
1727
2227
  }
1728
2228
  } else {
1729
- this._segmentsEl.appendChild(unrenderedSegment(segment));
2229
+ const el2 = unrenderedSegment(segment);
2230
+ this._segmentsEl.appendChild(el2);
2231
+ this._install(segment, el2, void 0);
1730
2232
  }
1731
2233
  if (this._contentEl) this._contentEl.style.display = "none";
1732
2234
  this._reflectError();
@@ -1737,15 +2239,32 @@ class AparteChatBubble extends HTMLElement {
1737
2239
  this._renderSegments();
1738
2240
  return;
1739
2241
  }
2242
+ if (this._releasedWhileDetached) return;
1740
2243
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
1741
2244
  if (!renderer) return;
2245
+ const drawnBefore = this._drawn.get(segmentId);
2246
+ if (drawnBefore && drawnBefore.renderer !== renderer) {
2247
+ drawnBefore.segment = segment;
2248
+ this._redrawSegment(segment);
2249
+ return;
2250
+ }
1742
2251
  if (renderer.update) {
1743
2252
  runWithConfig(this._cfg, () => renderer.update(el2, segment));
2253
+ const drawn = this._drawn.get(segmentId);
2254
+ if (drawn) {
2255
+ drawn.segment = segment;
2256
+ const now = this._segmentsEl?.querySelector(`:scope > [data-segment-id="${cssEscape(segmentId)}"]`);
2257
+ if (now && now !== drawn.el) {
2258
+ drawn.el = now;
2259
+ drawn.tool = segment.type === "tool_call" ? toolRendererThatDrew(now) : null;
2260
+ }
2261
+ }
1744
2262
  } else {
1745
2263
  const newEl = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
1746
2264
  if (newEl) {
2265
+ this._release(segmentId);
1747
2266
  el2.replaceWith(newEl);
1748
- runWithConfig(this._cfg, () => renderer.setup?.(newEl, segment));
2267
+ this._install(segment, newEl, renderer);
1749
2268
  }
1750
2269
  }
1751
2270
  if ("collapsed" in updates) {
@@ -1869,6 +2388,8 @@ class AparteChatBubble extends HTMLElement {
1869
2388
  this._renderAvatar();
1870
2389
  if (this._streaming) this._updateStreaming(true);
1871
2390
  this._updateWaiting();
2391
+ this._updateFinishNote();
2392
+ this._updateCheckpoint();
1872
2393
  }
1873
2394
  /**
1874
2395
  * Show the built-in waiting indicator while this bubble is in flight and has
@@ -2025,6 +2546,7 @@ class AparteChatBubble extends HTMLElement {
2025
2546
  _renderSegments() {
2026
2547
  if (!this._segmentsEl) return;
2027
2548
  this._drawnWithVersion = renderingVersionOf(this._cfg);
2549
+ this._releaseAll();
2028
2550
  this._segmentsEl.innerHTML = "";
2029
2551
  for (const segment of this._segments) {
2030
2552
  const renderer = resolveSegmentRenderer(segment.type, this._cfg);
@@ -2032,10 +2554,12 @@ class AparteChatBubble extends HTMLElement {
2032
2554
  const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => renderer.render(segment)), segment);
2033
2555
  if (el2) {
2034
2556
  this._segmentsEl.appendChild(el2);
2035
- runWithConfig(this._cfg, () => renderer.setup?.(el2, segment));
2557
+ this._install(segment, el2, renderer);
2036
2558
  }
2037
2559
  } else {
2038
- this._segmentsEl.appendChild(unrenderedSegment(segment));
2560
+ const el2 = unrenderedSegment(segment);
2561
+ this._segmentsEl.appendChild(el2);
2562
+ this._install(segment, el2, void 0);
2039
2563
  }
2040
2564
  }
2041
2565
  if (this._contentEl) {
@@ -2097,18 +2621,18 @@ class AparteChatBubble extends HTMLElement {
2097
2621
  const customAttachment = this._cfg.getAttachmentRenderer?.();
2098
2622
  if (customAttachment) {
2099
2623
  this._attachmentsEl.replaceChildren();
2100
- for (const a2 of this._attachments) {
2101
- const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a2)));
2624
+ for (const a3 of this._attachments) {
2625
+ const el2 = segmentRenderResultToElement(runWithConfig(this._cfg, () => customAttachment(a3)));
2102
2626
  if (el2) this._attachmentsEl.appendChild(el2);
2103
2627
  }
2104
2628
  return;
2105
2629
  }
2106
- this._attachmentsEl.innerHTML = this._attachments.map((a2) => {
2107
- const name = escapeHtml(a2.name);
2108
- if (a2.type.startsWith("image/") && this._isShowableUrl(a2.url)) {
2109
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a2.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
2630
+ this._attachmentsEl.innerHTML = this._attachments.map((a3) => {
2631
+ const name = escapeHtml(a3.name);
2632
+ if (a3.type.startsWith("image/") && this._isShowableUrl(a3.url)) {
2633
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--image" title="${name}"><img class="aparte-thumbnail__image" src="${escapeHtml(a3.url)}" alt="${name}" loading="lazy" /><span class="aparte-thumb__name">${name}</span></div>`;
2110
2634
  }
2111
- return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a2.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
2635
+ return `<div class="aparte-thumbnail aparte-thumb aparte-thumb--file" title="${name}"><span class="aparte-thumbnail__label">${escapeHtml(this._fileExt(a3.name))}</span><span class="aparte-thumb__name">${name}</span></div>`;
2112
2636
  }).join("");
2113
2637
  if (!this._cfg.getHostHandlers().attachmentPreview) return;
2114
2638
  this._attachmentsEl.querySelectorAll(".aparte-thumb--image").forEach((tile) => {
@@ -2264,7 +2788,7 @@ class AparteChatBubble extends HTMLElement {
2264
2788
  /**
2265
2789
  * What the copy action copies: the reply, not the reasoning. A `thinking` segment is
2266
2790
  * the model's scratchpad, and the client already keeps it out of the history it sends
2267
- * back (`_segmentsToText`) for the same reason — copying it along with the answer
2791
+ * back (`segmentsToWireText`) for the same reason — copying it along with the answer
2268
2792
  * pasted a paragraph of deliberation above every reply, which no assistant on the
2269
2793
  * market does. Segments with no `content` (a tool call) contribute nothing, so a
2270
2794
  * turn made only of those copies as `''` — and the action bar reads this to decide
@@ -2294,14 +2818,14 @@ class AparteChatBubble extends HTMLElement {
2294
2818
  const buttons = [];
2295
2819
  if (this._role === "user") {
2296
2820
  if (config.user) {
2297
- for (const a2 of config.user) buttons.push(this._actionButtonHtml(a2, icons, locale));
2821
+ for (const a3 of config.user) buttons.push(this._actionButtonHtml(a3, icons, locale));
2298
2822
  } else {
2299
2823
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
2300
2824
  if (config.edit) buttons.push(this._actionButtonHtml("edit", icons, locale));
2301
2825
  }
2302
2826
  } else if (this._role === "assistant") {
2303
2827
  if (config.assistant) {
2304
- for (const a2 of config.assistant) buttons.push(this._actionButtonHtml(a2, icons, locale));
2828
+ for (const a3 of config.assistant) buttons.push(this._actionButtonHtml(a3, icons, locale));
2305
2829
  } else {
2306
2830
  if (config.copy) buttons.push(this._actionButtonHtml("copy", icons, locale));
2307
2831
  if (config.retry) buttons.push(this._actionButtonHtml("retry", icons, locale));
@@ -2397,22 +2921,22 @@ class AparteChatBubble extends HTMLElement {
2397
2921
  /** Append the registered custom action buttons for this bubble's role. */
2398
2922
  _appendCustomActions() {
2399
2923
  if (!this._actionBarEl) return;
2400
- for (const a2 of this._cfg.getActions("bubble")) {
2401
- const roles = a2.bubble?.roles ?? ["user", "assistant"];
2924
+ for (const a3 of this._cfg.getActions("bubble")) {
2925
+ const roles = a3.bubble?.roles ?? ["user", "assistant"];
2402
2926
  if (!roles.includes(this._role)) continue;
2403
2927
  const btn = document.createElement("button");
2404
2928
  btn.type = "button";
2405
2929
  btn.className = "aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-custom";
2406
- btn.dataset["action"] = `custom:${a2.id}`;
2407
- btn.setAttribute("aria-label", a2.label);
2408
- btn.setAttribute("title", a2.label);
2409
- btn.innerHTML = a2.icon.startsWith("<") ? a2.icon : this._cfg.getIcon(a2.icon) ?? a2.iconFallback ?? "";
2930
+ btn.dataset["action"] = `custom:${a3.id}`;
2931
+ btn.setAttribute("aria-label", a3.label);
2932
+ btn.setAttribute("title", a3.label);
2933
+ btn.innerHTML = a3.icon.startsWith("<") ? a3.icon : this._cfg.getIcon(a3.icon) ?? a3.iconFallback ?? "";
2410
2934
  this._actionBarEl.appendChild(btn);
2411
2935
  }
2412
2936
  }
2413
2937
  /** Build the `<button>` HTML for a single named action (shared by flag + per-role rendering). */
2414
- _actionButtonHtml(action, icons, locale) {
2415
- switch (action) {
2938
+ _actionButtonHtml(action2, icons, locale) {
2939
+ switch (action2) {
2416
2940
  case "copy": {
2417
2941
  const l2 = locale.copy ?? "Copy";
2418
2942
  return `<button type="button" class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-action-btn aparte-action-copy" data-action="copy" aria-label="${escapeAttr(l2)}" title="${escapeAttr(l2)}">${icons.copy()}</button>`;
@@ -2456,10 +2980,10 @@ class AparteChatBubble extends HTMLElement {
2456
2980
  }
2457
2981
  _handleActionClick(e) {
2458
2982
  const btn = e.currentTarget;
2459
- const action = btn.dataset["action"];
2983
+ const action2 = btn.dataset["action"];
2460
2984
  const messageId = this.getAttribute("message-id");
2461
- if (action?.startsWith("custom:") && messageId) {
2462
- const actionId = action.slice("custom:".length);
2985
+ if (action2?.startsWith("custom:") && messageId) {
2986
+ const actionId = action2.slice("custom:".length);
2463
2987
  const detail = {
2464
2988
  actionId,
2465
2989
  zone: "bubble",
@@ -2475,7 +2999,7 @@ class AparteChatBubble extends HTMLElement {
2475
2999
  this._cfg.getActions("bubble").find((x) => x.id === actionId)?.onClick?.(e);
2476
3000
  return;
2477
3001
  }
2478
- switch (action) {
3002
+ switch (action2) {
2479
3003
  case "copy": {
2480
3004
  const text = this._copyableText();
2481
3005
  const icons = this._cfg.getIconProvider();
@@ -2524,7 +3048,7 @@ class AparteChatBubble extends HTMLElement {
2524
3048
  case "feedback-positive":
2525
3049
  case "feedback-negative": {
2526
3050
  if (!messageId) break;
2527
- const value = action === "feedback-positive" ? "positive" : "negative";
3051
+ const value = action2 === "feedback-positive" ? "positive" : "negative";
2528
3052
  btn.setAttribute("data-submitted", "");
2529
3053
  const detail = { messageId, value };
2530
3054
  this.dispatchEvent(new CustomEvent("aparte-feedback", {
@@ -2652,12 +3176,12 @@ class AparteChatBubble extends HTMLElement {
2652
3176
  }
2653
3177
  }
2654
3178
  this._updateWaiting();
3179
+ this._updateFinishNote();
2655
3180
  if (wasStreaming && !streaming) this._updateContent();
2656
- if (wasStreaming && !streaming && this._segments.length > 0 && renderingVersionOf(this._cfg) !== this._drawnWithVersion) {
2657
- this._renderSegments();
2658
- }
3181
+ if (wasStreaming && !streaming && this._segments.length > 0) this._redrawIfStale();
2659
3182
  }
2660
3183
  }
3184
+ deferHydrationOf(AparteChatBubble);
2661
3185
  if (!customElements.get("aparte-chat-bubble")) {
2662
3186
  customElements.define("aparte-chat-bubble", AparteChatBubble);
2663
3187
  }
@@ -2831,6 +3355,7 @@ class AparteChatStatus extends HTMLElement {
2831
3355
  container.setAttribute("aria-label", text || this._word());
2832
3356
  }
2833
3357
  }
3358
+ deferHydrationOf(AparteChatStatus);
2834
3359
  if (!customElements.get("aparte-chat-status")) {
2835
3360
  customElements.define("aparte-chat-status", AparteChatStatus);
2836
3361
  }
@@ -2946,6 +3471,13 @@ class AparteChatViewport extends HTMLElement {
2946
3471
  /** Last `--aparte-transcript-inset` written on the host, in px — same write-on-change rule. */
2947
3472
  _transcriptInset = -1;
2948
3473
  _boundResetHandler = null;
3474
+ /**
3475
+ * Marks the bubble a compaction is writing its checkpoint onto (`data-checkpoint`). The
3476
+ * vanilla viewport's job: under a wrapper the transcript is the framework's, and the
3477
+ * host answers for it instead (`active` below).
3478
+ */
3479
+ _checkpointState = null;
3480
+ _stopCheckpointState = null;
2949
3481
  /**
2950
3482
  * When true, _reRenderActivePath() only dispatches aparte-path-changed without
2951
3483
  * touching the DOM. Set via setFrameworkManagedDOM(true) when a framework
@@ -3015,6 +3547,16 @@ class AparteChatViewport extends HTMLElement {
3015
3547
  this._boundResetHandler = () => this.clearAll();
3016
3548
  window.addEventListener("aparte-reset", this._boundResetHandler);
3017
3549
  window.addEventListener("aparte-config-change", this._onConfigChange);
3550
+ this._checkpointState ??= new CheckpointStateWatcher({
3551
+ // A bare `<aparte-chat-viewport id="…">` with no named host around it is its own
3552
+ // chat: the client names its events with that id (`_targetIdOf` falls back to the
3553
+ // element's own), so the watcher answers to it too, or every event is someone else's.
3554
+ ownId: () => chatIdOf(this) ?? (this.id || void 0),
3555
+ bubbleOf: (id) => this.querySelector(`aparte-chat-bubble[message-id="${cssEscape(id)}"]`),
3556
+ owns: (id) => !!this.getMessage(id),
3557
+ active: () => !this._frameworkManagedDOM
3558
+ });
3559
+ this._stopCheckpointState ??= this._checkpointState.start();
3018
3560
  }
3019
3561
  /**
3020
3562
  * A locale switch changes the reading direction, and `dir` follows it, so a chat
@@ -3071,6 +3613,8 @@ class AparteChatViewport extends HTMLElement {
3071
3613
  window.removeEventListener("aparte-reset", this._boundResetHandler);
3072
3614
  this._boundResetHandler = null;
3073
3615
  }
3616
+ this._stopCheckpointState?.();
3617
+ this._stopCheckpointState = null;
3074
3618
  this._cleanup();
3075
3619
  }
3076
3620
  attributeChangedCallback(name, oldValue, newValue) {
@@ -3243,10 +3787,10 @@ class AparteChatViewport extends HTMLElement {
3243
3787
  this._notifyBubble(messageId, "addSegment", stamped);
3244
3788
  this._autoScroll();
3245
3789
  }
3246
- updateSegment(a2, b2, c2) {
3790
+ updateSegment(a3, b2, c2) {
3247
3791
  const clientForm = c2 === void 0 && typeof b2 === "object";
3248
- const messageId = clientForm ? this._activeMessageId() : a2;
3249
- const segmentId = clientForm ? a2 : b2;
3792
+ const messageId = clientForm ? this._activeMessageId() : a3;
3793
+ const segmentId = clientForm ? a3 : b2;
3250
3794
  const updates = clientForm ? b2 : c2;
3251
3795
  if (!messageId) return;
3252
3796
  const message = this._repo.getMessageById(messageId);
@@ -3259,10 +3803,10 @@ class AparteChatViewport extends HTMLElement {
3259
3803
  this._notifyBubble(messageId, "updateSegment", { segmentId, updates: stamped });
3260
3804
  }
3261
3805
  }
3262
- removeSegment(a2, b2) {
3806
+ removeSegment(a3, b2) {
3263
3807
  const clientForm = b2 === void 0;
3264
- const messageId = clientForm ? this._activeMessageId() : a2;
3265
- const segmentId = clientForm ? a2 : b2;
3808
+ const messageId = clientForm ? this._activeMessageId() : a3;
3809
+ const segmentId = clientForm ? a3 : b2;
3266
3810
  if (!messageId || !segmentId) return;
3267
3811
  const message = this._repo.getMessageById(messageId);
3268
3812
  if (message?.segments) {
@@ -3613,6 +4157,7 @@ class AparteChatViewport extends HTMLElement {
3613
4157
  this._setSpacerHeight(0);
3614
4158
  this._isAutoScrollEnabled = true;
3615
4159
  this._updateScrollButton();
4160
+ this._checkpointState?.clear();
3616
4161
  this.dispatchEvent(new CustomEvent("aparte-reset-done", { bubbles: true, composed: true }));
3617
4162
  }
3618
4163
  /**
@@ -3688,12 +4233,12 @@ class AparteChatViewport extends HTMLElement {
3688
4233
  this._repo.addOrUpdateMessage(this._repo.headId, message);
3689
4234
  return message;
3690
4235
  }
3691
- _notifyBubble(messageId, action, payload, segmentId) {
4236
+ _notifyBubble(messageId, action2, payload, segmentId) {
3692
4237
  const bubble = this.querySelector(
3693
4238
  `aparte-chat-bubble[message-id="${cssEscape(messageId)}"], [data-aparte-bubble][message-id="${cssEscape(messageId)}"]`
3694
4239
  );
3695
4240
  if (!bubble) return;
3696
- switch (action) {
4241
+ switch (action2) {
3697
4242
  case "appendToken":
3698
4243
  bubble.appendToken?.(payload);
3699
4244
  break;
@@ -3716,7 +4261,7 @@ class AparteChatViewport extends HTMLElement {
3716
4261
  break;
3717
4262
  case "update": {
3718
4263
  const updates = payload;
3719
- const renderable = ["status", "segments", "content", "attachments", "usage"];
4264
+ const renderable = ["status", "segments", "content", "attachments", "usage", "finishReason", "contextSummary", "compaction"];
3720
4265
  if (renderable.some((key) => key in updates)) {
3721
4266
  bubble.updateMessage?.(payload);
3722
4267
  }
@@ -3767,6 +4312,7 @@ class AparteChatViewport extends HTMLElement {
3767
4312
  }
3768
4313
  this._dispatchPathChanged(activeMessages, siblingsInfo);
3769
4314
  this._syncBusy();
4315
+ this._checkpointState?.reapply();
3770
4316
  this._recalculateSpacer();
3771
4317
  }
3772
4318
  _dispatchPathChanged(messages, siblings) {
@@ -4399,6 +4945,7 @@ class AparteChatViewport extends HTMLElement {
4399
4945
  this._settleUntil = 0;
4400
4946
  }
4401
4947
  }
4948
+ deferHydrationOf(AparteChatViewport);
4402
4949
  if (!customElements.get("aparte-chat-viewport")) {
4403
4950
  customElements.define("aparte-chat-viewport", AparteChatViewport);
4404
4951
  }
@@ -4905,6 +5452,7 @@ class AparteComposer extends HTMLElement {
4905
5452
  if (this._panelActive) this._evictPanel();
4906
5453
  }
4907
5454
  }
5455
+ deferHydrationOf(AparteComposer);
4908
5456
  if (!customElements.get("aparte-composer")) {
4909
5457
  customElements.define("aparte-composer", AparteComposer);
4910
5458
  }
@@ -5152,6 +5700,7 @@ class AparteComposerInput extends HTMLElement {
5152
5700
  }
5153
5701
  /** Escape a value before it lands in a double-quoted HTML attribute. */
5154
5702
  }
5703
+ deferHydrationOf(AparteComposerInput);
5155
5704
  if (!customElements.get("aparte-composer-input")) {
5156
5705
  customElements.define("aparte-composer-input", AparteComposerInput);
5157
5706
  }
@@ -5313,6 +5862,7 @@ class AparteComposerSend extends HTMLElement {
5313
5862
  return resolveConfig(this).getIcon("stop");
5314
5863
  }
5315
5864
  }
5865
+ deferHydrationOf(AparteComposerSend);
5316
5866
  if (!customElements.get("aparte-composer-send")) {
5317
5867
  customElements.define("aparte-composer-send", AparteComposerSend);
5318
5868
  }
@@ -5381,6 +5931,7 @@ class AparteComposerCancel extends HTMLElement {
5381
5931
  return resolveConfig(this).getIcon("stop");
5382
5932
  }
5383
5933
  }
5934
+ deferHydrationOf(AparteComposerCancel);
5384
5935
  if (!customElements.get("aparte-composer-cancel")) {
5385
5936
  customElements.define("aparte-composer-cancel", AparteComposerCancel);
5386
5937
  }
@@ -5466,6 +6017,7 @@ class AparteComposerAttachments extends HTMLElement {
5466
6017
  return dot > 0 ? filename.slice(dot + 1).toUpperCase().slice(0, 4) : "FILE";
5467
6018
  }
5468
6019
  }
6020
+ deferHydrationOf(AparteComposerAttachments);
5469
6021
  if (!customElements.get("aparte-composer-attachments")) {
5470
6022
  customElements.define("aparte-composer-attachments", AparteComposerAttachments);
5471
6023
  }
@@ -5592,6 +6144,7 @@ class AparteComposerAddAttachment extends HTMLElement {
5592
6144
  };
5593
6145
  }
5594
6146
  }
6147
+ deferHydrationOf(AparteComposerAddAttachment);
5595
6148
  if (!customElements.get("aparte-composer-add-attachment")) {
5596
6149
  customElements.define("aparte-composer-add-attachment", AparteComposerAddAttachment);
5597
6150
  }
@@ -5674,6 +6227,7 @@ class AparteComposerAction extends HTMLElement {
5674
6227
  return resolveConfig(this).getIcon(icon) ?? icon;
5675
6228
  }
5676
6229
  }
6230
+ deferHydrationOf(AparteComposerAction);
5677
6231
  if (!customElements.get("aparte-composer-action")) {
5678
6232
  customElements.define("aparte-composer-action", AparteComposerAction);
5679
6233
  }
@@ -5712,6 +6266,7 @@ class AparteComposerToolbar extends HTMLElement {
5712
6266
  else this.setAttribute("data-empty", "");
5713
6267
  }
5714
6268
  }
6269
+ deferHydrationOf(AparteComposerToolbar);
5715
6270
  if (!customElements.get("aparte-composer-toolbar")) {
5716
6271
  customElements.define("aparte-composer-toolbar", AparteComposerToolbar);
5717
6272
  }
@@ -6053,7 +6608,7 @@ class AparteConversationList extends HTMLElement {
6053
6608
  }
6054
6609
  }
6055
6610
  const fixed = ["pinned", "today", "yesterday", "week", "month"].map((k2) => buckets.get(k2)).filter((g2) => !!g2);
6056
- const months = [...buckets.keys()].filter((k2) => k2.startsWith("m-")).sort((a2, b2) => b2.localeCompare(a2)).map((k2) => buckets.get(k2));
6611
+ const months = [...buckets.keys()].filter((k2) => k2.startsWith("m-")).sort((a3, b2) => b2.localeCompare(a3)).map((k2) => buckets.get(k2));
6057
6612
  const undated = buckets.get("undated");
6058
6613
  return [...fixed, ...months, ...undated ? [undated] : []];
6059
6614
  }
@@ -6251,7 +6806,7 @@ class AparteConversationList extends HTMLElement {
6251
6806
  console.warn("[aparte-conversation-list] the conversation manager refused the change:", err);
6252
6807
  });
6253
6808
  }
6254
- _onMenuAction(action) {
6809
+ _onMenuAction(action2) {
6255
6810
  const open = this._open;
6256
6811
  if (!open) return;
6257
6812
  const conv = this._conversations.find((c2) => c2.id === open.id);
@@ -6260,7 +6815,7 @@ class AparteConversationList extends HTMLElement {
6260
6815
  return;
6261
6816
  }
6262
6817
  const id = conv.id;
6263
- switch (action) {
6818
+ switch (action2) {
6264
6819
  case "rename":
6265
6820
  this._closeMenu();
6266
6821
  this._startRename(open.row, conv);
@@ -6472,6 +7027,7 @@ class AparteConversationList extends HTMLElement {
6472
7027
  });
6473
7028
  }
6474
7029
  }
7030
+ deferHydrationOf(AparteConversationList);
6475
7031
  if (!customElements.get("aparte-conversation-list")) customElements.define("aparte-conversation-list", AparteConversationList);
6476
7032
  const isSuggestion = (value) => typeof value === "string" || typeof value === "object" && value !== null && typeof value.label === "string";
6477
7033
  const parseSuggestions = (raw) => {
@@ -6699,6 +7255,7 @@ class AparteSuggestions extends HTMLElement {
6699
7255
  }
6700
7256
  }
6701
7257
  }
7258
+ deferHydrationOf(AparteSuggestions);
6702
7259
  if (!customElements.get("aparte-suggestions")) {
6703
7260
  customElements.define("aparte-suggestions", AparteSuggestions);
6704
7261
  }
@@ -6706,9 +7263,14 @@ const fraction = (raw, fallback) => {
6706
7263
  const n2 = Number(raw);
6707
7264
  return raw !== null && Number.isFinite(n2) && n2 > 0 && n2 <= 1 ? n2 : fallback;
6708
7265
  };
6709
- const el = (tag, className) => {
7266
+ const hostIdOf = (target) => {
7267
+ const node = target instanceof Element ? target.closest("aparte-chat, [data-aparte-chat]") : null;
7268
+ return node?.id || void 0;
7269
+ };
7270
+ const el = (tag, className, text) => {
6710
7271
  const node = document.createElement(tag);
6711
7272
  node.className = className;
7273
+ if (text !== void 0) node.textContent = text;
6712
7274
  return node;
6713
7275
  };
6714
7276
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -6727,33 +7289,129 @@ const ringSvg = () => {
6727
7289
  }
6728
7290
  return svg;
6729
7291
  };
7292
+ const readsMessages = (candidate) => !!candidate && typeof candidate.getMessages === "function";
7293
+ const transcriptOf = (chat) => {
7294
+ if (!chat) return null;
7295
+ if (readsMessages(chat)) return chat;
7296
+ const viewport = chat.viewport ?? chat.querySelector("aparte-chat-viewport");
7297
+ return readsMessages(viewport) ? viewport : null;
7298
+ };
7299
+ let panelSeq = 0;
7300
+ const NO_PARTS = { system: 0, tools: 0, summary: 0, messages: 0 };
6730
7301
  class AparteContext extends HTMLElement {
6731
7302
  static get observedAttributes() {
6732
7303
  return ["window", "warn", "danger", "target", "variant", "used"];
6733
7304
  }
7305
+ /**
7306
+ * A reading with no transcript behind it: the `used` attribute's seed, or a turn's usage
7307
+ * when the chat's list cannot be read from here (a gauge placed far from its chat, a test).
7308
+ * When the list can be read, the measure comes from it and this is only the fallback.
7309
+ */
6734
7310
  _used = null;
6735
7311
  _level = null;
6736
7312
  _compactRequested = false;
7313
+ /** The last turn's usage and the reply it belongs to, so the measure has it before the list has written it back. */
7314
+ _lastDone = null;
7315
+ /** What the model reads changed with nothing on the path to say so (a Restore): the reading is an estimate until the next turn reports. */
7316
+ _viewChanged = false;
7317
+ /** A conversation was replaced and nothing has reported or loaded since: the list on screen is not read yet. */
7318
+ _blank = false;
7319
+ /** The model refused a request as over its window; the level is `danger` until something changes. */
7320
+ _overflow = false;
7321
+ _compact = null;
7322
+ _failure = "";
7323
+ _open = false;
7324
+ /** The reading drawn last, so the `used` getter and the panel read what is on screen. */
7325
+ _measured = null;
7326
+ /** The chat an event last came from, for a gauge that is neither under its chat nor names it with `target`. */
7327
+ _seen = null;
7328
+ /** What the panel was last built from: a render that changes nothing leaves its nodes, and the focus in them, alone. */
7329
+ _panelSignature = "";
7330
+ _remember(target) {
7331
+ const chat = target instanceof Element ? chatBoundaryOf(target) : null;
7332
+ if (chat) this._seen = chat;
7333
+ }
6737
7334
  _onDone = (e) => {
6738
7335
  const detail = e.detail;
6739
7336
  if (!detail?.usage || !this._isMine(detail.targetId)) return;
6740
- const { usage } = detail;
6741
- this._used = usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
7337
+ this._remember(e.target);
7338
+ this._used = usageTokens(detail.usage);
7339
+ this._lastDone = { messageId: detail.messageId, usage: detail.usage };
7340
+ this._viewChanged = false;
7341
+ this._blank = false;
7342
+ this._overflow = false;
7343
+ this._render(true);
7344
+ };
7345
+ /**
7346
+ * A conversation is replaced — a new chat, or one loaded in place of the last — and the
7347
+ * previous reading is not this one's. The viewport says so with `aparte-reset-done` (no
7348
+ * detail: the chat is found from where the event came from). A load clears first and
7349
+ * `aparte-conversation-loaded` follows, so the reading is dropped here and read again
7350
+ * there. Until then the chat's list is not trusted: a framework's may not have caught up.
7351
+ */
7352
+ _onReset = (e) => {
7353
+ if (!this._isMine(hostIdOf(e.target))) return;
7354
+ this._remember(e.target);
7355
+ this._used = null;
7356
+ this._lastDone = null;
7357
+ this._viewChanged = false;
7358
+ this._overflow = false;
7359
+ this._compact = null;
7360
+ this._blank = true;
6742
7361
  this._render();
6743
7362
  };
6744
7363
  /**
6745
- * After a real compaction the context is smaller by an amount nobody has measured
6746
- * yet — the next turn's usage will say. Until then the gauge shows nothing rather
6747
- * than a number it knows to be wrong. The event names the chat it compacted, so on a
6748
- * multi-chat page only that chat's gauge resets; an unnamed one resets every gauge,
6749
- * and each is right again one turn later.
7364
+ * The messages of a stored conversation are on screen: the reading is the last reply's
7365
+ * usage — what a turn would have reported had it just ended. A transcript with none
7366
+ * (an empty one, a conversation saved before usage was kept) shows nothing, the same as
7367
+ * a chat that has not spoken yet.
7368
+ */
7369
+ _onLoaded = (e) => {
7370
+ const detail = e.detail;
7371
+ if (!this._isMine(detail?.targetId)) return;
7372
+ this._remember(e.target);
7373
+ this._blank = false;
7374
+ this._lastDone = null;
7375
+ this._viewChanged = false;
7376
+ this._overflow = false;
7377
+ this._compact = null;
7378
+ const host = e.target;
7379
+ const messages = host?.getMessages?.() ?? [];
7380
+ const last = [...messages].reverse().find((m2) => m2.role === "assistant" && m2.usage);
7381
+ this._used = last?.usage ? usageTokens(last.usage) : null;
7382
+ this._render();
7383
+ };
7384
+ /** A compaction began for this chat: say so on the element and in the panel. */
7385
+ _onCompactStart = (e) => {
7386
+ const detail = e.detail;
7387
+ if (!this._isMine(detail?.targetId)) return;
7388
+ this._compact = "running";
7389
+ this._failure = "";
7390
+ this._render();
7391
+ };
7392
+ /**
7393
+ * After a real compaction — or a Restore — what the model reads is a different size by an
7394
+ * amount nobody has measured yet, and the last reply's reported usage describes the request
7395
+ * from before. The gauge measures the request that would be sent now and says so with `~`,
7396
+ * rather than keeping a number it knows to be wrong or going blank until the next turn. The
7397
+ * event names the chat it compacted, so on a multi-chat page only that chat's gauge
7398
+ * moves; an unnamed one moves every gauge.
7399
+ *
7400
+ * Without a transcript to measure (a gauge that cannot read its chat's list) there is
7401
+ * nothing to estimate from, and the reading is dropped as it always was.
6750
7402
  */
6751
7403
  _onCompacted = (e) => {
6752
7404
  const detail = e.detail;
6753
7405
  if (!this._isMine(detail?.targetId)) return;
6754
7406
  this._compactRequested = false;
6755
- if (detail?.skipped) return;
6756
- this._used = null;
7407
+ this._compact = null;
7408
+ if (detail?.skipped) {
7409
+ this._render();
7410
+ return;
7411
+ }
7412
+ this._viewChanged = true;
7413
+ this._overflow = false;
7414
+ if (!this._messages()) this._used = null;
6757
7415
  this._render();
6758
7416
  };
6759
7417
  /** A failed compaction spends the request too; the next `danger` asks again. */
@@ -6761,11 +7419,23 @@ class AparteContext extends HTMLElement {
6761
7419
  const detail = e.detail;
6762
7420
  if (!this._isMine(detail?.targetId)) return;
6763
7421
  this._compactRequested = false;
7422
+ this._compact = detail?.aborted ? null : "failed";
7423
+ this._failure = detail?.aborted ? "" : typeof detail?.error === "string" ? detail.error : "";
7424
+ this._render();
7425
+ };
7426
+ /** The model refused this chat's request as over its window: the number was wrong, and so is "ok". */
7427
+ _onMessageError = (e) => {
7428
+ const detail = e.detail;
7429
+ if (!this._isMine(detail?.targetId)) return;
7430
+ if (detail?.error?.code !== AparteErrorCode.USAGE_CONTEXT_EXCEEDED) return;
7431
+ this._remember(e.target);
7432
+ this._overflow = true;
7433
+ this._render();
6764
7434
  };
6765
7435
  _onRerender = () => this._render();
6766
- /** Tokens the last turn reported — or `used` set, until a turn reports — else `null`. */
7436
+ /** Tokens in use — what is drawn — else the last reported or seeded reading, else `null`. */
6767
7437
  get used() {
6768
- return this._used;
7438
+ return this._measured?.used ?? this._used;
6769
7439
  }
6770
7440
  /**
6771
7441
  * Writes the `used` attribute: a reading for a conversation restored without a turn of
@@ -6794,18 +7464,33 @@ class AparteContext extends HTMLElement {
6794
7464
  get level() {
6795
7465
  return this._level;
6796
7466
  }
7467
+ /** The reading drawn now, parts and all, or `null` while nothing is shown. */
7468
+ get measure() {
7469
+ return this._measured;
7470
+ }
6797
7471
  connectedCallback() {
6798
7472
  window.addEventListener("aparte-message-done", this._onDone);
7473
+ window.addEventListener("aparte-message-error", this._onMessageError);
7474
+ window.addEventListener("aparte-compact-start", this._onCompactStart);
6799
7475
  window.addEventListener("aparte-compact-done", this._onCompacted);
6800
7476
  window.addEventListener("aparte-compact-error", this._onCompactError);
7477
+ window.addEventListener("aparte-reset-done", this._onReset);
7478
+ window.addEventListener("aparte-conversation-loaded", this._onLoaded);
6801
7479
  window.addEventListener("aparte-config-change", this._onRerender);
7480
+ this.addEventListener("click", this._onClick);
6802
7481
  this._render();
6803
7482
  }
6804
7483
  disconnectedCallback() {
6805
7484
  window.removeEventListener("aparte-message-done", this._onDone);
7485
+ window.removeEventListener("aparte-message-error", this._onMessageError);
7486
+ window.removeEventListener("aparte-compact-start", this._onCompactStart);
6806
7487
  window.removeEventListener("aparte-compact-done", this._onCompacted);
6807
7488
  window.removeEventListener("aparte-compact-error", this._onCompactError);
7489
+ window.removeEventListener("aparte-reset-done", this._onReset);
7490
+ window.removeEventListener("aparte-conversation-loaded", this._onLoaded);
6808
7491
  window.removeEventListener("aparte-config-change", this._onRerender);
7492
+ this.removeEventListener("click", this._onClick);
7493
+ this._stopListeningWhileOpen();
6809
7494
  }
6810
7495
  attributeChangedCallback(name) {
6811
7496
  if (name === "used") {
@@ -6814,10 +7499,21 @@ class AparteContext extends HTMLElement {
6814
7499
  }
6815
7500
  if (this.isConnected) this._render();
6816
7501
  }
7502
+ /**
7503
+ * The config the chat this gauge watches answers to, not the one at the gauge's own
7504
+ * position. A gauge in a header, with `target=`, stands outside the chat's boundary, and
7505
+ * a chat that carries its own config (a wrapper's `config` prop) has its own prompt, tools,
7506
+ * window and compactor: the client and the plugin read them from there, so the gauge must
7507
+ * too, or it measures a different request and offers no Compact now. Resolved live, like
7508
+ * every config read: the boundary may be attached after this element connects.
7509
+ */
7510
+ _cfg() {
7511
+ return resolveConfig(this._chat() ?? this);
7512
+ }
6817
7513
  _window() {
6818
7514
  const attr = Number(this.getAttribute("window"));
6819
7515
  if (this.hasAttribute("window") && Number.isFinite(attr) && attr > 0) return attr;
6820
- const model = resolveConfig(this).getCurrentModel();
7516
+ const model = this._cfg().getCurrentModel();
6821
7517
  return model?.contextWindow && model.contextWindow > 0 ? model.contextWindow : null;
6822
7518
  }
6823
7519
  /** The chat this gauge watches: `target`, else the chat host above it — the composer's rule. */
@@ -6836,43 +7532,113 @@ class AparteContext extends HTMLElement {
6836
7532
  const own = this._ownTargetId();
6837
7533
  return !targetId || !own || targetId === own;
6838
7534
  }
6839
- _render() {
7535
+ /**
7536
+ * The chat's element: the one `target` names, else the chat above the gauge, else the one
7537
+ * its events last came from. Passed to the compactor as it is — a chat inside a shadow
7538
+ * root is not found by id, so the element is the address that always works.
7539
+ */
7540
+ _chat() {
7541
+ const attr = this.getAttribute("target");
7542
+ if (attr) {
7543
+ const root = this.getRootNode();
7544
+ const named = root.getElementById?.(attr) ?? document.getElementById(attr);
7545
+ if (named) return named;
7546
+ }
7547
+ const above = chatBoundaryOf(this);
7548
+ if (above) return above;
7549
+ return this._seen?.isConnected ? this._seen : null;
7550
+ }
7551
+ /** The chat's active path, or `null` when it cannot be read from here. */
7552
+ _messages() {
7553
+ const reader = transcriptOf(this._chat());
7554
+ if (!reader) return null;
7555
+ try {
7556
+ return reader.getMessages();
7557
+ } catch {
7558
+ return null;
7559
+ }
7560
+ }
7561
+ /**
7562
+ * What to draw: the measure of the chat's own transcript when it holds a reply with usage
7563
+ * (or the model has just refused a request, and an estimate is better than nothing), else
7564
+ * the seeded or reported number with nothing to break down.
7565
+ *
7566
+ * The last turn's usage is laid over its reply when the list has not written it back yet.
7567
+ * If the list does not hold that reply at all it is not used: the list is the path on
7568
+ * screen, and a reply that is not on it (a branch was switched) is not its reading.
7569
+ */
7570
+ _measure(capacity) {
7571
+ const stored = this._messages();
7572
+ const done = this._lastDone;
7573
+ const messages = stored && done?.messageId ? stored.map((m2) => m2.id === done.messageId && !m2.usage ? { ...m2, usage: done.usage } : m2) : stored;
7574
+ const hasUsage = !!messages?.some((m2) => m2.role === "assistant" && m2.usage);
7575
+ if (messages && (hasUsage && !this._blank || this._overflow && messages.length > 0)) {
7576
+ return measureContext({ messages, config: this._cfg(), windowOverride: capacity, viewChanged: this._viewChanged });
7577
+ }
7578
+ if (this._used === null) return null;
7579
+ return { window: capacity, used: this._used, estimated: false, parts: { ...NO_PARTS }, other: this._used };
7580
+ }
7581
+ _render(askToCompact = false) {
6840
7582
  const capacity = this._window();
6841
- if (capacity === null || this._used === null) {
7583
+ const measure = capacity === null ? null : this._measure(capacity);
7584
+ if (this._compact) this.setAttribute("data-compact", this._compact);
7585
+ else this.removeAttribute("data-compact");
7586
+ if (capacity === null || measure === null) {
7587
+ this._measured = null;
6842
7588
  this.setAttribute("data-empty", "");
6843
7589
  this.removeAttribute("data-level");
7590
+ this._open = false;
7591
+ this._stopListeningWhileOpen();
7592
+ this._panelSignature = "";
6844
7593
  this.replaceChildren();
6845
7594
  this._level = null;
6846
7595
  return;
6847
7596
  }
7597
+ this._measured = measure;
6848
7598
  this.removeAttribute("data-empty");
6849
- const used = this._used;
7599
+ const used = measure.used;
6850
7600
  const ratio = Math.min(1, used / capacity);
6851
7601
  const warn = fraction(this.getAttribute("warn"), 0.75);
6852
7602
  const danger = fraction(this.getAttribute("danger"), 0.9);
6853
- const level = ratio >= danger ? "danger" : ratio >= warn ? "warn" : "ok";
7603
+ const level = this._overflow || ratio >= danger ? "danger" : ratio >= warn ? "warn" : "ok";
6854
7604
  this.setAttribute("data-level", level);
6855
7605
  const ring = this.getAttribute("variant") === "ring";
6856
7606
  let root = this.querySelector(".aparte-context");
6857
7607
  if (root && root.classList.contains("aparte-context--ring") !== ring) {
6858
7608
  this.replaceChildren();
6859
7609
  root = null;
7610
+ this._panelSignature = "";
6860
7611
  }
6861
7612
  let meter = this.querySelector('[role="meter"]');
6862
7613
  let text = this.querySelector(".aparte-context__text");
6863
- if (!root || !meter || !text) {
7614
+ let toggle = this.querySelector(".aparte-context__toggle");
7615
+ let name = this.querySelector(".aparte-context__name");
7616
+ let panel = this.querySelector(".aparte-context__panel");
7617
+ if (!root || !meter || !text || !toggle || !name || !panel) {
6864
7618
  root = el("div", ring ? "aparte-context aparte-context--ring" : "aparte-context");
6865
7619
  meter = ring ? ringSvg() : el("div", "aparte-progress");
6866
7620
  meter.setAttribute("role", "meter");
6867
7621
  if (!ring) meter.appendChild(el("div", "aparte-progress__bar"));
7622
+ toggle = el("button", "aparte-btn aparte-btn--ghost aparte-btn--sm aparte-context__toggle");
7623
+ toggle.type = "button";
7624
+ name = el("span", "aparte-sr-only");
7625
+ name.classList.add("aparte-context__name");
6868
7626
  text = el("span", "aparte-context__text");
6869
- root.append(meter, text);
7627
+ toggle.append(name, text);
7628
+ panel = el("div", "aparte-context__panel aparte-popover");
7629
+ panel.id = `aparte-context-panel-${++panelSeq}`;
7630
+ panel.setAttribute("role", "group");
7631
+ panel.hidden = true;
7632
+ toggle.setAttribute("aria-controls", panel.id);
7633
+ root.append(meter, toggle, panel);
6870
7634
  this.replaceChildren(root);
7635
+ this._panelSignature = "";
6871
7636
  }
6872
- const cfg = resolveConfig(this);
7637
+ const cfg = this._cfg();
6873
7638
  const tag = cfg.getLocale().tag || void 0;
6874
7639
  const format = new Intl.NumberFormat(tag, { notation: "compact", maximumFractionDigits: 1 });
6875
- const reading = `${format.format(used)} / ${format.format(capacity)}`;
7640
+ const approx = measure.estimated ? "~" : "";
7641
+ const reading = `${approx}${format.format(used)} / ${format.format(capacity)}`;
6876
7642
  const percent = Math.round(ratio * 100);
6877
7643
  const label = cfg.t("contextLabel") || "Context window";
6878
7644
  if (ring) {
@@ -6887,7 +7653,13 @@ class AparteContext extends HTMLElement {
6887
7653
  meter.setAttribute("aria-valuemax", String(capacity));
6888
7654
  meter.setAttribute("aria-valuenow", String(used));
6889
7655
  meter.setAttribute("aria-label", `${label}: ${reading}`);
6890
- text.textContent = ring ? new Intl.NumberFormat(tag, { style: "percent", maximumFractionDigits: 0 }).format(percent / 100) : reading;
7656
+ name.textContent = `${label}: `;
7657
+ toggle.setAttribute("aria-expanded", String(this._open));
7658
+ text.textContent = ring ? `${approx}${new Intl.NumberFormat(tag, { style: "percent", maximumFractionDigits: 0 }).format(percent / 100)}` : reading;
7659
+ panel.setAttribute("aria-label", label);
7660
+ panel.hidden = !this._open;
7661
+ if (this._open) this._renderPanel(panel, measure, capacity, format);
7662
+ else this._panelSignature = "";
6891
7663
  if (level !== this._level) {
6892
7664
  this._level = level;
6893
7665
  const targetId = this._ownTargetId();
@@ -6895,12 +7667,191 @@ class AparteContext extends HTMLElement {
6895
7667
  this.dispatchEvent(new CustomEvent("aparte-context-threshold", { detail, bubbles: true, composed: true }));
6896
7668
  if (level !== "danger") this._compactRequested = false;
6897
7669
  }
6898
- if (level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
7670
+ if (askToCompact && level === "danger" && this.hasAttribute("auto-compact") && !this._compactRequested) {
6899
7671
  this._compactRequested = true;
6900
- window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId: this._ownTargetId() } }));
7672
+ window.dispatchEvent(new CustomEvent("aparte-compact", {
7673
+ detail: { targetId: this._ownTargetId(), auto: true, window: capacity }
7674
+ }));
7675
+ }
7676
+ }
7677
+ // ─────────────────────────────────────────────────────────────────────────
7678
+ // The panel
7679
+ // ─────────────────────────────────────────────────────────────────────────
7680
+ /**
7681
+ * What the window holds, row by row, and what can be done about it.
7682
+ *
7683
+ * The parts are raw estimates and `used` is the number the gauge draws — measured or
7684
+ * calibrated — so they need not agree. The panel never shows a sum above the total: when
7685
+ * the estimates add up to more than `used`, each is scaled down to fit and `other` is
7686
+ * zero; otherwise the difference is `other`, the part the heuristic cannot see.
7687
+ */
7688
+ _renderPanel(panel, measure, capacity, format) {
7689
+ const cfg = this._cfg();
7690
+ const compactor = cfg.getCompactor();
7691
+ const chat = this._chat();
7692
+ const targetId = this._ownTargetId();
7693
+ let preview = null;
7694
+ try {
7695
+ preview = compactor?.preview?.({ target: chat, ...targetId ? { targetId } : {} }) ?? null;
7696
+ } catch {
7697
+ preview = null;
7698
+ }
7699
+ const { parts, used } = measure;
7700
+ const sum = parts.system + parts.tools + parts.summary + parts.messages;
7701
+ const scale = sum > used && sum > 0 ? used / sum : 1;
7702
+ const approxOf = measure.estimated ? "~" : "";
7703
+ const rows = [
7704
+ { key: "system", label: cfg.t("contextSystem") || "System prompt", tokens: parts.system * scale, approx: "~" },
7705
+ { key: "tools", label: cfg.t("contextTools") || "Tools", tokens: parts.tools * scale, approx: "~" },
7706
+ { key: "summary", label: cfg.t("contextSummary") || "Summary", tokens: parts.summary * scale, approx: "~" },
7707
+ { key: "messages", label: cfg.t("contextMessages") || "Messages", tokens: parts.messages * scale, approx: "~" },
7708
+ { key: "other", label: cfg.t("contextOther") || "Other", tokens: scale < 1 ? 0 : measure.other, approx: "~" },
7709
+ { key: "free", label: cfg.t("contextFree") || "Free", tokens: Math.max(0, capacity - used), approx: approxOf }
7710
+ ];
7711
+ const total = `${approxOf}${format.format(used)} / ${format.format(capacity)}`;
7712
+ const reclaim = preview && preview.tokens > 0 ? (cfg.t("contextReclaim") || "Compacting now would free ~{n} tokens").replace("{n}", () => format.format(Math.round(preview.tokens))) : "";
7713
+ const canCompact = !!compactor && !!preview && this._compact === null;
7714
+ const list = el("ul", "aparte-context__rows");
7715
+ for (const row of rows) {
7716
+ const item = el("li", "aparte-context__row");
7717
+ item.setAttribute("data-part", row.key);
7718
+ item.append(el("span", "aparte-context__row-label", row.label));
7719
+ item.append(el("span", "aparte-context__row-value", `${row.approx}${format.format(Math.round(row.tokens))}`));
7720
+ const bar = el("div", "aparte-progress aparte-context__row-bar");
7721
+ bar.setAttribute("aria-hidden", "true");
7722
+ bar.style.setProperty("--aparte-progress-value", String(Math.round(Math.min(1, row.tokens / capacity) * 100)));
7723
+ bar.appendChild(el("div", "aparte-progress__bar"));
7724
+ item.append(bar);
7725
+ list.append(item);
7726
+ }
7727
+ const totalRow = el("li", "aparte-context__row aparte-context__row--total");
7728
+ totalRow.setAttribute("data-part", "total");
7729
+ totalRow.append(el("span", "aparte-context__row-label", cfg.t("contextTotal") || "Total"), el("span", "aparte-context__row-value", total));
7730
+ list.append(totalRow);
7731
+ const nodes = [list];
7732
+ if (this._compact === "running") {
7733
+ const status = el("p", "aparte-context__status aparte-shimmer", cfg.t("checkpointPending") || "Summarising the context…");
7734
+ status.setAttribute("role", "status");
7735
+ nodes.push(status);
7736
+ } else if (this._compact === "failed") {
7737
+ const status = el("p", "aparte-context__status aparte-context__status--failed", cfg.t("checkpointFailed") || "Couldn't summarise the context");
7738
+ status.setAttribute("role", "status");
7739
+ if (this._failure) {
7740
+ status.title = this._failure;
7741
+ status.appendChild(el("span", "aparte-sr-only", `: ${this._failure}`));
7742
+ }
7743
+ nodes.push(status, this._action("retry", cfg.t("retry") || "Retry"));
7744
+ }
7745
+ if (reclaim) nodes.push(el("p", "aparte-context__reclaim", reclaim));
7746
+ if (canCompact) nodes.push(this._action("compact", cfg.t("contextCompactNow") || "Compact now"));
7747
+ const next = el("div", "aparte-context__content");
7748
+ next.append(...nodes);
7749
+ if (next.innerHTML === this._panelSignature) return;
7750
+ this._panelSignature = next.innerHTML;
7751
+ const active = document.activeElement;
7752
+ const hadFocus = active && panel.contains(active) ? active.getAttribute("data-context-action") ?? "" : null;
7753
+ panel.replaceChildren(...Array.from(next.childNodes));
7754
+ if (hadFocus !== null) {
7755
+ const same = hadFocus ? panel.querySelector(`[data-context-action="${cssEscape(hadFocus)}"]`) : null;
7756
+ (same ?? this.querySelector(".aparte-context__toggle"))?.focus();
7757
+ }
7758
+ }
7759
+ _action(name, label) {
7760
+ const button = el("button", "aparte-btn aparte-btn--sm aparte-btn--surface aparte-context__action", label);
7761
+ button.type = "button";
7762
+ button.setAttribute("data-context-action", name);
7763
+ return button;
7764
+ }
7765
+ /**
7766
+ * Ask the registered compactor to compact this chat now, as a person asked. With none
7767
+ * registered (Retry only — Compact now is not offered without one) the command event
7768
+ * is the way to ask, the one a host's own button uses.
7769
+ */
7770
+ async _compactNow(reason) {
7771
+ const compactor = this._cfg().getCompactor();
7772
+ const targetId = this._ownTargetId();
7773
+ if (!compactor) {
7774
+ window.dispatchEvent(new CustomEvent("aparte-compact", { detail: { targetId } }));
7775
+ return;
7776
+ }
7777
+ this._compact = "running";
7778
+ this._failure = "";
7779
+ this._render();
7780
+ let failed = null;
7781
+ try {
7782
+ const outcome = await compactor.compact({ target: this._chat(), ...targetId ? { targetId } : {}, force: true, reason });
7783
+ if (!outcome.ok) failed = outcome.error;
7784
+ else if (!outcome.skipped) this._viewChanged = true;
7785
+ } catch (error) {
7786
+ failed = error instanceof Error ? error.message : String(error);
6901
7787
  }
7788
+ if (this._compact === "running") {
7789
+ this._compact = failed === null ? null : "failed";
7790
+ this._failure = failed ?? "";
7791
+ }
7792
+ if (this.isConnected) this._render();
7793
+ }
7794
+ // ─────────────────────────────────────────────────────────────────────────
7795
+ // The disclosure
7796
+ // ─────────────────────────────────────────────────────────────────────────
7797
+ _onClick = (event) => {
7798
+ const target = event.target;
7799
+ const action2 = target?.closest?.("[data-context-action]");
7800
+ if (action2) {
7801
+ void this._compactNow(action2.getAttribute("data-context-action") === "retry" ? "retry" : "manual");
7802
+ return;
7803
+ }
7804
+ if (target?.closest?.(".aparte-context__panel")) return;
7805
+ if (!target?.closest?.(".aparte-context")) return;
7806
+ this._setOpen(!this._open);
7807
+ };
7808
+ _setOpen(open, returnFocus = false) {
7809
+ if (open === this._open) return;
7810
+ this._open = open;
7811
+ if (open) this._listenWhileOpen();
7812
+ else this._stopListeningWhileOpen();
7813
+ this._render();
7814
+ if (open) this._place();
7815
+ else if (returnFocus) this.querySelector(".aparte-context__toggle")?.focus();
7816
+ }
7817
+ /**
7818
+ * Escape and a click anywhere else close the panel. Listened for on the document only
7819
+ * while it is open: a page full of closed gauges holds no listeners but its own.
7820
+ */
7821
+ _onDocumentClick = (event) => {
7822
+ if (!event.composedPath().includes(this)) this._setOpen(false);
7823
+ };
7824
+ _onDocumentKeydown = (event) => {
7825
+ if (event.key !== "Escape") return;
7826
+ const inside = event.composedPath().includes(this);
7827
+ if (inside) event.stopPropagation();
7828
+ this._setOpen(false, inside || this.contains(document.activeElement));
7829
+ };
7830
+ _listenWhileOpen() {
7831
+ document.addEventListener("click", this._onDocumentClick);
7832
+ document.addEventListener("keydown", this._onDocumentKeydown);
7833
+ }
7834
+ _stopListeningWhileOpen() {
7835
+ document.removeEventListener("click", this._onDocumentClick);
7836
+ document.removeEventListener("keydown", this._onDocumentKeydown);
7837
+ }
7838
+ /**
7839
+ * Where the panel opens: above the reading by default, since a gauge usually sits in the
7840
+ * composer at the foot of the page; below when there is no room above, and flush with the
7841
+ * other edge when it would run past the window's.
7842
+ */
7843
+ _place() {
7844
+ const panel = this.querySelector(".aparte-context__panel");
7845
+ if (!panel) return;
7846
+ panel.removeAttribute("data-placement");
7847
+ panel.removeAttribute("data-align");
7848
+ const anchor = this.getBoundingClientRect();
7849
+ const box = panel.getBoundingClientRect();
7850
+ if (box.height > 0 && anchor.top < box.height) panel.setAttribute("data-placement", "below");
7851
+ if (box.width > 0 && box.right > document.documentElement.clientWidth) panel.setAttribute("data-align", "end");
6902
7852
  }
6903
7853
  }
7854
+ deferHydrationOf(AparteContext);
6904
7855
  if (!customElements.get("aparte-context")) {
6905
7856
  customElements.define("aparte-context", AparteContext);
6906
7857
  }
@@ -7363,11 +8314,12 @@ class AparteScrollRail extends HTMLElement {
7363
8314
  this._held = null;
7364
8315
  }
7365
8316
  }
7366
- function sameElements(a2, b2) {
7367
- if (a2.length !== b2.length) return false;
7368
- for (let i2 = 0; i2 < a2.length; i2++) if (a2[i2] !== b2[i2]) return false;
8317
+ function sameElements(a3, b2) {
8318
+ if (a3.length !== b2.length) return false;
8319
+ for (let i2 = 0; i2 < a3.length; i2++) if (a3[i2] !== b2[i2]) return false;
7369
8320
  return true;
7370
8321
  }
8322
+ deferHydrationOf(AparteScrollRail);
7371
8323
  if (!customElements.get("aparte-scroll-rail")) {
7372
8324
  customElements.define("aparte-scroll-rail", AparteScrollRail);
7373
8325
  }
@@ -7718,6 +8670,7 @@ function visibleFocusable(root) {
7718
8670
  function nearestSidebar(control) {
7719
8671
  return control.closest("aparte-sidebar") ?? rootOf(control).querySelector("aparte-sidebar") ?? document.querySelector("aparte-sidebar");
7720
8672
  }
8673
+ deferHydrationOf(AparteSidebar);
7721
8674
  if (!customElements.get("aparte-sidebar")) {
7722
8675
  customElements.define("aparte-sidebar", AparteSidebar);
7723
8676
  }
@@ -8496,6 +9449,7 @@ class AparteSplit extends HTMLElement {
8496
9449
  handle.dataset["ownLabel"] = "locale";
8497
9450
  }
8498
9451
  }
9452
+ deferHydrationOf(AparteSplit);
8499
9453
  if (!customElements.get("aparte-split")) {
8500
9454
  customElements.define("aparte-split", AparteSplit);
8501
9455
  }
@@ -8571,7 +9525,7 @@ export {
8571
9525
  AparteElicitation,
8572
9526
  AparteElicitationAbortError,
8573
9527
  p as AparteError,
8574
- q as AparteErrorCode,
9528
+ AparteErrorCode,
8575
9529
  AparteIcon,
8576
9530
  AparteMessageRepository,
8577
9531
  AparteOptgroup,
@@ -8583,7 +9537,7 @@ export {
8583
9537
  AparteSplit,
8584
9538
  s as AparteStreamParser,
8585
9539
  AparteSuggestions,
8586
- t as aparteGlobalConfig,
9540
+ aparteGlobalConfig,
8587
9541
  u as applyElementProps,
8588
9542
  v as attachConfig,
8589
9543
  buildApprovalPanel,
@@ -8599,34 +9553,44 @@ export {
8599
9553
  cssEscape,
8600
9554
  I as declineDefaultRenderers,
8601
9555
  J as defaultSanitizer,
8602
- K as detachConfig,
9556
+ deferHydrationOf,
9557
+ L as defineMountedSegmentRenderer,
9558
+ M as defineMountedToolRenderer,
9559
+ N as detachConfig,
8603
9560
  escapeAttr,
8604
9561
  escapeHtml,
8605
- N as filesToAttachments,
8606
- O as getAllRenderers,
9562
+ Q as estimateTokens,
9563
+ R as estimateWireTokens,
9564
+ S as filesToAttachments,
9565
+ T as getAllRenderers,
8607
9566
  getSegmentRenderer,
9567
+ V as historyFromCheckpoint,
8608
9568
  installDefaultRenderersOnce,
8609
9569
  installDialogTriggersOnce,
8610
9570
  isAwaitingReply,
8611
- T as isFormatAdapter,
9571
+ Z as isFormatAdapter,
8612
9572
  isSafeUrl,
8613
9573
  isSegmentSettled,
8614
- W as parseAparteEventStream,
9574
+ measureContext,
9575
+ a1 as overrideSegmentRenderer,
9576
+ a2 as parseAparteEventStream,
8615
9577
  parseMarkdownToSegments,
8616
9578
  populateBubbleFromMessage,
8617
- Z as readableToAsyncIterable,
9579
+ a5 as readableToAsyncIterable,
8618
9580
  registerAllComponents,
8619
- _ as registerDefaultRenderers,
8620
- $ as registerSegmentRenderer,
8621
- a0 as requestUserInput,
9581
+ a6 as registerDefaultRenderers,
9582
+ a7 as registerSegmentRenderer,
9583
+ a8 as releaseDeferredHydration,
9584
+ a9 as requestUserInput,
8622
9585
  resolveConfig,
8623
9586
  revokeAttachmentUrls,
8624
9587
  runWithConfig,
8625
- a4 as segmentDuration,
8626
- a5 as segmentTiming,
9588
+ ad as segmentDuration,
9589
+ ae as segmentTiming,
8627
9590
  subscribeConfigChange,
8628
- a7 as toolRefusal,
8629
- a8 as unregisterSegmentRenderer,
9591
+ ag as toToolCallView,
9592
+ ah as toolRefusal,
9593
+ ai as unregisterSegmentRenderer,
8630
9594
  uuid
8631
9595
  };
8632
9596
  //# sourceMappingURL=index.js.map