@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.js CHANGED
@@ -40,7 +40,7 @@ var widgetStyles = `
40
40
  /* Panel */
41
41
  .acw-panel {
42
42
  position: fixed; right: 24px; bottom: 96px; z-index: 2147483000;
43
- width: 410px; max-width: calc(100vw - 48px); height: 580px; max-height: calc(100vh - 130px);
43
+ width: 480px; max-width: calc(100vw - 48px); height: 660px; max-height: calc(100vh - 120px);
44
44
  display: none; flex-direction: column;
45
45
  background: var(--acw-bg); color: var(--acw-fg);
46
46
  border: 1px solid var(--acw-border); border-radius: 16px; overflow: hidden;
@@ -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,28 +79,140 @@ 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
  }
98
116
  .acw-input:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
99
117
  .acw-send { border: none; border-radius: 10px; padding: 0 16px; background: var(--acw-accent); color: #fff; cursor: pointer; font-size: 14px; font-family: inherit; }
100
118
  .acw-send:hover { background: var(--acw-accent-dark); }
119
+
120
+ /* Support-ticket draft card */
121
+ .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); }
122
+ .acw-ticket-title { font-size: 15px; font-weight: 600; color: var(--acw-fg); margin: 0 0 4px; }
123
+ .acw-ticket-sub { font-size: 12px; color: var(--acw-muted); margin: 0 0 10px; }
124
+ .acw-ticket-label { display: block; font-size: 12px; font-weight: 600; color: var(--acw-muted); margin: 12px 0 5px; }
125
+ .acw-ticket-input, .acw-ticket-desc {
126
+ width: 100%; box-sizing: border-box; font-family: inherit; font-size: 13px;
127
+ padding: 10px 12px; border: 1px solid var(--acw-border); border-radius: 10px;
128
+ color: var(--acw-fg); background: var(--acw-bg);
129
+ }
130
+ .acw-ticket-desc { resize: vertical; min-height: 170px; line-height: 1.5; }
131
+ .acw-ticket-input:focus, .acw-ticket-desc:focus { outline: 2px solid var(--acw-accent); outline-offset: 0; }
132
+ .acw-ticket-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
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; }
134
+ .acw-btn-primary:hover { background: var(--acw-accent-dark); }
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; }
136
+ .acw-btn-ghost:hover { background: #f0eefe; }
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; }
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
+ }
101
156
  `;
102
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
+
103
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.";
104
216
  var PROGRESS_LABELS = {
105
217
  QuestionAccepted: "Reading your question\u2026",
106
218
  PermissionsVerified: "Verifying your permissions\u2026",
@@ -111,18 +223,50 @@ var CopilotWidget = class {
111
223
  constructor() {
112
224
  this.lastQuestion = "";
113
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;
114
229
  }
115
230
  init(config) {
231
+ const previousTenant = this.config?.tenantUniqueName;
116
232
  this.config = config;
117
233
  try {
118
234
  console.log("[ApplicationCopilot] widget loaded \u2014 support-ticket build v2");
119
235
  } catch {
120
236
  }
237
+ if (previousTenant !== void 0 && previousTenant !== config.tenantUniqueName) this.resetSession();
121
238
  this.mount();
122
- this.connectSignalR().catch(() => {
123
- });
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
+ }
124
267
  }
125
268
  open() {
269
+ this.syncSessionScope();
126
270
  this.panel?.classList.add("acw-open");
127
271
  this.refreshChips();
128
272
  }
@@ -167,6 +311,7 @@ var CopilotWidget = class {
167
311
  this.body.scrollTop = this.body.scrollHeight;
168
312
  }
169
313
  ask(question) {
314
+ this.syncSessionScope();
170
315
  if (this.mode === "intake") {
171
316
  void this.runIntakeAsk(question);
172
317
  } else {
@@ -184,7 +329,7 @@ var CopilotWidget = class {
184
329
  style.textContent = widgetStyles;
185
330
  this.root.appendChild(style);
186
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>';
187
- const tip = this.config.buttonText ?? "Ask Application Copilot";
332
+ const tip = escapeHtml(this.config.buttonText ?? "Ask Application Copilot");
188
333
  const wrap = document.createElement("div");
189
334
  wrap.className = "acw-launch-wrap";
190
335
  wrap.innerHTML = `<span class="acw-tip">${tip}</span><button class="acw-launch" type="button" aria-label="${tip}">${sparkle}</button>`;
@@ -197,7 +342,7 @@ var CopilotWidget = class {
197
342
  <div class="acw-header">
198
343
  ${sparkle}
199
344
  <span class="acw-title">Application Copilot</span>
200
- <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>
201
346
  <button class="acw-close" type="button" aria-label="Close">&times;</button>
202
347
  </div>
203
348
  <div class="acw-body"></div>
@@ -225,10 +370,21 @@ var CopilotWidget = class {
225
370
  if (e.key === "Enter") this.submitFromInput();
226
371
  });
227
372
  }
228
- /** 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
+ */
229
377
  promptUpload() {
230
378
  this.open();
231
- 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";
232
388
  }
233
389
  async uploadDocument(file) {
234
390
  this.open();
@@ -238,16 +394,14 @@ var CopilotWidget = class {
238
394
  const form = new FormData();
239
395
  form.append("file", file, file.name);
240
396
  form.append("tenantUniqueName", this.config.tenantUniqueName);
241
- form.append("environment", this.config.environment);
242
397
  const h = {};
243
398
  const token = this.config.getAuthToken?.();
244
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
245
399
  if (token) h["Authorization"] = `Bearer ${token}`;
246
- else if (email) h["X-User-Email"] = email;
247
400
  const res = await fetch(`${this.config.apiBaseUrl}/api/knowledge/upload`, { method: "POST", headers: h, body: form });
248
401
  this.setProgress("");
249
402
  if (res.status === 403) {
250
- 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." });
251
405
  return;
252
406
  }
253
407
  if (!res.ok) {
@@ -256,7 +410,7 @@ var CopilotWidget = class {
256
410
  }
257
411
  const data = await res.json();
258
412
  if (data?.status === "Indexed") {
259
- 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.` });
260
414
  } else {
261
415
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not add the document." });
262
416
  }
@@ -305,20 +459,30 @@ var CopilotWidget = class {
305
459
  this.open();
306
460
  this.addMessage(question, "user");
307
461
  this.setProgress("Finding the right offering\u2026");
462
+ const epoch = this.epoch;
308
463
  try {
309
464
  const sessionId = await this.ensureSession();
310
- 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`, {
311
466
  method: "POST",
312
467
  headers: this.headers(),
313
468
  body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName })
314
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
+ }
315
476
  if (!res.ok) {
316
477
  this.setProgress("");
317
478
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
318
479
  return;
319
480
  }
320
- this.renderIntakeAnswer(await res.json());
481
+ const data = await res.json();
482
+ if (epoch !== this.epoch) return;
483
+ this.renderIntakeAnswer(data);
321
484
  } catch {
485
+ if (epoch !== this.epoch) return;
322
486
  this.setProgress("");
323
487
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
324
488
  }
@@ -377,10 +541,10 @@ var CopilotWidget = class {
377
541
  p.textContent = text;
378
542
  this.body.scrollTop = this.body.scrollHeight;
379
543
  }
380
- renderAnswer(answer) {
544
+ renderAnswer(answer, question, wasNotHelpful = false) {
381
545
  this.setProgress("");
382
546
  const el = document.createElement("div");
383
- const isFailure = answer.status && answer.status !== "Answered";
547
+ const isFailure = !!answer.status && answer.status !== "Answered";
384
548
  if (isFailure) {
385
549
  el.className = "acw-answer";
386
550
  el.innerHTML = `<div class="acw-error">${escapeHtml(answer.primaryReason || answer.status)}</div>`;
@@ -390,32 +554,148 @@ var CopilotWidget = class {
390
554
  const sourceLabel = answer.source === "code" ? "App rules" : answer.source === "database" ? "Live data" : answer.source === "policy" ? "SharePoint docs" : "";
391
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>` : "");
392
556
  }
557
+ this.appendReferences(el, answer);
393
558
  this.body.appendChild(el);
394
- const role = (answer.verifiedRoleLabel || "").toLowerCase();
395
- const eligibleRole = role === "requestor" || role === "approver";
396
- 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 || "");
397
- const offerTicket = answer.supportTicketEligible === true || (answer.disabled === true || looksBlocked) && eligibleRole;
398
- if (!isFailure && offerTicket) {
399
- const btn = document.createElement("button");
400
- btn.type = "button";
401
- btn.className = "acw-chip";
402
- btn.textContent = "Raise a support ticket";
403
- btn.addEventListener("click", () => this.openTicketDraft(answer));
404
- 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);
405
580
  }
406
581
  this.body.scrollTop = this.body.scrollHeight;
407
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
+ }
408
650
  currentRequestId() {
409
651
  const ctx = this.currentContext();
410
652
  const explicit = ctx.page?.requestId || ctx.page?.RequestId;
411
653
  if (explicit) return String(explicit);
412
654
  const route = ctx.page?.route || (typeof window !== "undefined" ? window.location.pathname : "");
413
- const m = /MS[A-Z0-9]{6,}/i.exec(route || "");
414
- return m ? m[0] : void 0;
655
+ const ms = /MS[A-Z0-9]{6,}/i.exec(route || "");
656
+ if (ms) return ms[0];
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 || "");
658
+ return guid ? guid[0] : void 0;
659
+ }
660
+ /** Fetch the server-built draft, then show it as an editable form; the user triggers Send. */
661
+ async openTicketDraft(answer) {
662
+ const fromAnswer = /MS[A-Z0-9]{6,}/i.exec(answer.primaryReason || "");
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
+ );
415
696
  }
416
- /** Build a pre-filled, editable ticket draft and let the user review + Send (they trigger the API call). */
417
- openTicketDraft(answer) {
418
- const requestId = this.currentRequestId();
697
+ /** Used only when the draft endpoint is unavailable (older server or a transient error). */
698
+ localDraft(answer, requestId) {
419
699
  const role = answer.verifiedRoleLabel || "";
420
700
  const reason = answer.primaryReason || "I am blocked from completing an action.";
421
701
  const subject = requestId ? `[Cloud Events] Help needed on request ${requestId}` : "[Cloud Events] Help needed with an event request";
@@ -427,41 +707,57 @@ var CopilotWidget = class {
427
707
  ` : "") + `
428
708
  What the copilot reported:
429
709
  ${reason}`;
710
+ return { subject, description };
711
+ }
712
+ showTicketForm(subject, description, requestId, answer) {
430
713
  const card = document.createElement("div");
431
- card.className = "acw-answer";
432
- const lbl = "display:block;font-size:11px;color:var(--acw-muted);margin:8px 0 2px;";
433
- 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);";
434
- 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>`;
435
- card.querySelector(".acw-ticket-subject").value = subject;
436
- card.querySelector(".acw-ticket-desc").value = description;
714
+ card.className = "acw-ticket";
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;
437
720
  card.querySelector(".acw-ticket-cancel").addEventListener("click", () => card.remove());
438
721
  card.querySelector(".acw-ticket-send").addEventListener("click", () => {
439
- const subj = card.querySelector(".acw-ticket-subject").value.trim();
440
- 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
+ }
441
732
  card.remove();
442
- void this.submitTicket(subj, desc, requestId);
733
+ void this.submitTicket(subj, desc, requestId, answer.primaryReason);
443
734
  });
444
735
  this.body.appendChild(card);
445
736
  this.body.scrollTop = this.body.scrollHeight;
737
+ subjectInput.focus();
446
738
  }
447
- async submitTicket(subject, description, requestId) {
739
+ async submitTicket(subject, description, requestId, reason) {
448
740
  this.setProgress("Sending your support ticket\u2026");
741
+ const epoch = this.epoch;
449
742
  try {
450
743
  const sessionId = await this.ensureSession();
451
- 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`, {
452
745
  method: "POST",
453
746
  headers: this.headers(),
454
747
  body: JSON.stringify({
455
748
  tenantUniqueName: this.config.tenantUniqueName,
456
- environment: this.config.environment,
457
749
  requestId,
458
750
  subject,
459
- description
751
+ description,
752
+ reason,
753
+ question: this.lastQuestion || void 0
460
754
  })
461
755
  });
756
+ if (epoch !== this.epoch) return;
462
757
  this.setProgress("");
463
- if (res.status === 403) {
464
- 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();
465
761
  return;
466
762
  }
467
763
  if (!res.ok) {
@@ -469,17 +765,42 @@ ${reason}`;
469
765
  return;
470
766
  }
471
767
  const data = await res.json();
768
+ if (epoch !== this.epoch) return;
472
769
  if (data?.status === "Created") {
473
- const num = data.ticketNumber ? `#${data.ticketNumber}` : "";
474
770
  const el = document.createElement("div");
475
771
  el.className = "acw-answer acw-okstate";
476
- 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>` : "");
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";
778
+ if (data.webUrl || data.id || this.config.onOpenTicket) {
779
+ const open = document.createElement("button");
780
+ open.type = "button";
781
+ open.className = "acw-link-btn";
782
+ open.textContent = "Open the ticket";
783
+ open.addEventListener("click", () => {
784
+ const ticket = { id: data.id, number: data.ticketNumber, webUrl: data.webUrl };
785
+ if (this.config.onOpenTicket) {
786
+ this.close();
787
+ this.config.onOpenTicket(ticket);
788
+ } else {
789
+ const href = safeHref(data.webUrl);
790
+ if (href) window.open(href, "_blank", "noopener,noreferrer");
791
+ }
792
+ });
793
+ actions.appendChild(open);
794
+ }
795
+ this.appendOpenSupport(actions);
796
+ if (actions.childElementCount > 0) el.appendChild(actions);
477
797
  this.body.appendChild(el);
478
798
  this.body.scrollTop = this.body.scrollHeight;
479
799
  } else {
480
800
  this.renderAnswer({ status: "RequestFailed", primaryReason: data?.reason || "Could not create the ticket." });
481
801
  }
482
802
  } catch {
803
+ if (epoch !== this.epoch) return;
483
804
  this.setProgress("");
484
805
  this.renderAnswer({ status: "RequestFailed", primaryReason: "Could not reach the copilot service." });
485
806
  }
@@ -488,21 +809,62 @@ ${reason}`;
488
809
  headers() {
489
810
  const h = { "Content-Type": "application/json" };
490
811
  const token = this.config.getAuthToken?.();
491
- const email = typeof this.config.devUserEmail === "function" ? this.config.devUserEmail() : this.config.devUserEmail;
492
812
  if (token) h["Authorization"] = `Bearer ${token}`;
493
- else if (email) h["X-User-Email"] = email;
494
813
  return h;
495
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
+ }
496
850
  async ensureSession() {
851
+ this.syncSessionScope();
497
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;
498
856
  const res = await fetch(`${this.config.apiBaseUrl}/api/sessions`, {
499
857
  method: "POST",
500
858
  headers: this.headers(),
501
- 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 })
502
861
  });
503
862
  if (!res.ok) throw new Error(`Session creation failed (${res.status})`);
504
863
  const session = await res.json();
864
+ if (epoch !== this.epoch) throw new Error("The Copilot session was reset.");
505
865
  this.sessionId = session.sessionId;
866
+ this.sessionTenant = tenant;
867
+ this.sessionIdentity = identity;
506
868
  if (this.connection && this.sessionId) {
507
869
  try {
508
870
  await this.connection.invoke("JoinSession", this.sessionId);
@@ -511,25 +873,37 @@ ${reason}`;
511
873
  }
512
874
  return this.sessionId;
513
875
  }
514
- async runAsk(question) {
876
+ async runAsk(question, notHelpful = false) {
515
877
  this.open();
516
878
  this.lastQuestion = question;
517
- this.addMessage(question, "user");
879
+ this.addMessage(notHelpful ? "This didn\u2019t help." : question, "user");
518
880
  this.setProgress("Reading your question\u2026");
881
+ const epoch = this.epoch;
519
882
  try {
520
883
  const sessionId = await this.ensureSession();
521
- 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`, {
522
887
  method: "POST",
523
888
  headers: this.headers(),
524
- body: JSON.stringify({ question, tenantUniqueName: this.config.tenantUniqueName, context: this.currentContext() })
889
+ body: JSON.stringify(body)
525
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
+ }
526
897
  if (!res.ok) {
527
898
  this.setProgress("");
528
899
  this.renderAnswer({ status: "RequestFailed", primaryReason: `The copilot service returned ${res.status}.` });
529
900
  return;
530
901
  }
531
- this.renderAnswer(await res.json());
902
+ const answer = await res.json();
903
+ if (epoch !== this.epoch) return;
904
+ this.renderAnswer(answer, question, notHelpful);
532
905
  } catch (err) {
906
+ if (epoch !== this.epoch) return;
533
907
  this.setProgress("");
534
908
  const msg = err?.message ? String(err.message) : "Could not reach the copilot service.";
535
909
  this.renderAnswer({ status: "RequestFailed", primaryReason: msg });
@@ -538,7 +912,7 @@ ${reason}`;
538
912
  async connectSignalR() {
539
913
  if (!this.config.signalRHubUrl) return;
540
914
  const signalR = await import("@microsoft/signalr");
541
- 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();
542
916
  for (const event of Object.keys(PROGRESS_LABELS)) {
543
917
  connection.on(event, () => {
544
918
  if (event !== "AnswerCompleted") this.setProgress(PROGRESS_LABELS[event]);
@@ -548,9 +922,6 @@ ${reason}`;
548
922
  this.connection = connection;
549
923
  }
550
924
  };
551
- function escapeHtml(s) {
552
- return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
553
- }
554
925
 
555
926
  // src/index.ts
556
927
  var instance = new CopilotWidget();
@@ -564,7 +935,9 @@ var ApplicationCopilotWidget = {
564
935
  promptUpload: () => instance.promptUpload(),
565
936
  ask: (question) => instance.ask(question),
566
937
  setContext: (context) => instance.setContext(context),
567
- 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()
568
941
  };
569
942
  if (typeof window !== "undefined") {
570
943
  window.ApplicationCopilotWidget = ApplicationCopilotWidget;