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