@lightworkai.official/debug-capture 0.12.8 → 0.13.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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ขอให้ทีมดำเนินการ — asking the team for something, from the app.
3
+ *
4
+ * A report is "something broke": it carries a screenshot and the page's logs,
5
+ * because those are what make it reproducible. A request is "please do this"
6
+ * — reset my password, give me access — and nothing about the screen helps,
7
+ * so it asks for no screen share and sends no bundle. The desk decides what
8
+ * may be asked (its request types opened to reporters, GET
9
+ * /ingest/request-types); where it opens none, the widget is exactly what it
10
+ * was.
11
+ *
12
+ * Read AHEAD of a launch, not at it: `getDisplayMedia` must be called inside
13
+ * the click that opened the widget, so a launch cannot wait on the network to
14
+ * find out whether to ask "report or request?" first. Installing the widget
15
+ * starts the read; a launch uses whatever has arrived.
16
+ */
17
+ import { getConfig, appInfo, resolveModule } from "./config";
18
+ import { failureOf, identityHeader, ReportError, type FiledTicket } from "./submit";
19
+
20
+ export interface RequestTypeOption {
21
+ id: string;
22
+ name: string;
23
+ description: string;
24
+ /** How long the team means to take, in hours — null when it has not said. */
25
+ targetHours: number | null;
26
+ }
27
+
28
+ let known: RequestTypeOption[] | null = null;
29
+ let reading: Promise<RequestTypeOption[]> | null = null;
30
+
31
+ const hostOf = () => getConfig().host.replace(/\/$/, "");
32
+
33
+ /** What the desk opens to its reporters, as far as is known right now — null before the first read lands. */
34
+ export function requestTypesNow(): RequestTypeOption[] | null {
35
+ return known;
36
+ }
37
+
38
+ /**
39
+ * Ask the desk once. Never throws: a desk that answers nothing useful — an
40
+ * older one without the endpoint (404), a network blip — offers nothing, and
41
+ * the widget stays a report button.
42
+ */
43
+ export function loadRequestTypes(): Promise<RequestTypeOption[]> {
44
+ if (reading) return reading;
45
+ reading = (async () => {
46
+ try {
47
+ const res = await fetch(`${hostOf()}/ingest/request-types`, { headers: { "x-realm-key": getConfig().realmKey } });
48
+ const body = res.ok ? ((await res.json()) as { types?: unknown }) : null;
49
+ known = Array.isArray(body?.types) ? body.types.filter(isOption) : [];
50
+ } catch {
51
+ known = [];
52
+ // A failed read may be tried again on the next launch.
53
+ reading = null;
54
+ }
55
+ return known ?? [];
56
+ })();
57
+ return reading;
58
+ }
59
+
60
+ function isOption(t: unknown): t is RequestTypeOption {
61
+ const o = t as Partial<RequestTypeOption> | null;
62
+ return !!o && typeof o.id === "string" && typeof o.name === "string";
63
+ }
64
+
65
+ /** Forget what was read — for tests, and for a host that changes realm. */
66
+ export function resetRequestTypes(): void {
67
+ known = null;
68
+ reading = null;
69
+ }
70
+
71
+ /**
72
+ * Send a request. The same door as a report (POST /ingest/tickets), with the
73
+ * type it asks for — the desk starts its steps and never folds two people's
74
+ * same request into one. Who is asking comes from the identity token when the
75
+ * host has one, as for a report.
76
+ */
77
+ export async function submitRequest(input: { ticketTypeId: string; title: string; description: string }): Promise<FiledTicket> {
78
+ const config = getConfig();
79
+ let user: { email?: string | null; fullName?: string | null } = {};
80
+ try {
81
+ user = config.user?.() ?? {};
82
+ } catch {
83
+ user = {};
84
+ }
85
+ const here = typeof window === "undefined" ? null : window.location;
86
+ const body = {
87
+ title: input.title,
88
+ description: input.description.trim() || undefined,
89
+ ticketTypeId: input.ticketTypeId,
90
+ appName: appInfo().name,
91
+ category: here ? resolveModule(here.pathname)?.label : undefined,
92
+ routeUrl: here?.href,
93
+ priority: "MEDIUM",
94
+ requesterEmail: user.email ?? undefined,
95
+ requesterName: user.fullName ?? undefined,
96
+ };
97
+ const res = await fetch(`${hostOf()}/ingest/tickets`, {
98
+ method: "POST",
99
+ headers: { "content-type": "application/json", "x-realm-key": config.realmKey, ...(await identityHeader()) },
100
+ body: JSON.stringify(body),
101
+ }).catch(() => {
102
+ throw new ReportError("request never left the browser", "offline");
103
+ });
104
+ if (!res.ok) throw await failureOf(res);
105
+ return (await res.json()) as FiledTicket;
106
+ }
package/src/submit.ts CHANGED
@@ -76,7 +76,7 @@ export function bundleAttachment(bundle: DebugBundle): { dataUri: string; filena
76
76
  * endpoint is having a bad day — a bug report is the last thing that should
77
77
  * fail because of a side quest.
78
78
  */
79
- async function identityHeader(): Promise<Record<string, string>> {
79
+ export async function identityHeader(): Promise<Record<string, string>> {
80
80
  const provider = getConfig().identity;
81
81
  if (!provider) return {};
82
82
  try {
@@ -220,20 +220,23 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
220
220
  throw new ReportError("report never left the browser", "offline");
221
221
  });
222
222
 
223
- if (!res.ok) {
224
- // The ingest endpoint answers 429 with a retry-after; anything else carries
225
- // an { error } body. Surface whichever it is — "failed" alone sends someone
226
- // reading a network tab they may not be able to open.
227
- const retryAfter = res.headers.get("retry-after");
228
- const detail = await res
229
- .json()
230
- .then((b: { error?: string }) => b.error)
231
- .catch(() => null);
232
- if (res.status === 429) throw new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
233
- // 502/503/504 come from whatever is in front of the desk while it is being
234
- // replaced, and the body is that thing's error page, not a sentence for us.
235
- if (UNAVAILABLE.has(res.status)) throw new ReportError("desk unavailable", "unavailable");
236
- throw new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
237
- }
223
+ if (!res.ok) throw await failureOf(res);
238
224
  return (await res.json()) as FiledTicket;
239
225
  }
226
+
227
+ /** The send that did not go through, as one of the four failures — shared with a request's send (requests.ts). */
228
+ export async function failureOf(res: Response): Promise<ReportError> {
229
+ // The ingest endpoint answers 429 with a retry-after; anything else carries
230
+ // an { error } body. Surface whichever it is — "failed" alone sends someone
231
+ // reading a network tab they may not be able to open.
232
+ const retryAfter = res.headers.get("retry-after");
233
+ const detail = await res
234
+ .json()
235
+ .then((b: { error?: string }) => b.error)
236
+ .catch(() => null);
237
+ if (res.status === 429) return new ReportError("rate limited", "rateLimited", retryAfter ?? undefined);
238
+ // 502/503/504 come from whatever is in front of the desk while it is being
239
+ // replaced, and the body is that thing's error page, not a sentence for us.
240
+ if (UNAVAILABLE.has(res.status)) return new ReportError("desk unavailable", "unavailable");
241
+ return new ReportError(detail ?? `Report failed (${res.status})`, "rejected", undefined, res.status);
242
+ }
package/src/ui/element.ts CHANGED
@@ -13,6 +13,7 @@ import { buildBundle } from "../bundle";
13
13
  import { getConfig } from "../config";
14
14
  import { onLaunch } from "../reporter";
15
15
  import { captureScreen } from "../screenshot";
16
+ import { loadRequestTypes, requestTypesNow, submitRequest, type RequestTypeOption } from "../requests";
16
17
  import { ReportError, submitBundle } from "../submit";
17
18
  import type { DebugBundle, DebugReason, ErroredQuery } from "../types";
18
19
  import { createAnnotator, type Annotator, type AnnotatorTool } from "./annotator";
@@ -120,6 +121,26 @@ function createReporterClass(): CustomElementConstructor {
120
121
  this.busy = true;
121
122
  const t = strings(getConfig().locale);
122
123
 
124
+ /*
125
+ * Pressed by hand, on a desk that takes requests from its reporters:
126
+ * ask first whether this is a problem or a request. Read from what is
127
+ * already known — waiting on the network here would spend the click the
128
+ * screen share needs; a read not landed yet is the old dialog, and the
129
+ * next press will have it.
130
+ */
131
+ if (reason.type === "manual") {
132
+ const asks = requestTypesNow();
133
+ if (asks?.length) {
134
+ this.renderChooser(t, reason, asks);
135
+ return;
136
+ }
137
+ if (asks === null) void loadRequestTypes();
138
+ }
139
+ await this.report(reason, t);
140
+ }
141
+
142
+ /** A report: the screen first, then the form — see open(). Called inside a click, with nothing awaited before the capture. */
143
+ private async report(reason: DebugReason, t: Strings): Promise<void> {
123
144
  /*
124
145
  * Capture BEFORE anything of ours is on screen.
125
146
  *
@@ -147,6 +168,108 @@ function createReporterClass(): CustomElementConstructor {
147
168
  this.renderForm(t);
148
169
  }
149
170
 
171
+ /** แจ้งปัญหา or ขอให้ทีมดำเนินการ — two cards, nothing else. */
172
+ private renderChooser(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
173
+ const panel = this.shell(t);
174
+ panel.classList.add("narrow");
175
+ panel.setAttribute("aria-label", t.chooseTitle);
176
+ const head = el("div", "head");
177
+ const closeBtn = button("✕", "icon");
178
+ closeBtn.setAttribute("aria-label", t.close);
179
+ closeBtn.addEventListener("click", () => this.close());
180
+ head.append(el("h2", "", t.chooseTitle), closeBtn);
181
+
182
+ const problem = choice(t.chooseProblem, t.chooseProblemHint);
183
+ problem.dataset.choose = "problem";
184
+ // Our own dialog leaves the screen before the share starts, or it is in the picture.
185
+ problem.addEventListener("click", () => {
186
+ this.root.replaceChildren();
187
+ void this.report(reason, t);
188
+ });
189
+ const ask = choice(t.chooseRequest, t.chooseRequestHint);
190
+ ask.dataset.choose = "request";
191
+ ask.addEventListener("click", () => this.renderRequestForm(t, reason, types));
192
+
193
+ const choices = el("div", "choices");
194
+ choices.append(problem, ask);
195
+ panel.append(head, choices);
196
+ problem.focus();
197
+ }
198
+
199
+ /** A request: what is asked for, a subject, a line or two — no screenshot, no logs. */
200
+ private renderRequestForm(t: Strings, reason: DebugReason, types: RequestTypeOption[]): void {
201
+ const panel = this.shell(t);
202
+ panel.classList.add("narrow");
203
+ panel.setAttribute("aria-label", t.requestTitle);
204
+ const head = el("div", "head");
205
+ const closeBtn = button("✕", "icon");
206
+ closeBtn.setAttribute("aria-label", t.close);
207
+ closeBtn.addEventListener("click", () => this.close());
208
+ head.append(el("h2", "", t.requestTitle), closeBtn);
209
+
210
+ let chosen: RequestTypeOption = types[0]!;
211
+ const about = el("p", "status", "");
212
+ const describe = () => {
213
+ const parts = [chosen.description, chosen.targetHours ? t.requestTarget.replace("{time}", hoursWords(t, chosen.targetHours)) : ""].filter(Boolean);
214
+ about.textContent = parts.join(" · ");
215
+ };
216
+ const typeRow: HTMLElement[] = [labelFor(t.requestTypeLabel, "", true)];
217
+ if (types.length > 1) {
218
+ const select = document.createElement("select");
219
+ select.dataset.requestType = "";
220
+ for (const option of types) {
221
+ const o = document.createElement("option");
222
+ o.value = option.id;
223
+ o.textContent = option.name;
224
+ select.append(o);
225
+ }
226
+ select.addEventListener("change", () => {
227
+ chosen = types.find((x) => x.id === select.value) ?? chosen;
228
+ describe();
229
+ });
230
+ typeRow.push(select);
231
+ } else typeRow.push(el("p", "ask-type", chosen.name));
232
+ describe();
233
+
234
+ const summary = document.createElement("input");
235
+ summary.type = "text";
236
+ summary.placeholder = t.requestSummaryPlaceholder;
237
+ summary.maxLength = 300;
238
+ const detail = document.createElement("textarea");
239
+ detail.placeholder = t.requestDetailPlaceholder;
240
+
241
+ const status = el("p", "status", "");
242
+ const submitBtn = button(t.requestSubmit, "primary");
243
+ submitBtn.disabled = true;
244
+ summary.addEventListener("input", () => {
245
+ submitBtn.disabled = summary.value.trim().length === 0;
246
+ });
247
+ const back = button(t.back);
248
+ back.addEventListener("click", () => this.renderChooser(t, reason, types));
249
+ const foot = el("div", "foot");
250
+ foot.append(back, status, el("span", "grow"), submitBtn);
251
+ panel.append(head, ...typeRow, about, labelFor(t.requestSummaryLabel, "", true), summary, labelFor(t.requestDetailLabel), detail, foot);
252
+
253
+ submitBtn.addEventListener("click", async () => {
254
+ submitBtn.disabled = true;
255
+ status.dataset.tone = "";
256
+ status.textContent = t.submitting;
257
+ try {
258
+ const filed = await submitRequest({ ticketTypeId: chosen.id, title: summary.value.trim(), description: detail.value });
259
+ const announced = this.dispatchEvent(new CustomEvent("lw-request-submitted", { detail: filed, bubbles: true, composed: true, cancelable: true }));
260
+ this.close();
261
+ if (announced) showToast(`${t.requestSubmitted} — #${filed.number}`, "success");
262
+ } catch (error) {
263
+ const message = failureMessage(error, t);
264
+ status.dataset.tone = "error";
265
+ status.textContent = message;
266
+ showToast(message, "error");
267
+ submitBtn.disabled = false;
268
+ }
269
+ });
270
+ summary.focus();
271
+ }
272
+
150
273
  private close(): void {
151
274
  this.annotator?.destroy();
152
275
  this.annotator = null;
@@ -360,6 +483,22 @@ function el(tag: string, className = "", text = ""): HTMLElement {
360
483
  return node;
361
484
  }
362
485
 
486
+ /** "3 วัน" or "4 ชั่วโมง" — the way the desk's setting offers it. */
487
+ function hoursWords(t: Strings, h: number): string {
488
+ return h >= 24 && h % 24 === 0 ? t.days.replace("{n}", String(h / 24)) : t.hours.replace("{n}", String(h));
489
+ }
490
+
491
+ /** A card of a choice: its name, and a line under it. */
492
+ function choice(title: string, hint: string): HTMLButtonElement {
493
+ const node = button("", "choice");
494
+ const b = document.createElement("b");
495
+ b.textContent = title;
496
+ const span = document.createElement("span");
497
+ span.textContent = hint;
498
+ node.append(b, span);
499
+ return node;
500
+ }
501
+
363
502
  function button(text: string, className = ""): HTMLButtonElement {
364
503
  const node = document.createElement("button");
365
504
  node.type = "button";
package/src/ui/strings.ts CHANGED
@@ -64,6 +64,29 @@ export interface Strings {
64
64
  handoffFailed: string;
65
65
  handoffBlocked: string;
66
66
  handoffOpen: string;
67
+ /*
68
+ * ขอให้ทีมดำเนินการ — offered beside แจ้งปัญหา when the desk opens request
69
+ * types to its reporters (requests.ts). A request asks for no screenshot.
70
+ */
71
+ chooseTitle: string;
72
+ chooseProblem: string;
73
+ chooseProblemHint: string;
74
+ chooseRequest: string;
75
+ chooseRequestHint: string;
76
+ requestTitle: string;
77
+ requestTypeLabel: string;
78
+ /** Carries `{time}`. */
79
+ requestTarget: string;
80
+ requestSummaryLabel: string;
81
+ requestSummaryPlaceholder: string;
82
+ requestDetailLabel: string;
83
+ requestDetailPlaceholder: string;
84
+ requestSubmit: string;
85
+ requestSubmitted: string;
86
+ back: string;
87
+ /** Carry `{n}`. */
88
+ days: string;
89
+ hours: string;
67
90
  }
68
91
 
69
92
  const th: Strings = {
@@ -109,6 +132,23 @@ const th: Strings = {
109
132
  handoffFailed: "เปิดรายการที่แจ้งไว้ไม่สำเร็จ",
110
133
  handoffBlocked: "เบราว์เซอร์บล็อกการเปิดแท็บใหม่",
111
134
  handoffOpen: "เปิดในแท็บใหม่",
135
+ chooseTitle: "ต้องการให้ทีมช่วยเรื่องอะไร",
136
+ chooseProblem: "แจ้งปัญหา",
137
+ chooseProblemHint: "มีอะไรเสียหรือใช้งานไม่ได้ — แนบภาพหน้าจอ",
138
+ chooseRequest: "ขอให้ทีมดำเนินการ",
139
+ chooseRequestHint: "ขอสิทธิ์ ขอแก้ข้อมูล หรือขอให้ทีมทำบางอย่าง",
140
+ requestTitle: "ขอให้ทีมดำเนินการ",
141
+ requestTypeLabel: "เรื่องที่ขอ",
142
+ requestTarget: "ทีมตั้งใจให้เสร็จภายใน {time}",
143
+ requestSummaryLabel: "หัวข้อ",
144
+ requestSummaryPlaceholder: "ขออะไร — สั้น ๆ",
145
+ requestDetailLabel: "รายละเอียด (ไม่บังคับ)",
146
+ requestDetailPlaceholder: "สิ่งที่ทีมต้องรู้เพื่อดำเนินการ",
147
+ requestSubmit: "ส่งคำขอ",
148
+ requestSubmitted: "ส่งคำขอแล้ว",
149
+ back: "ย้อนกลับ",
150
+ days: "{n} วัน",
151
+ hours: "{n} ชั่วโมง",
112
152
  };
113
153
 
114
154
  const en: Strings = {
@@ -154,6 +194,23 @@ const en: Strings = {
154
194
  handoffFailed: "Could not open your reports",
155
195
  handoffBlocked: "Your browser blocked the new tab",
156
196
  handoffOpen: "Open in a new tab",
197
+ chooseTitle: "How can the team help?",
198
+ chooseProblem: "Report a problem",
199
+ chooseProblemHint: "Something is broken or not working — with a screenshot",
200
+ chooseRequest: "Ask the team",
201
+ chooseRequestHint: "Access, a data change, or something done for you",
202
+ requestTitle: "Ask the team",
203
+ requestTypeLabel: "What you need",
204
+ requestTarget: "The team aims to finish within {time}",
205
+ requestSummaryLabel: "Subject",
206
+ requestSummaryPlaceholder: "What do you need — briefly",
207
+ requestDetailLabel: "Details (optional)",
208
+ requestDetailPlaceholder: "What the team needs to know to do it",
209
+ requestSubmit: "Send request",
210
+ requestSubmitted: "Request sent",
211
+ back: "Back",
212
+ days: "{n} days",
213
+ hours: "{n} hours",
157
214
  };
158
215
 
159
216
  export function strings(locale: "th" | "en" | undefined): Strings {
package/src/ui/styles.ts CHANGED
@@ -34,11 +34,11 @@ h2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }
34
34
  label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
35
35
  .req { color: #dc2626; }
36
36
  .hint { font-weight: 400; color: #64748b; }
37
- input[type="text"], textarea {
37
+ input[type="text"], textarea, select {
38
38
  width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;
39
39
  border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;
40
40
  }
41
- input[type="text"]:focus, textarea:focus {
41
+ input[type="text"]:focus, textarea:focus, select:focus {
42
42
  outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
43
43
  }
44
44
  textarea { min-height: 88px; resize: vertical; }
@@ -93,4 +93,17 @@ canvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; b
93
93
  border-radius: 0 0 16px 16px;
94
94
  }
95
95
  .foot .grow { flex: 1; }
96
+
97
+ /* ขอให้ทีมดำเนินการ: the chooser and the request form — a smaller dialog, no image in it. */
98
+ .panel.narrow { width: min(560px, 100%); }
99
+ .choices { display: grid; gap: 10px; padding-bottom: 24px; }
100
+ button.choice {
101
+ display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
102
+ width: 100%; padding: 14px 16px; text-align: left; border-radius: 12px;
103
+ }
104
+ button.choice b { font-size: 15px; font-weight: 600; }
105
+ button.choice span { font-size: 13px; font-weight: 400; color: #64748b; }
106
+ button.choice:focus-visible { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }
107
+ .panel.narrow p { margin: 0; }
108
+ .ask-type { font-size: 14px; font-weight: 600; margin-top: -8px; }
96
109
  `;