@spektra-cloudevents/application-copilot-widget 1.1.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
@@ -77,7 +77,7 @@ var widgetStyles = `
77
77
  /* Panel */
78
78
  .acw-panel {
79
79
  position: fixed; right: 24px; bottom: 96px; z-index: 2147483000;
80
- width: 410px; max-width: calc(100vw - 48px); height: 580px; max-height: calc(100vh - 130px);
80
+ width: 480px; max-width: calc(100vw - 48px); height: 660px; max-height: calc(100vh - 120px);
81
81
  display: none; flex-direction: column;
82
82
  background: var(--acw-bg); color: var(--acw-fg);
83
83
  border: 1px solid var(--acw-border); border-radius: 16px; overflow: hidden;
@@ -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,28 +116,140 @@ 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
  }
135
153
  .acw-input:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
136
154
  .acw-send { border: none; border-radius: 10px; padding: 0 16px; background: var(--acw-accent); color: #fff; cursor: pointer; font-size: 14px; font-family: inherit; }
137
155
  .acw-send:hover { background: var(--acw-accent-dark); }
156
+
157
+ /* Support-ticket draft card */
158
+ .acw-ticket { background: var(--acw-bg); border: 1px solid var(--acw-border); border-radius: 12px; padding: 14px; margin: 12px 0; border-left: 4px solid var(--acw-accent); }
159
+ .acw-ticket-title { font-size: 15px; font-weight: 600; color: var(--acw-fg); margin: 0 0 4px; }
160
+ .acw-ticket-sub { font-size: 12px; color: var(--acw-muted); margin: 0 0 10px; }
161
+ .acw-ticket-label { display: block; font-size: 12px; font-weight: 600; color: var(--acw-muted); margin: 12px 0 5px; }
162
+ .acw-ticket-input, .acw-ticket-desc {
163
+ width: 100%; box-sizing: border-box; font-family: inherit; font-size: 13px;
164
+ padding: 10px 12px; border: 1px solid var(--acw-border); border-radius: 10px;
165
+ color: var(--acw-fg); background: var(--acw-bg);
166
+ }
167
+ .acw-ticket-desc { resize: vertical; min-height: 170px; line-height: 1.5; }
168
+ .acw-ticket-input:focus, .acw-ticket-desc:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
169
+ .acw-ticket-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
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; }
171
+ .acw-btn-primary:hover { background: var(--acw-accent-dark); }
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; }
173
+ .acw-btn-ghost:hover { background: #f0eefe; }
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; }
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
+ }
138
193
  `;
139
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
+
140
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.";
141
253
  var PROGRESS_LABELS = {
142
254
  QuestionAccepted: "Reading your question\u2026",
143
255
  PermissionsVerified: "Verifying your permissions\u2026",
@@ -148,18 +260,50 @@ var CopilotWidget = class {
148
260
  constructor() {
149
261
  this.lastQuestion = "";
150
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;
151
266
  }
152
267
  init(config) {
268
+ const previousTenant = this.config?.tenantUniqueName;
153
269
  this.config = config;
154
270
  try {
155
271
  console.log("[ApplicationCopilot] widget loaded \u2014 support-ticket build v2");
156
272
  } catch {
157
273
  }
274
+ if (previousTenant !== void 0 && previousTenant !== config.tenantUniqueName) this.resetSession();
158
275
  this.mount();
159
- this.connectSignalR().catch(() => {
160
- });
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
+ }
161
304
  }
162
305
  open() {
306
+ this.syncSessionScope();
163
307
  this.panel?.classList.add("acw-open");
164
308
  this.refreshChips();
165
309
  }
@@ -204,6 +348,7 @@ var CopilotWidget = class {
204
348
  this.body.scrollTop = this.body.scrollHeight;
205
349
  }
206
350
  ask(question) {
351
+ this.syncSessionScope();
207
352
  if (this.mode === "intake") {
208
353
  void this.runIntakeAsk(question);
209
354
  } else {
@@ -221,7 +366,7 @@ var CopilotWidget = class {
221
366
  style.textContent = widgetStyles;
222
367
  this.root.appendChild(style);
223
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>';
224
- const tip = this.config.buttonText ?? "Ask Application Copilot";
369
+ const tip = escapeHtml(this.config.buttonText ?? "Ask Application Copilot");
225
370
  const wrap = document.createElement("div");
226
371
  wrap.className = "acw-launch-wrap";
227
372
  wrap.innerHTML = `<span class="acw-tip">${tip}</span><button class="acw-launch" type="button" aria-label="${tip}">${sparkle}</button>`;
@@ -234,7 +379,7 @@ var CopilotWidget = class {
234
379
  <div class="acw-header">
235
380
  ${sparkle}
236
381
  <span class="acw-title">Application Copilot</span>
237
- <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>
238
383
  <button class="acw-close" type="button" aria-label="Close">&times;</button>
239
384
  </div>
240
385
  <div class="acw-body"></div>
@@ -262,10 +407,21 @@ var CopilotWidget = class {
262
407
  if (e.key === "Enter") this.submitFromInput();
263
408
  });
264
409
  }
265
- /** 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
+ */
266
414
  promptUpload() {
267
415
  this.open();
268
- 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";
269
425
  }
270
426
  async uploadDocument(file) {
271
427
  this.open();
@@ -275,16 +431,14 @@ var CopilotWidget = class {
275
431
  const form = new FormData();
276
432
  form.append("file", file, file.name);
277
433
  form.append("tenantUniqueName", this.config.tenantUniqueName);
278
- form.append("environment", this.config.environment);
279
434
  const h = {};
280
435
  const token = this.config.getAuthToken?.();
281
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
282
436
  if (token) h["Authorization"] = `Bearer ${token}`;
283
- else if (email) h["X-User-Email"] = email;
284
437
  const res = await fetch(`${this.config.apiBaseUrl}/api/knowledge/upload`, { method: "POST", headers: h, body: form });
285
438
  this.setProgress("");
286
439
  if (res.status === 403) {
287
- 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." });
288
442
  return;
289
443
  }
290
444
  if (!res.ok) {
@@ -293,7 +447,7 @@ var CopilotWidget = class {
293
447
  }
294
448
  const data = await res.json();
295
449
  if (data?.status === "Indexed") {
296
- 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.` });
297
451
  } else {
298
452
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not add the document." });
299
453
  }
@@ -342,20 +496,30 @@ var CopilotWidget = class {
342
496
  this.open();
343
497
  this.addMessage(question, "user");
344
498
  this.setProgress("Finding the right offering\u2026");
499
+ const epoch = this.epoch;
345
500
  try {
346
501
  const sessionId = await this.ensureSession();
347
- 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`, {
348
503
  method: "POST",
349
504
  headers: this.headers(),
350
505
  body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName })
351
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
+ }
352
513
  if (!res.ok) {
353
514
  this.setProgress("");
354
515
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
355
516
  return;
356
517
  }
357
- this.renderIntakeAnswer(await res.json());
518
+ const data = await res.json();
519
+ if (epoch !== this.epoch) return;
520
+ this.renderIntakeAnswer(data);
358
521
  } catch {
522
+ if (epoch !== this.epoch) return;
359
523
  this.setProgress("");
360
524
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
361
525
  }
@@ -414,10 +578,10 @@ var CopilotWidget = class {
414
578
  p.textContent = text;
415
579
  this.body.scrollTop = this.body.scrollHeight;
416
580
  }
417
- renderAnswer(answer) {
581
+ renderAnswer(answer, question, wasNotHelpful = false) {
418
582
  this.setProgress("");
419
583
  const el = document.createElement("div");
420
- const isFailure = answer.status && answer.status !== "Answered";
584
+ const isFailure = !!answer.status && answer.status !== "Answered";
421
585
  if (isFailure) {
422
586
  el.className = "acw-answer";
423
587
  el.innerHTML = `<div class="acw-error">${escapeHtml(answer.primaryReason || answer.status)}</div>`;
@@ -427,32 +591,148 @@ var CopilotWidget = class {
427
591
  const sourceLabel = answer.source === "code" ? "App rules" : answer.source === "database" ? "Live data" : answer.source === "policy" ? "SharePoint docs" : "";
428
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>` : "");
429
593
  }
594
+ this.appendReferences(el, answer);
430
595
  this.body.appendChild(el);
431
- const role = (answer.verifiedRoleLabel || "").toLowerCase();
432
- const eligibleRole = role === "requestor" || role === "approver";
433
- 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 || "");
434
- const offerTicket = answer.supportTicketEligible === true || (answer.disabled === true || looksBlocked) && eligibleRole;
435
- if (!isFailure && offerTicket) {
436
- const btn = document.createElement("button");
437
- btn.type = "button";
438
- btn.className = "acw-chip";
439
- btn.textContent = "Raise a support ticket";
440
- btn.addEventListener("click", () => this.openTicketDraft(answer));
441
- 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);
442
617
  }
443
618
  this.body.scrollTop = this.body.scrollHeight;
444
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
+ }
445
687
  currentRequestId() {
446
688
  const ctx = this.currentContext();
447
689
  const explicit = ctx.page?.requestId || ctx.page?.RequestId;
448
690
  if (explicit) return String(explicit);
449
691
  const route = ctx.page?.route || (typeof window !== "undefined" ? window.location.pathname : "");
450
- const m = /MS[A-Z0-9]{6,}/i.exec(route || "");
451
- return m ? m[0] : void 0;
692
+ const ms = /MS[A-Z0-9]{6,}/i.exec(route || "");
693
+ if (ms) return ms[0];
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 || "");
695
+ return guid ? guid[0] : void 0;
696
+ }
697
+ /** Fetch the server-built draft, then show it as an editable form; the user triggers Send. */
698
+ async openTicketDraft(answer) {
699
+ const fromAnswer = /MS[A-Z0-9]{6,}/i.exec(answer.primaryReason || "");
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
+ );
452
733
  }
453
- /** Build a pre-filled, editable ticket draft and let the user review + Send (they trigger the API call). */
454
- openTicketDraft(answer) {
455
- const requestId = this.currentRequestId();
734
+ /** Used only when the draft endpoint is unavailable (older server or a transient error). */
735
+ localDraft(answer, requestId) {
456
736
  const role = answer.verifiedRoleLabel || "";
457
737
  const reason = answer.primaryReason || "I am blocked from completing an action.";
458
738
  const subject = requestId ? `[Cloud Events] Help needed on request ${requestId}` : "[Cloud Events] Help needed with an event request";
@@ -464,41 +744,57 @@ var CopilotWidget = class {
464
744
  ` : "") + `
465
745
  What the copilot reported:
466
746
  ${reason}`;
747
+ return { subject, description };
748
+ }
749
+ showTicketForm(subject, description, requestId, answer) {
467
750
  const card = document.createElement("div");
468
- card.className = "acw-answer";
469
- const lbl = "display:block;font-size:11px;color:var(--acw-muted);margin:8px 0 2px;";
470
- const fld = "width:100%;box-sizing:border-box;font:inherit;font-size:13px;padding:6px 8px;border:1px solid var(--acw-border);border-radius:8px;background:var(--acw-bg);color:var(--acw-fg);";
471
- card.innerHTML = `<div class="acw-reason"><strong>Review your support ticket</strong></div><label style="${lbl}">Subject</label><input class="acw-ticket-subject" type="text" style="${fld}" /><label style="${lbl}">Description</label><textarea class="acw-ticket-desc" rows="6" style="${fld}resize:vertical;"></textarea><div style="display:flex;gap:8px;margin-top:10px;"><button class="acw-ticket-send" type="button" style="border:none;border-radius:8px;padding:6px 14px;cursor:pointer;background:var(--acw-accent);color:var(--acw-accent-fg);font-size:13px;">Send ticket</button><button class="acw-ticket-cancel" type="button" style="border:1px solid var(--acw-border);border-radius:8px;padding:6px 12px;cursor:pointer;background:var(--acw-bg);color:var(--acw-fg);font-size:13px;">Cancel</button></div>`;
472
- card.querySelector(".acw-ticket-subject").value = subject;
473
- card.querySelector(".acw-ticket-desc").value = description;
751
+ card.className = "acw-ticket";
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;
474
757
  card.querySelector(".acw-ticket-cancel").addEventListener("click", () => card.remove());
475
758
  card.querySelector(".acw-ticket-send").addEventListener("click", () => {
476
- const subj = card.querySelector(".acw-ticket-subject").value.trim();
477
- 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
+ }
478
769
  card.remove();
479
- void this.submitTicket(subj, desc, requestId);
770
+ void this.submitTicket(subj, desc, requestId, answer.primaryReason);
480
771
  });
481
772
  this.body.appendChild(card);
482
773
  this.body.scrollTop = this.body.scrollHeight;
774
+ subjectInput.focus();
483
775
  }
484
- async submitTicket(subject, description, requestId) {
776
+ async submitTicket(subject, description, requestId, reason) {
485
777
  this.setProgress("Sending your support ticket\u2026");
778
+ const epoch = this.epoch;
486
779
  try {
487
780
  const sessionId = await this.ensureSession();
488
- 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`, {
489
782
  method: "POST",
490
783
  headers: this.headers(),
491
784
  body: JSON.stringify({
492
785
  tenantUniqueName: this.config.tenantUniqueName,
493
- environment: this.config.environment,
494
786
  requestId,
495
787
  subject,
496
- description
788
+ description,
789
+ reason,
790
+ question: this.lastQuestion || void 0
497
791
  })
498
792
  });
793
+ if (epoch !== this.epoch) return;
499
794
  this.setProgress("");
500
- if (res.status === 403) {
501
- 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();
502
798
  return;
503
799
  }
504
800
  if (!res.ok) {
@@ -506,17 +802,42 @@ ${reason}`;
506
802
  return;
507
803
  }
508
804
  const data = await res.json();
805
+ if (epoch !== this.epoch) return;
509
806
  if (data?.status === "Created") {
510
- const num = data.ticketNumber ? `#${data.ticketNumber}` : "";
511
807
  const el = document.createElement("div");
512
808
  el.className = "acw-answer acw-okstate";
513
- el.innerHTML = `<div class="acw-reason">Support ticket ${escapeHtml(num)} has been created. The support team will follow up.</div>` + (data.webUrl ? `<div class="acw-role"><a href="${escapeHtml(data.webUrl)}" target="_blank" rel="noopener">Open the ticket</a></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";
815
+ if (data.webUrl || data.id || this.config.onOpenTicket) {
816
+ const open = document.createElement("button");
817
+ open.type = "button";
818
+ open.className = "acw-link-btn";
819
+ open.textContent = "Open the ticket";
820
+ open.addEventListener("click", () => {
821
+ const ticket = { id: data.id, number: data.ticketNumber, webUrl: data.webUrl };
822
+ if (this.config.onOpenTicket) {
823
+ this.close();
824
+ this.config.onOpenTicket(ticket);
825
+ } else {
826
+ const href = safeHref(data.webUrl);
827
+ if (href) window.open(href, "_blank", "noopener,noreferrer");
828
+ }
829
+ });
830
+ actions.appendChild(open);
831
+ }
832
+ this.appendOpenSupport(actions);
833
+ if (actions.childElementCount > 0) el.appendChild(actions);
514
834
  this.body.appendChild(el);
515
835
  this.body.scrollTop = this.body.scrollHeight;
516
836
  } else {
517
837
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not create the ticket." });
518
838
  }
519
839
  } catch {
840
+ if (epoch !== this.epoch) return;
520
841
  this.setProgress("");
521
842
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
522
843
  }
@@ -525,21 +846,62 @@ ${reason}`;
525
846
  headers() {
526
847
  const h = { "Content-Type": "application/json" };
527
848
  const token = this.config.getAuthToken?.();
528
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
529
849
  if (token) h["Authorization"] = `Bearer ${token}`;
530
- else if (email) h["X-User-Email"] = email;
531
850
  return h;
532
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
+ }
533
887
  async ensureSession() {
888
+ this.syncSessionScope();
534
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;
535
893
  const res = await fetch(`${this.config.apiBaseUrl}/api/sessions`, {
536
894
  method: "POST",
537
895
  headers: this.headers(),
538
- 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 })
539
898
  });
540
899
  if (!res.ok) throw new Error(`Session creation failed (${res.status})`);
541
900
  const session = await res.json();
901
+ if (epoch !== this.epoch) throw new Error("The Copilot session was reset.");
542
902
  this.sessionId = session.sessionId;
903
+ this.sessionTenant = tenant;
904
+ this.sessionIdentity = identity;
543
905
  if (this.connection && this.sessionId) {
544
906
  try {
545
907
  await this.connection.invoke("JoinSession", this.sessionId);
@@ -548,25 +910,37 @@ ${reason}`;
548
910
  }
549
911
  return this.sessionId;
550
912
  }
551
- async runAsk(question) {
913
+ async runAsk(question, notHelpful = false) {
552
914
  this.open();
553
915
  this.lastQuestion = question;
554
- this.addMessage(question, "user");
916
+ this.addMessage(notHelpful ? "This didn\u2019t help." : question, "user");
555
917
  this.setProgress("Reading your question\u2026");
918
+ const epoch = this.epoch;
556
919
  try {
557
920
  const sessionId = await this.ensureSession();
558
- 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`, {
559
924
  method: "POST",
560
925
  headers: this.headers(),
561
- body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName, context: this.currentContext() })
926
+ body: JSON.stringify(body)
562
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
+ }
563
934
  if (!res.ok) {
564
935
  this.setProgress("");
565
936
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
566
937
  return;
567
938
  }
568
- this.renderAnswer(await res.json());
939
+ const answer = await res.json();
940
+ if (epoch !== this.epoch) return;
941
+ this.renderAnswer(answer, question, notHelpful);
569
942
  } catch (err) {
943
+ if (epoch !== this.epoch) return;
570
944
  this.setProgress("");
571
945
  const msg = err?.message ? String(err.message) : "Could not reach the copilot service.";
572
946
  this.renderAnswer({ status: "RequestFailed", primaryReason: msg });
@@ -575,7 +949,7 @@ ${reason}`;
575
949
  async connectSignalR() {
576
950
  if (!this.config.signalRHubUrl) return;
577
951
  const signalR = await import("@microsoft/signalr");
578
- 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();
579
953
  for (const event of Object.keys(PROGRESS_LABELS)) {
580
954
  connection.on(event, () => {
581
955
  if (event !== "AnswerCompleted") this.setProgress(PROGRESS_LABELS[event]);
@@ -585,9 +959,6 @@ ${reason}`;
585
959
  this.connection = connection;
586
960
  }
587
961
  };
588
- function escapeHtml(s) {
589
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
590
- }
591
962
 
592
963
  // src/index.ts
593
964
  var instance = new CopilotWidget();
@@ -601,7 +972,9 @@ var ApplicationCopilotWidget = {
601
972
  promptUpload: () => instance.promptUpload(),
602
973
  ask: (question) => instance.ask(question),
603
974
  setContext: (context) => instance.setContext(context),
604
- 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()
605
978
  };
606
979
  if (typeof window !== "undefined") {
607
980
  window.ApplicationCopilotWidget = ApplicationCopilotWidget;