@lightworkai.official/debug-capture 0.12.7 → 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.
@@ -64,5 +64,24 @@ export interface Strings {
64
64
  handoffFailed: string;
65
65
  handoffBlocked: string;
66
66
  handoffOpen: string;
67
+ chooseTitle: string;
68
+ chooseProblem: string;
69
+ chooseProblemHint: string;
70
+ chooseRequest: string;
71
+ chooseRequestHint: string;
72
+ requestTitle: string;
73
+ requestTypeLabel: string;
74
+ /** Carries `{time}`. */
75
+ requestTarget: string;
76
+ requestSummaryLabel: string;
77
+ requestSummaryPlaceholder: string;
78
+ requestDetailLabel: string;
79
+ requestDetailPlaceholder: string;
80
+ requestSubmit: string;
81
+ requestSubmitted: string;
82
+ back: string;
83
+ /** Carry `{n}`. */
84
+ days: string;
85
+ hours: string;
67
86
  }
68
87
  export declare function strings(locale: "th" | "en" | undefined): Strings;
@@ -10,4 +10,4 @@
10
10
  * Everything is explicit for the same reason: the host's `body` font and colours
11
11
  * do not inherit past the boundary, so stating them is not belt-and-braces.
12
12
  */
13
- export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n";
13
+ export declare const CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n\n.backdrop {\n position: fixed; inset: 0; z-index: 2147483000;\n display: flex; align-items: center; justify-content: center;\n padding: 24px; background: rgba(15, 23, 42, 0.55);\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Noto Sans Thai\", sans-serif;\n color: #0f172a;\n}\n.panel {\n display: flex; flex-direction: column; gap: 16px;\n width: min(820px, 100%); max-height: 90vh; overflow-y: auto;\n /* No bottom padding: the footer supplies its own and sits flush, so it can\n stick to the panel's edge without a gap under it. */\n padding: 24px 24px 0; border-radius: 16px; background: #fff;\n box-shadow: 0 24px 60px rgba(15, 23, 42, 0.28);\n}\n.head { display: flex; align-items: flex-start; gap: 12px; }\nh2 { margin: 0; font-size: 20px; font-weight: 700; flex: 1; }\nlabel { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }\n.req { color: #dc2626; }\n.hint { font-weight: 400; color: #64748b; }\ninput[type=\"text\"], textarea, select {\n width: 100%; padding: 10px 12px; font: inherit; font-size: 14px;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\ninput[type=\"text\"]:focus, textarea:focus, select:focus {\n outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);\n}\ntextarea { min-height: 88px; resize: vertical; }\n\n.meta {\n padding: 12px 14px; border-radius: 10px; background: #f8fafc;\n font-size: 13px; line-height: 1.7; color: #475569; word-break: break-all;\n}\n.meta b { color: #0f172a; font-weight: 600; }\n\n.tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }\n.sep { width: 1px; align-self: stretch; background: #e2e8f0; margin: 0 4px; }\nbutton {\n display: inline-flex; align-items: center; gap: 6px; cursor: pointer;\n padding: 8px 14px; font: inherit; font-size: 14px; font-weight: 500;\n border: 1px solid #cbd5e1; border-radius: 10px; background: #fff; color: inherit;\n}\nbutton:hover:not(:disabled) { background: #f1f5f9; }\nbutton:disabled { opacity: 0.5; cursor: not-allowed; }\nbutton[aria-pressed=\"true\"] { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }\nbutton.primary { background: #2563eb; border-color: #2563eb; color: #fff; }\nbutton.primary:hover:not(:disabled) { background: #1d4ed8; }\nbutton.icon { padding: 6px 10px; border-color: transparent; font-size: 18px; line-height: 1; }\n\n/* The on-image text editor. Absolutely placed over the canvas and matched to\n the shape it becomes \u2014 same weight, same red, same white plate \u2014 so what you\n type is what gets drawn, at the position you clicked. Sized in JS, because\n the font size tracks the image's own scale. */\n.text-edit {\n position: absolute; z-index: 2; margin: 0; padding: 2px 4px;\n font-family: system-ui, sans-serif; font-weight: 600; line-height: 1.25;\n color: #e11d48; background: rgba(255, 255, 255, 0.92);\n border: 1px dashed #e11d48; border-radius: 4px; outline: none;\n box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18);\n}\n.text-edit::placeholder { color: rgba(225, 29, 72, 0.45); }\n\ncanvas { width: 100%; height: auto; display: block; border: 1px solid #e2e8f0; border-radius: 10px; touch-action: none; }\n.shot { position: relative; }\n.status { font-size: 13px; color: #64748b; }\n.status[data-tone=\"error\"] { color: #dc2626; }\n/* Pinned to the bottom of the scrolling panel.\n A screenshot is tall \u2014 often taller than the viewport \u2014 so \u0E41\u0E08\u0E49\u0E07\u0E1B\u0E31\u0E0D\u0E2B\u0E32 sat\n below the fold and the last step of filing a report was \"scroll to the end\n and find the button\". Sticky, with its own ground and a rule above it so the\n annotated image scrolls underneath rather than bleeding into it. */\n.foot {\n position: sticky; bottom: 0; z-index: 3;\n display: flex; align-items: center; gap: 12px;\n margin: 4px -24px 0; padding: 12px 24px;\n background: #fff; border-top: 1px solid #e2e8f0;\n border-radius: 0 0 16px 16px;\n}\n.foot .grow { flex: 1; }\n\n/* \u0E02\u0E2D\u0E43\u0E2B\u0E49\u0E17\u0E35\u0E21\u0E14\u0E33\u0E40\u0E19\u0E34\u0E19\u0E01\u0E32\u0E23: the chooser and the request form \u2014 a smaller dialog, no image in it. */\n.panel.narrow { width: min(560px, 100%); }\n.choices { display: grid; gap: 10px; padding-bottom: 24px; }\nbutton.choice {\n display: flex; flex-direction: column; align-items: flex-start; gap: 2px;\n width: 100%; padding: 14px 16px; text-align: left; border-radius: 12px;\n}\nbutton.choice b { font-size: 15px; font-weight: 600; }\nbutton.choice span { font-size: 13px; font-weight: 400; color: #64748b; }\nbutton.choice:focus-visible { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18); }\n.panel.narrow p { margin: 0; }\n.ask-type { font-size: 14px; font-weight: 600; margin-top: -8px; }\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightworkai.official/debug-capture",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
5
5
  "type": "module",
6
6
  "module": "./dist/index.mjs",
package/src/embed.ts CHANGED
@@ -66,7 +66,13 @@ if (data.host && data.realmKey) {
66
66
  locale: data.locale === "en" ? "en" : "th",
67
67
  ...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
68
68
  });
69
- installDebugCapture();
69
+ /*
70
+ * In <head>, the script runs before there is a <body> — and installing then
71
+ * threw on the first observer set on it, so the page had no reporter and no
72
+ * `lwReportProblem` either. Wait for the document when it is not there yet.
73
+ */
74
+ if (document.body) installDebugCapture();
75
+ else document.addEventListener("DOMContentLoaded", () => installDebugCapture(), { once: true });
70
76
  }
71
77
 
72
78
  // The only handle a no-bundler page gets. Call it from a button's onclick —
package/src/index.ts CHANGED
@@ -22,6 +22,8 @@ export { launch, onLaunch, onCrash, emitCrash } from "./reporter";
22
22
  export { buildBundle, bundleToMarkdown, hasCause, bundleFileName, toCurl } from "./bundle";
23
23
  export { submitBundle } from "./submit";
24
24
  export type { FiledTicket } from "./submit";
25
+ export { loadRequestTypes, requestTypesNow, submitRequest } from "./requests";
26
+ export type { RequestTypeOption } from "./requests";
25
27
  export { errorSignature } from "./signature";
26
28
  export { trimToBudget, BUNDLE_BUDGET_BYTES } from "./budget";
27
29
  export { captureScreen } from "./screenshot";
package/src/install.ts CHANGED
@@ -13,6 +13,7 @@ import { launch, onCrash } from "./reporter";
13
13
  import { strings } from "./ui/strings";
14
14
  import { showToast } from "./ui/toast";
15
15
  import { defineReporterElement, REPORTER_TAG } from "./ui/element";
16
+ import { loadRequestTypes } from "./requests";
16
17
 
17
18
  let armed = false;
18
19
 
@@ -59,4 +60,13 @@ export function installDebugCapture(options: InstallOptions = {}): void {
59
60
  if (!document.querySelector(REPORTER_TAG)) {
60
61
  document.body.append(document.createElement(REPORTER_TAG));
61
62
  }
63
+
64
+ /*
65
+ * What the desk opens to its reporters, read once the page has settled —
66
+ * so a press of the button can offer ขอให้ทีมดำเนินการ without waiting
67
+ * (requests.ts). Late on purpose: it is never what the page came for.
68
+ */
69
+ const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
70
+ if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
71
+ else setTimeout(() => void loadRequestTypes(), 2000);
62
72
  }
@@ -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/server.ts CHANGED
@@ -17,12 +17,56 @@
17
17
  */
18
18
  import { createHmac, randomUUID } from "node:crypto";
19
19
 
20
+ /**
21
+ * One fact about a person's WORK, as the host knows it — their unit, their
22
+ * position — with the host's own label for it. The support desk shows these
23
+ * beside the person's name and records them with what the person does (an
24
+ * event registration takes them as its own), so nobody types their unit into
25
+ * a form the host could have filled.
26
+ *
27
+ * `key` is stable across people (it names a column in an export); `label` is
28
+ * what a person reads, in the host's language. Order matters: the first is
29
+ * the one drawn under a name.
30
+ */
31
+ export interface SupportProfileFact {
32
+ key: string;
33
+ label: string;
34
+ value: string;
35
+ }
36
+
20
37
  /** Whoever the host has authenticated. Email is the account key. */
21
38
  export interface SupportUser {
22
39
  /** The host's own id for this person, recorded so a changed email still matches. */
23
40
  id?: string | null;
24
41
  email: string;
25
42
  name?: string | null;
43
+ /** What the host knows about their work — most important first. Optional; bounded on the way in. */
44
+ profile?: SupportProfileFact[] | null;
45
+ }
46
+
47
+ /** How much of a profile rides in a token. It travels in a URL, so it is small on purpose. */
48
+ export const PROFILE_MAX_FACTS = 10;
49
+ const PROFILE_KEY = /^[a-z][a-z0-9_]{0,31}$/;
50
+
51
+ /**
52
+ * A profile made safe to sign: known shape, stable keys, trimmed, bounded.
53
+ * A fact with no value is left out (an empty unit is not a unit). Null when
54
+ * there is nothing to say — the support side then keeps what it had.
55
+ */
56
+ export function cleanProfile(profile: SupportProfileFact[] | null | undefined): SupportProfileFact[] | null {
57
+ if (!Array.isArray(profile)) return null;
58
+ const out: SupportProfileFact[] = [];
59
+ const seen = new Set<string>();
60
+ for (const f of profile) {
61
+ const key = typeof f?.key === "string" ? f.key.trim() : "";
62
+ const value = typeof f?.value === "string" ? f.value.trim() : "";
63
+ if (!PROFILE_KEY.test(key) || !value || seen.has(key)) continue;
64
+ seen.add(key);
65
+ const label = typeof f.label === "string" && f.label.trim() ? f.label.trim().slice(0, 40) : key;
66
+ out.push({ key, label, value: value.slice(0, 120) });
67
+ if (out.length >= PROFILE_MAX_FACTS) break;
68
+ }
69
+ return out.length ? out : null;
26
70
  }
27
71
 
28
72
  /**
@@ -65,6 +109,8 @@ export function createSupportToken(options: {
65
109
  sub: options.user.id?.trim() || email,
66
110
  email,
67
111
  name: options.user.name?.trim() || null,
112
+ // Only when there is one: an older support desk ignores the claim, a newer one records it.
113
+ ...(cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {}),
68
114
  // Spent once on arrival; without it the freshness window is a replay window.
69
115
  jti: randomUUID(),
70
116
  iat: now,
@@ -74,6 +120,38 @@ export function createSupportToken(options: {
74
120
  return { token: `${body}.${createHmac("sha256", options.secret).update(body).digest("base64url")}`, expiresIn };
75
121
  }
76
122
 
123
+ /**
124
+ * The address that signs a person into the support desk and lands them on a
125
+ * page of it — for a host that sends people there by LINK rather than from
126
+ * its own button (a notice on a portal, a "sign in" door the desk bounces a
127
+ * signed-out reader to). The token is minted here, on the server, at the
128
+ * moment of the redirect; it lives two minutes and is spent on arrival.
129
+ *
130
+ * redirect(supportSignInUrl({ host, realmKey, secret, user, returnPath: "/e/อบรม-a4f2" }))
131
+ *
132
+ * `returnPath` must be a path on the support desk; the desk decides again
133
+ * where it may land (its own reporter pages, or an event).
134
+ */
135
+ export function supportSignInUrl(options: {
136
+ /** The support desk's origin, e.g. https://support.example.com */
137
+ host: string;
138
+ /** The realm's public key. */
139
+ realmKey: string;
140
+ secret: string;
141
+ user: SupportUser;
142
+ returnPath?: string | null;
143
+ ttlSeconds?: number;
144
+ }): string {
145
+ const { token } = createSupportToken({ secret: options.secret, user: options.user, ttlSeconds: options.ttlSeconds });
146
+ const url = new URL("/sso", options.host.replace(/\/+$/, ""));
147
+ url.searchParams.set("realm", options.realmKey);
148
+ url.searchParams.set("token", token);
149
+ const back = options.returnPath?.trim();
150
+ // A path, never an address: the desk would refuse anything else anyway, so say so here first.
151
+ if (back && back.startsWith("/") && !back.startsWith("//")) url.searchParams.set("return", back);
152
+ return url.toString();
153
+ }
154
+
77
155
  /**
78
156
  * A ready-made route handler, for the common case.
79
157
  *
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
  `;