@spektra-cloudevents/application-copilot-widget 1.2.0 → 2.0.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/dist/index.cjs CHANGED
@@ -96,7 +96,7 @@ var widgetStyles = `
96
96
  .acw-close { margin-left: auto; border: none; background: transparent; cursor: pointer; font-size: 22px; color: rgba(255,255,255,0.85); line-height: 1; }
97
97
  .acw-close:hover { color: #fff; }
98
98
 
99
- .acw-body { flex: 1; overflow-y: auto; padding: 16px; background: var(--acw-surface); }
99
+ .acw-body { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 16px; background: var(--acw-surface); }
100
100
  .acw-hint { font-size: 12px; color: var(--acw-muted); margin: 0 0 10px; }
101
101
 
102
102
  .acw-chip {
@@ -116,19 +116,37 @@ var widgetStyles = `
116
116
  .acw-msg.acw-user span, .acw-msg.acw-user {
117
117
  display: inline-block;
118
118
  }
119
+ .acw-msg.acw-user { display: block; max-width: 100%; white-space: pre-wrap; overflow-wrap: anywhere; }
119
120
  .acw-progress { font-size: 13px; color: var(--acw-muted); margin: 10px 0; }
120
121
 
121
122
  .acw-answer { background: var(--acw-bg); border: 1px solid var(--acw-border); border-radius: 12px; padding: 12px 14px; margin: 12px 0; }
122
- .acw-answer .acw-reason { font-size: 14px; line-height: 1.5; }
123
+ .acw-answer .acw-reason, .acw-msg .acw-reason { font-size: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
123
124
  .acw-answer.acw-blocked { border-left: 4px solid var(--acw-danger); }
124
125
  .acw-answer.acw-okstate { border-left: 4px solid var(--acw-ok); }
125
126
  .acw-role { font-size: 12px; color: var(--acw-muted); margin-top: 8px; }
126
127
  .acw-evidence { font-size: 12px; color: var(--acw-muted); margin-top: 10px; word-break: break-word; }
127
- .acw-error { font-size: 14px; color: var(--acw-danger); }
128
+ .acw-error { font-size: 14px; color: var(--acw-danger); white-space: pre-wrap; overflow-wrap: anywhere; }
129
+
130
+ /* Knowledge-base references */
131
+ .acw-refs { margin-top: 10px; font-size: 12px; }
132
+ .acw-refs-label { color: var(--acw-muted); font-weight: 600; margin-bottom: 4px; }
133
+ .acw-refs-list { margin: 0; padding-left: 18px; }
134
+ .acw-refs-list li { margin: 2px 0; }
135
+ .acw-ref { color: var(--acw-accent); text-decoration: underline; overflow-wrap: anywhere; }
136
+ .acw-ref:hover { color: var(--acw-accent-dark); }
137
+
138
+ /* Support-ticket offer */
139
+ .acw-offer { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--acw-border); }
140
+ .acw-offer-text { font-size: 13px; line-height: 1.5; color: var(--acw-fg); white-space: pre-wrap; overflow-wrap: anywhere; margin-bottom: 8px; }
141
+ .acw-offer-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; }
142
+ .acw-offer-actions .acw-chip { width: auto; margin-bottom: 0; }
143
+ .acw-offer-actions .acw-link-btn { margin-top: 0; }
144
+ .acw-chip:disabled, .acw-link-btn:disabled { opacity: .6; cursor: default; }
145
+ .acw-nothelpful { display: block; margin-top: 10px; color: var(--acw-muted); }
128
146
 
129
147
  .acw-footer { display: flex; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--acw-border); background: var(--acw-bg); }
130
148
  .acw-input {
131
- flex: 1; padding: 10px 12px; font-size: 14px;
149
+ flex: 1; min-width: 0; padding: 10px 12px; font-size: 14px;
132
150
  border: 1px solid var(--acw-border); border-radius: 10px; color: var(--acw-fg); background: var(--acw-bg);
133
151
  font-family: inherit;
134
152
  }
@@ -148,16 +166,90 @@ var widgetStyles = `
148
166
  }
149
167
  .acw-ticket-desc { resize: vertical; min-height: 170px; line-height: 1.5; }
150
168
  .acw-ticket-input:focus, .acw-ticket-desc:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
151
- .acw-ticket-actions { display: flex; gap: 10px; margin-top: 14px; }
169
+ .acw-ticket-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
152
170
  .acw-btn-primary { border: none; border-radius: 10px; padding: 10px 20px; cursor: pointer; background: var(--acw-accent); color: #fff; font-size: 14px; font-weight: 600; font-family: inherit; }
153
171
  .acw-btn-primary:hover { background: var(--acw-accent-dark); }
154
172
  .acw-btn-ghost { border: 1px solid var(--acw-border); border-radius: 10px; padding: 10px 16px; cursor: pointer; background: var(--acw-bg); color: var(--acw-fg); font-size: 14px; font-family: inherit; }
155
173
  .acw-btn-ghost:hover { background: #f0eefe; }
156
174
  .acw-link-btn { margin-top: 8px; border: none; background: transparent; color: var(--acw-accent); font-size: 12px; font-family: inherit; cursor: pointer; padding: 0; text-decoration: underline; }
157
175
  .acw-link-btn:hover { color: var(--acw-accent-dark); }
176
+
177
+ /* Responsive: full-width panel below 480px; never taller than the viewport; no horizontal scroll. */
178
+ @media (max-width: 479.98px) {
179
+ .acw-launch-wrap { right: 16px; bottom: 16px; }
180
+ .acw-tip { display: none; }
181
+ .acw-panel {
182
+ left: 0; right: 0; bottom: 0;
183
+ width: 100%; max-width: 100%;
184
+ height: calc(100vh - 32px); max-height: calc(100vh - 32px);
185
+ border-radius: 16px 16px 0 0; border-left: none; border-right: none; border-bottom: none;
186
+ }
187
+ .acw-ticket-desc { min-height: 120px; }
188
+ }
189
+ /* Short desktop/tablet viewports: keep the panel inside the viewport (beside the launcher). */
190
+ @media (max-height: 700px) and (min-width: 480px) {
191
+ .acw-panel { bottom: 16px; right: 96px; max-width: calc(100vw - 112px); max-height: calc(100vh - 32px); }
192
+ }
158
193
  `;
159
194
 
195
+ // src/internal.ts
196
+ function escapeHtml(value) {
197
+ const s = value === null || value === void 0 ? "" : String(value);
198
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
199
+ }
200
+ function safeHref(raw) {
201
+ if (typeof raw !== "string") return null;
202
+ const trimmed = raw.trim();
203
+ if (!trimmed) return null;
204
+ let url;
205
+ try {
206
+ url = new URL(trimmed);
207
+ } catch {
208
+ return null;
209
+ }
210
+ if (url.protocol !== "https:") return null;
211
+ if (url.username || url.password) return null;
212
+ if (!url.hostname) return null;
213
+ return url.href;
214
+ }
215
+ function normalizeReferences(refs, max = 10) {
216
+ if (!Array.isArray(refs)) return [];
217
+ const out = [];
218
+ const seen = /* @__PURE__ */ new Set();
219
+ for (const r of refs) {
220
+ if (!r || typeof r !== "object") continue;
221
+ const href = safeHref(r.url);
222
+ if (!href || seen.has(href)) continue;
223
+ seen.add(href);
224
+ const rawTitle = r.title;
225
+ const title = typeof rawTitle === "string" && rawTitle.trim() ? rawTitle.trim() : new URL(href).hostname;
226
+ out.push({ title, href });
227
+ if (out.length >= max) break;
228
+ }
229
+ return out;
230
+ }
231
+ function isSessionInvalidStatus(status) {
232
+ return status === 403 || status === 404;
233
+ }
234
+ function tokenIdentity(token) {
235
+ if (!token || typeof token !== "string") return null;
236
+ const parts = token.split(".");
237
+ if (parts.length < 2 || !parts[1]) return null;
238
+ try {
239
+ const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
240
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
241
+ const payload = JSON.parse(atob(padded));
242
+ const emails = Array.isArray(payload.emails) ? payload.emails[0] : void 0;
243
+ const id = payload.oid ?? payload.sub ?? emails ?? payload.email ?? payload.preferred_username;
244
+ return typeof id === "string" && id ? id.toLowerCase() : null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
160
250
  // src/widget.ts
251
+ var TICKET_OFFER_TEXT = "I couldn\u2019t answer this confidently. Would you like to raise a support ticket? I\u2019ll pre-fill it with this conversation.";
252
+ var SESSION_RESET_TEXT = "Your Copilot session has ended or is no longer valid, so a new one will be started. Please ask your question again.";
161
253
  var PROGRESS_LABELS = {
162
254
  QuestionAccepted: "Reading your question\u2026",
163
255
  PermissionsVerified: "Verifying your permissions\u2026",
@@ -168,18 +260,50 @@ var CopilotWidget = class {
168
260
  constructor() {
169
261
  this.lastQuestion = "";
170
262
  this.mode = "explain";
263
+ this.connecting = false;
264
+ /** Bumped by resetSession(); responses that belong to an older epoch are dropped. */
265
+ this.epoch = 0;
171
266
  }
172
267
  init(config) {
268
+ const previousTenant = this.config?.tenantUniqueName;
173
269
  this.config = config;
174
270
  try {
175
271
  console.log("[ApplicationCopilot] widget loaded \u2014 support-ticket build v2");
176
272
  } catch {
177
273
  }
274
+ if (previousTenant !== void 0 && previousTenant !== config.tenantUniqueName) this.resetSession();
178
275
  this.mount();
179
- this.connectSignalR().catch(() => {
180
- });
276
+ this.applyKnowledgeUploadVisibility();
277
+ if (!this.connection && !this.connecting) {
278
+ this.connecting = true;
279
+ this.connectSignalR().catch(() => {
280
+ }).finally(() => {
281
+ this.connecting = false;
282
+ });
283
+ }
284
+ }
285
+ /**
286
+ * Drop the current Copilot session and clear the conversation. The next question opens a new session
287
+ * for the current `tenantUniqueName` and signed-in user. Also called automatically when the tenant or the
288
+ * token's user changes and when the API answers 403/404 for the session.
289
+ */
290
+ resetSession() {
291
+ this.epoch++;
292
+ this.sessionId = void 0;
293
+ this.sessionTenant = void 0;
294
+ this.sessionIdentity = void 0;
295
+ this.lastQuestion = "";
296
+ this.contextOverride = void 0;
297
+ this.recentError = void 0;
298
+ this.mode = "explain";
299
+ if (this.input) this.input.placeholder = "Ask a question\u2026";
300
+ if (this.body) {
301
+ this.body.replaceChildren();
302
+ if (this.panel?.classList.contains("acw-open")) this.refreshChips();
303
+ }
181
304
  }
182
305
  open() {
306
+ this.syncSessionScope();
183
307
  this.panel?.classList.add("acw-open");
184
308
  this.refreshChips();
185
309
  }
@@ -224,6 +348,7 @@ var CopilotWidget = class {
224
348
  this.body.scrollTop = this.body.scrollHeight;
225
349
  }
226
350
  ask(question) {
351
+ this.syncSessionScope();
227
352
  if (this.mode === "intake") {
228
353
  void this.runIntakeAsk(question);
229
354
  } else {
@@ -241,7 +366,7 @@ var CopilotWidget = class {
241
366
  style.textContent = widgetStyles;
242
367
  this.root.appendChild(style);
243
368
  const sparkle = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l1.7 4.6L18.3 8l-4.6 1.4L12 14l-1.7-4.6L5.7 8l4.6-1.4L12 2zm6.5 9l1 2.6 2.6 1-2.6 1-1 2.6-1-2.6-2.6-1 2.6-1 1-2.6zM6 13.5l.9 2.3 2.3.9-2.3.9L6 20l-.9-2.4L2.7 16.7l2.4-.9.9-2.3z"/></svg>';
244
- const tip = this.config.buttonText ?? "Ask Application Copilot";
369
+ const tip = escapeHtml(this.config.buttonText ?? "Ask Application Copilot");
245
370
  const wrap = document.createElement("div");
246
371
  wrap.className = "acw-launch-wrap";
247
372
  wrap.innerHTML = `<span class="acw-tip">${tip}</span><button class="acw-launch" type="button" aria-label="${tip}">${sparkle}</button>`;
@@ -254,7 +379,7 @@ var CopilotWidget = class {
254
379
  <div class="acw-header">
255
380
  ${sparkle}
256
381
  <span class="acw-title">Application Copilot</span>
257
- <button class="acw-kb" type="button" aria-label="Add a document to the knowledge base" title="Add to knowledge base (admins)" style="background:none;border:none;color:inherit;cursor:pointer;padding:2px 6px;display:inline-flex;align-items:center;">${uploadIcon}</button>
382
+ <button class="acw-kb" type="button" aria-label="Add a document to the knowledge base" title="Add to knowledge base (admins)" hidden style="background:none;border:none;color:inherit;cursor:pointer;padding:2px 6px;align-items:center;">${uploadIcon}</button>
258
383
  <button class="acw-close" type="button" aria-label="Close">&times;</button>
259
384
  </div>
260
385
  <div class="acw-body"></div>
@@ -282,10 +407,21 @@ var CopilotWidget = class {
282
407
  if (e.key === "Enter") this.submitFromInput();
283
408
  });
284
409
  }
285
- /** Open the file picker to add a document to the knowledge base (server enforces admin-only). */
410
+ /**
411
+ * Open the file picker to add a document to the knowledge base. Only when the host set `showKnowledgeUpload`;
412
+ * otherwise it just opens the panel. The Copilot API authorizes every upload either way.
413
+ */
286
414
  promptUpload() {
287
415
  this.open();
288
- this.fileInput?.click();
416
+ if (this.config?.showKnowledgeUpload === true) this.fileInput?.click();
417
+ }
418
+ /** B5: the upload button is hidden unless the host opts in (re-applied on every init, e.g. after a tenant switch). */
419
+ applyKnowledgeUploadVisibility() {
420
+ const btn = this.panel?.querySelector(".acw-kb");
421
+ if (!btn) return;
422
+ const show = this.config?.showKnowledgeUpload === true;
423
+ btn.hidden = !show;
424
+ btn.style.display = show ? "inline-flex" : "none";
289
425
  }
290
426
  async uploadDocument(file) {
291
427
  this.open();
@@ -295,16 +431,14 @@ var CopilotWidget = class {
295
431
  const form = new FormData();
296
432
  form.append("file", file, file.name);
297
433
  form.append("tenantUniqueName", this.config.tenantUniqueName);
298
- form.append("environment", this.config.environment);
299
434
  const h = {};
300
435
  const token = this.config.getAuthToken?.();
301
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
302
436
  if (token) h["Authorization"] = `Bearer ${token}`;
303
- else if (email) h["X-User-Email"] = email;
304
437
  const res = await fetch(`${this.config.apiBaseUrl}/api/knowledge/upload`, { method: "POST", headers: h, body: form });
305
438
  this.setProgress("");
306
439
  if (res.status === 403) {
307
- this.renderAnswer({ status: "Forbidden", primaryReason: "Only Spektra Admins can add to the knowledge base." });
440
+ if (await this.renderIfFeatureDisabled(res)) return;
441
+ this.renderAnswer({ status: "Forbidden", primaryReason: "Only System Admins and Partner Admins can add to the knowledge base." });
308
442
  return;
309
443
  }
310
444
  if (!res.ok) {
@@ -313,7 +447,7 @@ var CopilotWidget = class {
313
447
  }
314
448
  const data = await res.json();
315
449
  if (data?.status === "Indexed") {
316
- this.renderAnswer({ status: "Answered", disabled: false, primaryReason: `Added \u201C${data.title}\u201D to the knowledge base (${data.chunks} chunk${data.chunks === 1 ? "" : "s"}). I can now use it to answer questions.` });
450
+ this.renderAnswer({ status: "Answered", disabled: false, primaryReason: `Uploaded \u201C${data.title}\u201D as a Draft (${data.chunks} chunk${data.chunks === 1 ? "" : "s"}). A System Admin or Partner Admin must publish it in Admin \u2192 Settings \u2192 Copilot Knowledge Base before I use it in answers.` });
317
451
  } else {
318
452
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not add the document." });
319
453
  }
@@ -362,20 +496,30 @@ var CopilotWidget = class {
362
496
  this.open();
363
497
  this.addMessage(question, "user");
364
498
  this.setProgress("Finding the right offering\u2026");
499
+ const epoch = this.epoch;
365
500
  try {
366
501
  const sessionId = await this.ensureSession();
367
- const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${sessionId}/intake/ask`, {
502
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/intake/ask`, {
368
503
  method: "POST",
369
504
  headers: this.headers(),
370
505
  body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName })
371
506
  });
507
+ if (epoch !== this.epoch) return;
508
+ if (isSessionInvalidStatus(res.status)) {
509
+ if (await this.renderIfFeatureDisabled(res)) return;
510
+ this.handleInvalidSession();
511
+ return;
512
+ }
372
513
  if (!res.ok) {
373
514
  this.setProgress("");
374
515
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
375
516
  return;
376
517
  }
377
- this.renderIntakeAnswer(await res.json());
518
+ const data = await res.json();
519
+ if (epoch !== this.epoch) return;
520
+ this.renderIntakeAnswer(data);
378
521
  } catch {
522
+ if (epoch !== this.epoch) return;
379
523
  this.setProgress("");
380
524
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
381
525
  }
@@ -434,10 +578,10 @@ var CopilotWidget = class {
434
578
  p.textContent = text;
435
579
  this.body.scrollTop = this.body.scrollHeight;
436
580
  }
437
- renderAnswer(answer) {
581
+ renderAnswer(answer, question, wasNotHelpful = false) {
438
582
  this.setProgress("");
439
583
  const el = document.createElement("div");
440
- const isFailure = answer.status && answer.status !== "Answered";
584
+ const isFailure = !!answer.status && answer.status !== "Answered";
441
585
  if (isFailure) {
442
586
  el.className = "acw-answer";
443
587
  el.innerHTML = `<div class="acw-error">${escapeHtml(answer.primaryReason || answer.status)}</div>`;
@@ -447,21 +591,99 @@ var CopilotWidget = class {
447
591
  const sourceLabel = answer.source === "code" ? "App rules" : answer.source === "database" ? "Live data" : answer.source === "policy" ? "SharePoint docs" : "";
448
592
  el.innerHTML = `<div class="acw-reason">${escapeHtml(answer.primaryReason || "")}</div>` + (answer.verifiedRoleLabel ? `<div class="acw-role">Verified role: ${escapeHtml(answer.verifiedRoleLabel)}</div>` : "") + (sourceLabel ? `<div class="acw-role">Source: ${sourceLabel}</div>` : "") + (evidence ? `<div class="acw-evidence">Evidence: ${evidence}</div>` : "");
449
593
  }
594
+ this.appendReferences(el, answer);
450
595
  this.body.appendChild(el);
451
- const role = (answer.verifiedRoleLabel || "").toLowerCase();
452
- const eligibleRole = role === "requestor" || role === "approver";
453
- const looksBlocked = /lock(ed)?|can only be (changed|edited)|not editable|isn'?t permitted|not permitted|can'?t (change|edit|update)|cannot (change|edit|update)|read-only|don'?t have access|not allowed/i.test(answer.primaryReason || "");
454
- const offerTicket = answer.supportTicketEligible === true || (answer.disabled === true || looksBlocked) && eligibleRole;
455
- if (!isFailure && offerTicket) {
456
- const btn = document.createElement("button");
457
- btn.type = "button";
458
- btn.className = "acw-chip";
459
- btn.textContent = "Raise a support ticket";
460
- btn.addEventListener("click", () => this.openTicketDraft(answer));
461
- el.appendChild(btn);
596
+ let offerTicket;
597
+ if (typeof answer.supportTicketEligible === "boolean") {
598
+ offerTicket = answer.supportTicketEligible;
599
+ } else {
600
+ const role = (answer.verifiedRoleLabel || "").toLowerCase();
601
+ const eligibleRole = role === "requestor" || role === "approver";
602
+ const looksBlocked = /lock(ed)?|can only be (changed|edited)|not editable|isn'?t permitted|not permitted|can'?t (change|edit|update)|cannot (change|edit|update)|read-only|don'?t have access|not allowed/i.test(answer.primaryReason || "");
603
+ offerTicket = !isFailure && (answer.disabled === true || looksBlocked) && eligibleRole;
604
+ }
605
+ if (offerTicket) {
606
+ this.appendTicketOffer(el, answer);
607
+ } else if (!isFailure && question && !wasNotHelpful) {
608
+ const nh = document.createElement("button");
609
+ nh.type = "button";
610
+ nh.className = "acw-link-btn acw-nothelpful";
611
+ nh.textContent = "This didn\u2019t help";
612
+ nh.addEventListener("click", () => {
613
+ nh.remove();
614
+ void this.runAsk(question, true);
615
+ });
616
+ el.appendChild(nh);
462
617
  }
463
618
  this.body.scrollTop = this.body.scrollHeight;
464
619
  }
620
+ /** KB references as safe links: https only, built with DOM APIs (no innerHTML), new tab, no opener/referrer. */
621
+ appendReferences(container, answer) {
622
+ const refs = normalizeReferences(answer.references);
623
+ if (refs.length === 0) return;
624
+ const wrap = document.createElement("div");
625
+ wrap.className = "acw-refs";
626
+ const label = document.createElement("div");
627
+ label.className = "acw-refs-label";
628
+ label.textContent = "Learn more:";
629
+ wrap.appendChild(label);
630
+ const list = document.createElement("ul");
631
+ list.className = "acw-refs-list";
632
+ for (const ref of refs) {
633
+ const li = document.createElement("li");
634
+ const a = document.createElement("a");
635
+ a.className = "acw-ref";
636
+ a.setAttribute("href", ref.href);
637
+ a.setAttribute("target", "_blank");
638
+ a.setAttribute("rel", "noopener noreferrer");
639
+ a.textContent = ref.title;
640
+ li.appendChild(a);
641
+ list.appendChild(li);
642
+ }
643
+ wrap.appendChild(list);
644
+ container.appendChild(wrap);
645
+ }
646
+ appendTicketOffer(container, answer) {
647
+ const offer = document.createElement("div");
648
+ offer.className = "acw-offer";
649
+ const text = document.createElement("div");
650
+ text.className = "acw-offer-text";
651
+ text.textContent = answer.supportTicketPrompt?.trim() || TICKET_OFFER_TEXT;
652
+ offer.appendChild(text);
653
+ const actions = document.createElement("div");
654
+ actions.className = "acw-offer-actions";
655
+ const btn = document.createElement("button");
656
+ btn.type = "button";
657
+ btn.className = "acw-chip";
658
+ btn.textContent = "Raise a support ticket";
659
+ btn.addEventListener("click", () => {
660
+ btn.disabled = true;
661
+ void this.openTicketDraft(answer).finally(() => {
662
+ btn.disabled = false;
663
+ });
664
+ });
665
+ actions.appendChild(btn);
666
+ this.appendOpenSupport(actions);
667
+ offer.appendChild(actions);
668
+ container.appendChild(offer);
669
+ }
670
+ /** Adds an "Open support" action when the host supplied `onOpenSupport`. */
671
+ appendOpenSupport(container) {
672
+ if (!this.config.onOpenSupport) return;
673
+ const b = document.createElement("button");
674
+ b.type = "button";
675
+ b.className = "acw-link-btn acw-open-support";
676
+ b.textContent = "Open support";
677
+ b.addEventListener("click", () => this.openSupport());
678
+ container.appendChild(b);
679
+ }
680
+ /** Close the panel first (synchronously), then hand over to the host so the two overlays never stack. */
681
+ openSupport() {
682
+ const cb = this.config.onOpenSupport;
683
+ if (!cb) return;
684
+ this.close();
685
+ cb();
686
+ }
465
687
  currentRequestId() {
466
688
  const ctx = this.currentContext();
467
689
  const explicit = ctx.page?.requestId || ctx.page?.RequestId;
@@ -472,10 +694,45 @@ var CopilotWidget = class {
472
694
  const guid = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/.exec(route || "");
473
695
  return guid ? guid[0] : void 0;
474
696
  }
475
- /** Build a pre-filled, editable ticket draft and let the user review + Send (they trigger the API call). */
476
- openTicketDraft(answer) {
697
+ /** Fetch the server-built draft, then show it as an editable form; the user triggers Send. */
698
+ async openTicketDraft(answer) {
477
699
  const fromAnswer = /MS[A-Z0-9]{6,}/i.exec(answer.primaryReason || "");
478
700
  const requestId = fromAnswer ? fromAnswer[0] : this.currentRequestId();
701
+ const epoch = this.epoch;
702
+ this.setProgress("Preparing your support ticket\u2026");
703
+ let draft;
704
+ try {
705
+ const sessionId = await this.ensureSession();
706
+ const qs = requestId ? `?requestId=${encodeURIComponent(requestId)}` : "";
707
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/support-ticket/draft${qs}`, {
708
+ method: "GET",
709
+ headers: this.headers()
710
+ });
711
+ if (epoch !== this.epoch) return;
712
+ if (isSessionInvalidStatus(res.status)) {
713
+ if (await this.renderIfFeatureDisabled(res)) return;
714
+ this.handleInvalidSession();
715
+ return;
716
+ }
717
+ if (res.ok) {
718
+ const data = await res.json();
719
+ if (epoch !== this.epoch) return;
720
+ if (data && typeof data.subject === "string" && typeof data.description === "string") draft = data;
721
+ }
722
+ } catch {
723
+ }
724
+ if (epoch !== this.epoch) return;
725
+ this.setProgress("");
726
+ const fallback = this.localDraft(answer, requestId);
727
+ this.showTicketForm(
728
+ draft?.subject || fallback.subject,
729
+ draft?.description || fallback.description,
730
+ draft?.fields?.requestId || requestId,
731
+ answer
732
+ );
733
+ }
734
+ /** Used only when the draft endpoint is unavailable (older server or a transient error). */
735
+ localDraft(answer, requestId) {
479
736
  const role = answer.verifiedRoleLabel || "";
480
737
  const reason = answer.primaryReason || "I am blocked from completing an action.";
481
738
  const subject = requestId ? `[Cloud Events] Help needed on request ${requestId}` : "[Cloud Events] Help needed with an event request";
@@ -487,39 +744,57 @@ var CopilotWidget = class {
487
744
  ` : "") + `
488
745
  What the copilot reported:
489
746
  ${reason}`;
747
+ return { subject, description };
748
+ }
749
+ showTicketForm(subject, description, requestId, answer) {
490
750
  const card = document.createElement("div");
491
751
  card.className = "acw-ticket";
492
- card.innerHTML = `<div class="acw-ticket-title">Raise a support ticket</div><div class="acw-ticket-sub">Review the details below and click Send. It goes to the support team as a Zoho Desk ticket.</div><label class="acw-ticket-label">Subject</label><input class="acw-ticket-subject acw-ticket-input" type="text" /><label class="acw-ticket-label">Description</label><textarea class="acw-ticket-desc" rows="8"></textarea><div class="acw-ticket-actions"><button class="acw-ticket-send acw-btn-primary" type="button">Send ticket</button><button class="acw-ticket-cancel acw-btn-ghost" type="button">Cancel</button></div>`;
493
- card.querySelector(".acw-ticket-subject").value = subject;
494
- card.querySelector(".acw-ticket-desc").value = description;
752
+ card.innerHTML = `<div class="acw-ticket-title">Raise a support ticket</div><div class="acw-ticket-sub">Review and edit the details below, then click Send. It goes to the support team as a Zoho Desk ticket.</div><label class="acw-ticket-label">Subject</label><input class="acw-ticket-subject acw-ticket-input" type="text" /><label class="acw-ticket-label">Description</label><textarea class="acw-ticket-desc" rows="8"></textarea><div class="acw-ticket-actions"><button class="acw-ticket-send acw-btn-primary" type="button">Send ticket</button><button class="acw-ticket-cancel acw-btn-ghost" type="button">Cancel</button></div>`;
753
+ const subjectInput = card.querySelector(".acw-ticket-subject");
754
+ const descInput = card.querySelector(".acw-ticket-desc");
755
+ subjectInput.value = subject;
756
+ descInput.value = description;
495
757
  card.querySelector(".acw-ticket-cancel").addEventListener("click", () => card.remove());
496
758
  card.querySelector(".acw-ticket-send").addEventListener("click", () => {
497
- const subj = card.querySelector(".acw-ticket-subject").value.trim();
498
- const desc = card.querySelector(".acw-ticket-desc").value.trim();
759
+ const subj = subjectInput.value.trim();
760
+ const desc = descInput.value.trim();
761
+ if (!subj) {
762
+ subjectInput.focus();
763
+ return;
764
+ }
765
+ if (!desc) {
766
+ descInput.focus();
767
+ return;
768
+ }
499
769
  card.remove();
500
- void this.submitTicket(subj, desc, requestId);
770
+ void this.submitTicket(subj, desc, requestId, answer.primaryReason);
501
771
  });
502
772
  this.body.appendChild(card);
503
773
  this.body.scrollTop = this.body.scrollHeight;
774
+ subjectInput.focus();
504
775
  }
505
- async submitTicket(subject, description, requestId) {
776
+ async submitTicket(subject, description, requestId, reason) {
506
777
  this.setProgress("Sending your support ticket\u2026");
778
+ const epoch = this.epoch;
507
779
  try {
508
780
  const sessionId = await this.ensureSession();
509
- const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${sessionId}/support-ticket`, {
781
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/support-ticket`, {
510
782
  method: "POST",
511
783
  headers: this.headers(),
512
784
  body: JSON.stringify({
513
785
  tenantUniqueName: this.config.tenantUniqueName,
514
- environment: this.config.environment,
515
786
  requestId,
516
787
  subject,
517
- description
788
+ description,
789
+ reason,
790
+ question: this.lastQuestion || void 0
518
791
  })
519
792
  });
793
+ if (epoch !== this.epoch) return;
520
794
  this.setProgress("");
521
- if (res.status === 403) {
522
- this.renderAnswer({ status: "Forbidden", primaryReason: "Support tickets are available for Requestor/Approver roles." });
795
+ if (isSessionInvalidStatus(res.status)) {
796
+ if (await this.renderIfFeatureDisabled(res)) return;
797
+ this.handleInvalidSession();
523
798
  return;
524
799
  }
525
800
  if (!res.ok) {
@@ -527,11 +802,16 @@ ${reason}`;
527
802
  return;
528
803
  }
529
804
  const data = await res.json();
805
+ if (epoch !== this.epoch) return;
530
806
  if (data?.status === "Created") {
531
- const num = data.ticketNumber ? `#${data.ticketNumber}` : "";
532
807
  const el = document.createElement("div");
533
808
  el.className = "acw-answer acw-okstate";
534
- el.innerHTML = `<div class="acw-reason">Support ticket ${escapeHtml(num)} has been created. The support team will follow up.</div>`;
809
+ const msg = document.createElement("div");
810
+ msg.className = "acw-reason";
811
+ msg.textContent = data.message?.trim() || (data.ticketNumber ? `Your support ticket ${data.ticketNumber} has been created.` : "Your support ticket has been created.");
812
+ el.appendChild(msg);
813
+ const actions = document.createElement("div");
814
+ actions.className = "acw-offer-actions";
535
815
  if (data.webUrl || data.id || this.config.onOpenTicket) {
536
816
  const open = document.createElement("button");
537
817
  open.type = "button";
@@ -540,19 +820,24 @@ ${reason}`;
540
820
  open.addEventListener("click", () => {
541
821
  const ticket = { id: data.id, number: data.ticketNumber, webUrl: data.webUrl };
542
822
  if (this.config.onOpenTicket) {
823
+ this.close();
543
824
  this.config.onOpenTicket(ticket);
544
- } else if (data.webUrl) {
545
- window.open(data.webUrl, "_blank", "noopener");
825
+ } else {
826
+ const href = safeHref(data.webUrl);
827
+ if (href) window.open(href, "_blank", "noopener,noreferrer");
546
828
  }
547
829
  });
548
- el.appendChild(open);
830
+ actions.appendChild(open);
549
831
  }
832
+ this.appendOpenSupport(actions);
833
+ if (actions.childElementCount > 0) el.appendChild(actions);
550
834
  this.body.appendChild(el);
551
835
  this.body.scrollTop = this.body.scrollHeight;
552
836
  } else {
553
837
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not create the ticket." });
554
838
  }
555
839
  } catch {
840
+ if (epoch !== this.epoch) return;
556
841
  this.setProgress("");
557
842
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
558
843
  }
@@ -561,21 +846,62 @@ ${reason}`;
561
846
  headers() {
562
847
  const h = { "Content-Type": "application/json" };
563
848
  const token = this.config.getAuthToken?.();
564
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
565
849
  if (token) h["Authorization"] = `Bearer ${token}`;
566
- else if (email) h["X-User-Email"] = email;
567
850
  return h;
568
851
  }
852
+ /** Reset the session when the tenant or the token's user no longer match the ones it was opened for. */
853
+ syncSessionScope() {
854
+ if (!this.config || !this.sessionId) return;
855
+ const tenantChanged = this.sessionTenant !== this.config.tenantUniqueName;
856
+ const identity = tokenIdentity(this.config.getAuthToken?.());
857
+ const userChanged = !!identity && !!this.sessionIdentity && identity !== this.sessionIdentity;
858
+ if (tenantChanged || userChanged) this.resetSession();
859
+ }
860
+ /**
861
+ * B5 contract check (R1): a 403 {status:"FeatureDisabled"} means a capability is switched off, not that the session is
862
+ * invalid - keep the session and show the server's reason instead of resetting.
863
+ */
864
+ async renderIfFeatureDisabled(res) {
865
+ if (res.status !== 403) return false;
866
+ try {
867
+ const body = await res.clone().json();
868
+ if (body?.status !== "FeatureDisabled") return false;
869
+ this.setProgress("");
870
+ this.renderAnswer({ status: "FeatureDisabled", primaryReason: typeof body.reason === "string" ? body.reason : "That part of the Copilot is not enabled yet." });
871
+ return true;
872
+ } catch {
873
+ return false;
874
+ }
875
+ }
876
+ /** 403/404 on a session-scoped call: drop the session and tell the user to ask again. */
877
+ handleInvalidSession() {
878
+ this.setProgress("");
879
+ this.resetSession();
880
+ if (!this.body) return;
881
+ const el = document.createElement("div");
882
+ el.className = "acw-answer";
883
+ el.innerHTML = `<div class="acw-error">${escapeHtml(SESSION_RESET_TEXT)}</div>`;
884
+ this.body.appendChild(el);
885
+ this.body.scrollTop = this.body.scrollHeight;
886
+ }
569
887
  async ensureSession() {
888
+ this.syncSessionScope();
570
889
  if (this.sessionId) return this.sessionId;
890
+ const tenant = this.config.tenantUniqueName;
891
+ const identity = tokenIdentity(this.config.getAuthToken?.());
892
+ const epoch = this.epoch;
571
893
  const res = await fetch(`${this.config.apiBaseUrl}/api/sessions`, {
572
894
  method: "POST",
573
895
  headers: this.headers(),
574
- body: JSON.stringify({ projectKey: this.config.projectKey, environment: this.config.environment })
896
+ // The session is bound to the signed-in user and this tenant (verified server-side).
897
+ body: JSON.stringify({ projectKey: this.config.projectKey, tenantUniqueName: tenant })
575
898
  });
576
899
  if (!res.ok) throw new Error(`Session creation failed (${res.status})`);
577
900
  const session = await res.json();
901
+ if (epoch !== this.epoch) throw new Error("The Copilot session was reset.");
578
902
  this.sessionId = session.sessionId;
903
+ this.sessionTenant = tenant;
904
+ this.sessionIdentity = identity;
579
905
  if (this.connection && this.sessionId) {
580
906
  try {
581
907
  await this.connection.invoke("JoinSession", this.sessionId);
@@ -584,25 +910,37 @@ ${reason}`;
584
910
  }
585
911
  return this.sessionId;
586
912
  }
587
- async runAsk(question) {
913
+ async runAsk(question, notHelpful = false) {
588
914
  this.open();
589
915
  this.lastQuestion = question;
590
- this.addMessage(question, "user");
916
+ this.addMessage(notHelpful ? "This didn\u2019t help." : question, "user");
591
917
  this.setProgress("Reading your question\u2026");
918
+ const epoch = this.epoch;
592
919
  try {
593
920
  const sessionId = await this.ensureSession();
594
- const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${sessionId}/messages`, {
921
+ const body = { question, tenantUniqueName: this.config.tenantUniqueName, context: this.currentContext() };
922
+ if (notHelpful) body.notHelpful = true;
923
+ const res = await fetch(`${this.config.apiBaseUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, {
595
924
  method: "POST",
596
925
  headers: this.headers(),
597
- body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName, context: this.currentContext() })
926
+ body: JSON.stringify(body)
598
927
  });
928
+ if (epoch !== this.epoch) return;
929
+ if (isSessionInvalidStatus(res.status)) {
930
+ if (await this.renderIfFeatureDisabled(res)) return;
931
+ this.handleInvalidSession();
932
+ return;
933
+ }
599
934
  if (!res.ok) {
600
935
  this.setProgress("");
601
936
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
602
937
  return;
603
938
  }
604
- this.renderAnswer(await res.json());
939
+ const answer = await res.json();
940
+ if (epoch !== this.epoch) return;
941
+ this.renderAnswer(answer, question, notHelpful);
605
942
  } catch (err) {
943
+ if (epoch !== this.epoch) return;
606
944
  this.setProgress("");
607
945
  const msg = err?.message ? String(err.message) : "Could not reach the copilot service.";
608
946
  this.renderAnswer({ status: "RequestFailed", primaryReason: msg });
@@ -611,7 +949,7 @@ ${reason}`;
611
949
  async connectSignalR() {
612
950
  if (!this.config.signalRHubUrl) return;
613
951
  const signalR = await import("@microsoft/signalr");
614
- const connection = new signalR.HubConnectionBuilder().withUrl(this.config.signalRHubUrl).withAutomaticReconnect().build();
952
+ const connection = new signalR.HubConnectionBuilder().withUrl(this.config.signalRHubUrl, { accessTokenFactory: () => this.config.getAuthToken?.() ?? "" }).withAutomaticReconnect().build();
615
953
  for (const event of Object.keys(PROGRESS_LABELS)) {
616
954
  connection.on(event, () => {
617
955
  if (event !== "AnswerCompleted") this.setProgress(PROGRESS_LABELS[event]);
@@ -621,9 +959,6 @@ ${reason}`;
621
959
  this.connection = connection;
622
960
  }
623
961
  };
624
- function escapeHtml(s) {
625
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
626
- }
627
962
 
628
963
  // src/index.ts
629
964
  var instance = new CopilotWidget();
@@ -637,7 +972,9 @@ var ApplicationCopilotWidget = {
637
972
  promptUpload: () => instance.promptUpload(),
638
973
  ask: (question) => instance.ask(question),
639
974
  setContext: (context) => instance.setContext(context),
640
- captureError: (errorContext) => instance.captureError(errorContext)
975
+ captureError: (errorContext) => instance.captureError(errorContext),
976
+ /** Drop the current Copilot session and conversation (e.g. after a tenant switch or sign-out). */
977
+ resetSession: () => instance.resetSession()
641
978
  };
642
979
  if (typeof window !== "undefined") {
643
980
  window.ApplicationCopilotWidget = ApplicationCopilotWidget;