@noodleseed/assistant 1.20.0 → 1.22.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.
package/README.md CHANGED
@@ -222,7 +222,10 @@ instead of `sessionEndpoint` — the two are mutually exclusive, and a public em
222
222
  ```
223
223
 
224
224
  A public embed opens its session on **first open**, never on mount — every mount would otherwise spend
225
- one of the surface's daily admission budget. Add `signIn: true` to the surface and it becomes **mixed**:
225
+ one of the surface's daily admission budget. While that first session is resolving, the launcher remains as
226
+ a loading affordance and the panel stays hidden. The panel's first visible frame therefore already carries
227
+ the deployment's compiled name, labels, presentation, and light/dark brand tokens instead of flashing the
228
+ built-in defaults. Add `signIn: true` to the surface and it becomes **mixed**:
226
229
  anonymous visitors start immediately, and identity-dependent capabilities raise the sign-in flow below.
227
230
  Before shipping, preflight the host with `noodle assistant embed --check --surface public` (or `mixed`) —
228
231
  it verifies `script-src` too, the one CSP directive whose failure runs no widget code at all.
@@ -246,7 +249,14 @@ const session = await createAssistantSession({
246
249
  });
247
250
  ```
248
251
 
249
- The visitor keeps the same conversation with a new token; the anonymous token dies at that moment.
252
+ The visitor keeps the same conversation with a new token; the anonymous token dies at that moment
253
+ and by default **the assistant answers the pending question itself**: the service re-attempts the
254
+ intercepted tool under the signed-in principal and streams the result as the session's first turn,
255
+ so the panel's first content after sign-in is the answer the visitor asked for. It is one-shot
256
+ (consumed by that turn, or mooted the moment the visitor types first), confirm-gated tools stop at
257
+ their normal confirmation card — sign-in is never implicit consent for a write — and the turn counts
258
+ against the surface's daily budget like any other. Pass `resume: false` beside the ticket if your
259
+ application provides its own post-login affordance.
250
260
  `signInTicket` and `context` are mutually exclusive — an elevation continues an existing session.
251
261
  `routing` IS accepted here, and this is the moment to send it: elevation is the first authenticated
252
262
  exchange, so it is the only chance a routed connector's session gets its backend-verified customer
@@ -494,6 +504,21 @@ element.addEventListener("assistant-view-available", (event) => {
494
504
  });
495
505
  ```
496
506
 
507
+ #### Readiness and call ordering
508
+
509
+ `assistant-ready` fires **once per element lifetime**, when the element's shadow DOM is rendered and its
510
+ imperative API (`sendMessage`, `confirmTool`, …) is callable. It does **not** mean a session exists — that
511
+ is the `assistant-event` with `event: 'session_started'`. A DOM move (router reparent, portal) re-runs the
512
+ element's connection lifecycle but never re-fires the event. The React wrapper's `onReady` mirrors this:
513
+ at most once per component instance, whether the element upgraded before or after the component mounted;
514
+ the first callback identity wins, and a `console.debug('[noodle-assistant] ready')` breadcrumb marks the
515
+ moment for field diagnosis.
516
+
517
+ `sendMessage` awaits an in-flight eager session exchange and never opens a second session — a message sent
518
+ while the panel is still connecting is delivered on the session that exchange produces. It does **not**
519
+ queue: a `sendMessage` issued while another send or response is in flight rejects with a retryable
520
+ `request_in_progress` error before touching the transcript.
521
+
497
522
  Assistant text renders as provider deltas arrive. If a turn finds an expired session, the component calls
498
523
  the same authenticated session endpoint and retries that unprocessed message once. Confirmations never
499
524
  replay across sessions. React applications may observe recovery and structured failures:
@@ -4,7 +4,7 @@ import {
4
4
  copyAssistantPageContext,
5
5
  createAssistantClient,
6
6
  sessionSourceKey
7
- } from "./chunk-52XT7BLR.js";
7
+ } from "./chunk-ZPZHPXIQ.js";
8
8
  import {
9
9
  _enum,
10
10
  _null,
@@ -6824,6 +6824,11 @@ var AssistantElementEventController = class {
6824
6824
  this.#completedTool = void 0;
6825
6825
  return;
6826
6826
  }
6827
+ if (name === "resume_started") {
6828
+ this.#startStream("message");
6829
+ this.#host.element.dispatchEvent(new CustomEvent("assistant-message-started"));
6830
+ return;
6831
+ }
6827
6832
  if (name === "interaction_completed") {
6828
6833
  if (this.#streamKind !== "interaction") return;
6829
6834
  this.#finishInteraction(String(data.id ?? ""), String(data.action ?? "accept"));
@@ -7042,6 +7047,36 @@ function queryRequired(root, selector) {
7042
7047
  if (!element) throw new Error(`assistant renderer is missing ${selector}`);
7043
7048
  return element;
7044
7049
  }
7050
+ function assistantElementMarkup(appearance, open, styles) {
7051
+ return `${styles}
7052
+ <button class="launcher" type="button" aria-label="${escapeAttribute(appearance.labels.open)}" aria-controls="assistant-panel" aria-expanded="${String(open)}"><slot name="launcher-icon"></slot><span class="visually-hidden" data-session-status aria-live="polite" hidden></span></button>
7053
+ <section class="panel" id="assistant-panel" role="dialog" aria-label="${escapeAttribute(appearance.brand.name)}">
7054
+ <header><slot name="header-leading"></slot><img class="brand-logo" alt=""><strong></strong><slot name="header-actions"></slot><button class="close" type="button" aria-label="${escapeAttribute(appearance.labels.close)}"></button></header>
7055
+ <div class="welcome"><slot name="empty-state"></slot></div>
7056
+ <div class="suggested-prompts"></div>
7057
+ <div class="messages-region">
7058
+ <div class="messages" aria-live="polite"></div>
7059
+ <button class="new-messages" type="button" aria-label="${escapeAttribute(appearance.labels.newMessages)}" hidden></button>
7060
+ </div>
7061
+ <form><slot name="composer-leading"></slot><textarea rows="1"></textarea><button class="send" type="submit"></button><slot name="composer-trailing"></slot></form>
7062
+ <nav class="legal" aria-label="Legal"></nav>
7063
+ <slot name="conversation-footer"></slot>
7064
+ </section>`;
7065
+ }
7066
+ function appendLegalLinks(legal, appearance) {
7067
+ for (const [label, href] of [
7068
+ ["Privacy", appearance.privacyUrl],
7069
+ ["Terms", appearance.termsUrl]
7070
+ ]) {
7071
+ if (!href) continue;
7072
+ const link = document.createElement("a");
7073
+ link.href = href;
7074
+ link.target = "_blank";
7075
+ link.rel = "noopener noreferrer";
7076
+ link.textContent = label;
7077
+ legal.append(link);
7078
+ }
7079
+ }
7045
7080
 
7046
7081
  // src/element-page-context-controller.ts
7047
7082
  var AssistantElementPageContextController = class {
@@ -7083,6 +7118,33 @@ var AssistantElementPageContextController = class {
7083
7118
  }
7084
7119
  };
7085
7120
 
7121
+ // src/element-presentation-gate.ts
7122
+ var AssistantElementPresentationGate = class {
7123
+ #host;
7124
+ #primeSession;
7125
+ #focusComposer;
7126
+ constructor(host, primeSession, focusComposer) {
7127
+ this.#host = host;
7128
+ this.#primeSession = primeSession;
7129
+ this.#focusComposer = focusComposer;
7130
+ }
7131
+ syncOpenState() {
7132
+ const open = this.#host.hasAttribute("open");
7133
+ const presentationReady = this.#host.hasAttribute("data-presentation-ready");
7134
+ const launcher = this.#host.shadowRoot?.querySelector(".launcher");
7135
+ launcher?.setAttribute("aria-expanded", String(open && presentationReady));
7136
+ launcher?.setAttribute("aria-busy", String(open && !presentationReady));
7137
+ if (launcher) launcher.disabled = open && !presentationReady;
7138
+ if (open && this.#host.isConnected) this.#primeSession();
7139
+ }
7140
+ reveal() {
7141
+ if (this.#host.hasAttribute("data-presentation-ready")) return;
7142
+ this.#host.setAttribute("data-presentation-ready", "");
7143
+ this.syncOpenState();
7144
+ if (this.#host.hasAttribute("open")) queueMicrotask(this.#focusComposer);
7145
+ }
7146
+ };
7147
+
7086
7148
  // src/element-scroll-controller.ts
7087
7149
  var AssistantElementScrollController = class {
7088
7150
  #region;
@@ -7198,9 +7260,10 @@ var ASSISTANT_ELEMENT_STYLES = `<style>
7198
7260
  .launcher:hover { transform: translateY(-2px) scale(1.03); box-shadow: 0 8px 26px rgba(0,0,0,.16); }
7199
7261
  .launcher img { width: 28px; height: 28px; border-radius: 50%; object-fit: contain; }
7200
7262
  .panel { display: none; flex-direction: column; width: min(var(--ns-assistant-panel-width, var(--ns-assistant-default-panel-width)), calc(100vw - 40px)); min-height: var(--ns-assistant-min-height, var(--ns-assistant-default-min-height)); max-height: min(var(--ns-assistant-max-height, var(--ns-assistant-default-max-height)), calc(100vh - 40px)); overflow: hidden; color: var(--ns-assistant-panel-text, var(--ns-assistant-text, var(--ns-assistant-default-text))); background: var(--ns-assistant-panel, var(--ns-assistant-default-panel)); border: 1px solid var(--ns-assistant-panel-border, var(--ns-assistant-divider, var(--ns-assistant-default-divider))); border-radius: var(--ns-assistant-panel-radius, var(--ns-assistant-default-panel-radius)); box-shadow: var(--ns-assistant-shadow, var(--ns-assistant-default-shadow)); transform-origin: bottom right; isolation: isolate; animation: halo-panel-in .28s cubic-bezier(.23,1,.32,1) both; }
7201
- :host([open]) .panel { display: flex; }
7263
+ :host([open][data-presentation-ready]) .panel { display: flex; }
7202
7264
  .panel { position: relative; }
7203
- :host([open]) .launcher { display: none; }
7265
+ :host([open][data-presentation-ready]) .launcher { display: none; }
7266
+ :host([open]:not([data-presentation-ready])) .launcher { cursor: progress; opacity: .72; transform: none; }
7204
7267
  header { display: flex; flex: 0 0 auto; gap: 10px; align-items: center; min-height: 64px; padding: 12px 18px; color: var(--ns-assistant-header-text, inherit); background: var(--ns-assistant-header, transparent); border-bottom: 1px solid color-mix(in srgb, var(--ns-assistant-header-border, var(--ns-assistant-divider, var(--ns-assistant-default-divider))) 78%, transparent); }
7205
7268
  header strong { flex: 1; font-size: 16px; font-weight: 600; letter-spacing: 0; }
7206
7269
  .brand-logo { display: block; max-width: 120px; max-height: 28px; object-fit: contain; }
@@ -7826,9 +7889,16 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7826
7889
  #automaticPageContext = new AssistantElementPageContextController(
7827
7890
  () => !this.#pageContext
7828
7891
  );
7892
+ #presentationGate = new AssistantElementPresentationGate(
7893
+ this,
7894
+ () => this.#primeSession(),
7895
+ () => this.focusComposer()
7896
+ );
7829
7897
  #pendingUserEcho;
7830
7898
  #sessionState = "idle";
7831
7899
  #startOpenApplied = false;
7900
+ #readyDispatched = false;
7901
+ #turnInFlight = false;
7832
7902
  #hostAppearance;
7833
7903
  #appearanceWarningKeys = /* @__PURE__ */ new Set();
7834
7904
  #appearanceStyleSnapshot = /* @__PURE__ */ new Map();
@@ -7894,20 +7964,24 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7894
7964
  if (!this.sessionEndpoint) this.sessionEndpoint = this.getAttribute("session-endpoint") ?? "";
7895
7965
  if (!this.embedId) this.embedId = this.getAttribute("embed-id") ?? "";
7896
7966
  if (!this.serviceUrl) this.serviceUrl = this.getAttribute("service-url") ?? "";
7967
+ if (!this.sessionEndpoint && !this.embedId) this.#presentationGate.reveal();
7897
7968
  this.#media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
7898
7969
  this.#media?.addEventListener("change", this.#handleSystemTheme);
7899
7970
  document.addEventListener("keydown", this.#handleDocumentKeydown);
7900
7971
  document.addEventListener("pointerdown", this.#handleDocumentPointerDown);
7901
7972
  if (this.#appearance.behavior.startOpen) this.#applyStartOpenPolicy();
7902
7973
  this.#render();
7903
- this.dispatchEvent(new CustomEvent("assistant-ready"));
7974
+ if (!this.#readyDispatched) {
7975
+ this.#readyDispatched = true;
7976
+ this.dispatchEvent(new CustomEvent("assistant-ready"));
7977
+ }
7904
7978
  if (this.hasAttribute("start-open")) {
7905
7979
  this.setAttribute("open", "");
7906
7980
  this.#startOpenApplied = true;
7907
7981
  } else if (!this.embedId) {
7908
7982
  this.#primeSession();
7909
7983
  }
7910
- this.#syncOpenState();
7984
+ this.#presentationGate.syncOpenState();
7911
7985
  }
7912
7986
  disconnectedCallback() {
7913
7987
  this.#conversationGeneration += 1;
@@ -7925,7 +7999,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
7925
7999
  }
7926
8000
  attributeChangedCallback(name) {
7927
8001
  if (name === "theme") this.#applyTheme();
7928
- if (name === "open") this.#syncOpenState();
8002
+ if (name === "open") this.#presentationGate.syncOpenState();
7929
8003
  }
7930
8004
  open() {
7931
8005
  this.setAttribute("open", "");
@@ -8014,7 +8088,14 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8014
8088
  async sendMessage(text2) {
8015
8089
  const message = text2.trim();
8016
8090
  if (!message) return;
8091
+ if (this.#turnInFlight) {
8092
+ throw new AssistantClientError(
8093
+ { code: "request_in_progress", retryable: true },
8094
+ "an assistant request is already in progress"
8095
+ );
8096
+ }
8017
8097
  const generation = this.#conversationGeneration;
8098
+ this.#turnInFlight = true;
8018
8099
  this.#pendingUserEcho = message;
8019
8100
  this.#appendMessage("user", message);
8020
8101
  this.#setBusy(true);
@@ -8031,6 +8112,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8031
8112
  this.#dispatchClientError(error, "turn_failed");
8032
8113
  throw error;
8033
8114
  } finally {
8115
+ this.#turnInFlight = false;
8034
8116
  if (this.#pendingUserEcho === message) this.#pendingUserEcho = void 0;
8035
8117
  if (generation === this.#conversationGeneration) this.#setBusy(false);
8036
8118
  }
@@ -8099,6 +8181,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8099
8181
  #primeSession() {
8100
8182
  if (!this.sessionEndpoint && !this.embedId) return;
8101
8183
  if (this.#sessionBootstrap || this.#client?.hasSession()) return;
8184
+ if (this.#client?.isBusy?.() === true) return;
8102
8185
  if (this.embedId && !this.#pageContext) void this.#automaticPageContext.refresh();
8103
8186
  const client = this.#ensureClient();
8104
8187
  const generation = this.#conversationGeneration;
@@ -8106,6 +8189,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8106
8189
  const bootstrap = client.connect().catch((error) => {
8107
8190
  if (generation !== this.#conversationGeneration || this.#client !== client) return;
8108
8191
  this.#dispatchClientError(error, "session_failed");
8192
+ this.#presentationGate.reveal();
8109
8193
  }).finally(() => {
8110
8194
  if (this.#sessionBootstrap === bootstrap) this.#sessionBootstrap = void 0;
8111
8195
  });
@@ -8115,6 +8199,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8115
8199
  this.dispatchEvent(new CustomEvent("assistant-event", { detail: event }));
8116
8200
  this.#events.handle(event);
8117
8201
  if (event.event === "session_started") {
8202
+ this.#presentationGate.reveal();
8118
8203
  this.#setSessionState("ready");
8119
8204
  this.#messages?.querySelector(".conversation-error")?.remove();
8120
8205
  }
@@ -8128,6 +8213,8 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8128
8213
  if (event.event === "content" || event.event === "message_completed" || event.event === "interaction_completed" || event.event === "error") {
8129
8214
  this.#removeThinking();
8130
8215
  }
8216
+ if (event.event === "resume_started") this.#setBusy(true);
8217
+ if (event.event === "message_completed" || event.event === "error") this.#setBusy(false);
8131
8218
  }
8132
8219
  #setBusy(busy) {
8133
8220
  this.toggleAttribute("busy", busy);
@@ -8282,11 +8369,6 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8282
8369
  this.#sessionState = state;
8283
8370
  syncAssistantSessionVisualState(this, this.shadowRoot, state, this.#appearance);
8284
8371
  }
8285
- #syncOpenState() {
8286
- const open = this.hasAttribute("open");
8287
- this.shadowRoot?.querySelector(".launcher")?.setAttribute("aria-expanded", String(open));
8288
- if (open && this.isConnected) this.#primeSession();
8289
- }
8290
8372
  #closeAndRestoreFocus() {
8291
8373
  this.close();
8292
8374
  this.shadowRoot?.querySelector(".launcher")?.focus();
@@ -8336,7 +8418,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8336
8418
  queryRequired(this.shadowRoot, "header").hidden = !appearance.behavior.showHeader;
8337
8419
  const legal = queryRequired(this.shadowRoot, ".legal");
8338
8420
  legal.replaceChildren();
8339
- this.#appendLegalLinks(legal);
8421
+ appendLegalLinks(legal, this.#appearance);
8340
8422
  this.#applyTheme();
8341
8423
  }
8342
8424
  #render() {
@@ -8352,20 +8434,11 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8352
8434
  this.dataset.position = appearance.layout.position;
8353
8435
  this.dataset.density = appearance.layout.density;
8354
8436
  this.toggleAttribute("mobile-fullscreen", appearance.layout.mobileFullscreen);
8355
- this.shadowRoot.innerHTML = `${ASSISTANT_ELEMENT_STYLES}${presentationStyles}
8356
- <button class="launcher" type="button" aria-label="${escapeAttribute(appearance.labels.open)}" aria-controls="assistant-panel" aria-expanded="${String(this.hasAttribute("open"))}"><slot name="launcher-icon"></slot><span class="visually-hidden" data-session-status aria-live="polite" hidden></span></button>
8357
- <section class="panel" id="assistant-panel" role="dialog" aria-label="${escapeAttribute(appearance.brand.name)}">
8358
- <header><slot name="header-leading"></slot><img class="brand-logo" alt=""><strong></strong><slot name="header-actions"></slot><button class="close" type="button" aria-label="${escapeAttribute(appearance.labels.close)}"></button></header>
8359
- <div class="welcome"><slot name="empty-state"></slot></div>
8360
- <div class="suggested-prompts"></div>
8361
- <div class="messages-region">
8362
- <div class="messages" aria-live="polite"></div>
8363
- <button class="new-messages" type="button" aria-label="${escapeAttribute(appearance.labels.newMessages)}" hidden></button>
8364
- </div>
8365
- <form><slot name="composer-leading"></slot><textarea rows="1"></textarea><button class="send" type="submit"></button><slot name="composer-trailing"></slot></form>
8366
- <nav class="legal" aria-label="Legal"></nav>
8367
- <slot name="conversation-footer"></slot>
8368
- </section>`;
8437
+ this.shadowRoot.innerHTML = assistantElementMarkup(
8438
+ appearance,
8439
+ this.hasAttribute("open"),
8440
+ `${ASSISTANT_ELEMENT_STYLES}${presentationStyles}`
8441
+ );
8369
8442
  queryRequired(this.shadowRoot, "strong").textContent = appearance.brand.name;
8370
8443
  const logo = queryRequired(this.shadowRoot, ".brand-logo");
8371
8444
  const logoUrl = appearance.brand.logo[this.#resolvedMode()];
@@ -8392,7 +8465,7 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8392
8465
  if (!appearance.behavior.showHeader)
8393
8466
  queryRequired(this.shadowRoot, "header").hidden = true;
8394
8467
  const legal = queryRequired(this.shadowRoot, ".legal");
8395
- this.#appendLegalLinks(legal);
8468
+ appendLegalLinks(legal, this.#appearance);
8396
8469
  this.#messages = this.shadowRoot.querySelector(".messages") ?? void 0;
8397
8470
  this.#messages?.append(...conversation);
8398
8471
  this.#thinking = this.#messages?.querySelector(".thinking") ?? void 0;
@@ -8431,20 +8504,6 @@ var NoodleAssistantElement = class extends HTMLElementBase {
8431
8504
  });
8432
8505
  this.#applyTheme();
8433
8506
  }
8434
- #appendLegalLinks(legal) {
8435
- for (const [label, href] of [
8436
- ["Privacy", this.#appearance.privacyUrl],
8437
- ["Terms", this.#appearance.termsUrl]
8438
- ]) {
8439
- if (!href) continue;
8440
- const link = document.createElement("a");
8441
- link.href = href;
8442
- link.target = "_blank";
8443
- link.rel = "noopener noreferrer";
8444
- link.textContent = label;
8445
- legal.append(link);
8446
- }
8447
- }
8448
8507
  };
8449
8508
  function registerNoodleAssistant() {
8450
8509
  if (globalThis.customElements && !customElements.get(ASSISTANT_TAG_NAME)) {
@@ -8465,4 +8524,4 @@ export {
8465
8524
  dompurify/dist/purify.es.mjs:
8466
8525
  (*! @license DOMPurify 3.4.11 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.4.11/LICENSE *)
8467
8526
  */
8468
- //# sourceMappingURL=chunk-DLUINP4W.js.map
8527
+ //# sourceMappingURL=chunk-6TT3R555.js.map