@oharness/server 0.9.3 → 0.11.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.
Files changed (93) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +500 -64
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/main.js +4 -0
  9. package/dist/main.js.map +1 -1
  10. package/dist/model-order.js +1 -0
  11. package/dist/model-order.js.map +1 -1
  12. package/dist/page/client/account.js +570 -27
  13. package/dist/page/client/account.js.map +1 -1
  14. package/dist/page/client/base.js +55 -12
  15. package/dist/page/client/base.js.map +1 -1
  16. package/dist/page/client/browser.js +1 -1
  17. package/dist/page/client/composer.js +802 -59
  18. package/dist/page/client/composer.js.map +1 -1
  19. package/dist/page/client/computer.d.ts +27 -0
  20. package/dist/page/client/computer.js +358 -0
  21. package/dist/page/client/computer.js.map +1 -0
  22. package/dist/page/client/dialogs.js +168 -6
  23. package/dist/page/client/dialogs.js.map +1 -1
  24. package/dist/page/client/events.js +119 -9
  25. package/dist/page/client/events.js.map +1 -1
  26. package/dist/page/client/files.js +86 -1
  27. package/dist/page/client/files.js.map +1 -1
  28. package/dist/page/client/index.js +8 -0
  29. package/dist/page/client/index.js.map +1 -1
  30. package/dist/page/client/panels.js +14 -10
  31. package/dist/page/client/panels.js.map +1 -1
  32. package/dist/page/client/ripple.d.ts +13 -0
  33. package/dist/page/client/ripple.js +247 -0
  34. package/dist/page/client/ripple.js.map +1 -0
  35. package/dist/page/client/routines.js +432 -45
  36. package/dist/page/client/routines.js.map +1 -1
  37. package/dist/page/client/shell.js +386 -104
  38. package/dist/page/client/shell.js.map +1 -1
  39. package/dist/page/client/state.js +278 -19
  40. package/dist/page/client/state.js.map +1 -1
  41. package/dist/page/client/tasks.d.ts +8 -0
  42. package/dist/page/client/tasks.js +293 -0
  43. package/dist/page/client/tasks.js.map +1 -0
  44. package/dist/page/client/terminal.js +34 -7
  45. package/dist/page/client/terminal.js.map +1 -1
  46. package/dist/page/client/theme.d.ts +17 -0
  47. package/dist/page/client/theme.js +365 -0
  48. package/dist/page/client/theme.js.map +1 -0
  49. package/dist/page/client/transcript.js +196 -8
  50. package/dist/page/client/transcript.js.map +1 -1
  51. package/dist/page/client/usage.js +1 -11
  52. package/dist/page/client/usage.js.map +1 -1
  53. package/dist/page/style.js +745 -76
  54. package/dist/page/style.js.map +1 -1
  55. package/dist/page.js +94 -16
  56. package/dist/page.js.map +1 -1
  57. package/dist/prefs.d.ts +31 -0
  58. package/dist/prefs.js +13 -1
  59. package/dist/prefs.js.map +1 -1
  60. package/dist/remote-crypto.d.ts +78 -0
  61. package/dist/remote-crypto.js +216 -0
  62. package/dist/remote-crypto.js.map +1 -0
  63. package/dist/remote.d.ts +103 -0
  64. package/dist/remote.js +498 -0
  65. package/dist/remote.js.map +1 -0
  66. package/dist/server.d.ts +70 -0
  67. package/dist/server.js +1738 -73
  68. package/dist/server.js.map +1 -1
  69. package/dist/starters.d.ts +92 -0
  70. package/dist/starters.js +213 -0
  71. package/dist/starters.js.map +1 -0
  72. package/dist/suggest.d.ts +42 -0
  73. package/dist/suggest.js +215 -0
  74. package/dist/suggest.js.map +1 -0
  75. package/dist/terminal.d.ts +28 -2
  76. package/dist/terminal.js +45 -3
  77. package/dist/terminal.js.map +1 -1
  78. package/dist/theme-assets.d.ts +39 -0
  79. package/dist/theme-assets.js +109 -0
  80. package/dist/theme-assets.js.map +1 -0
  81. package/dist/theme-store.d.ts +50 -0
  82. package/dist/theme-store.js +124 -0
  83. package/dist/theme-store.js.map +1 -0
  84. package/dist/theme-tools.d.ts +25 -0
  85. package/dist/theme-tools.js +243 -0
  86. package/dist/theme-tools.js.map +1 -0
  87. package/dist/turn-shots.d.ts +43 -0
  88. package/dist/turn-shots.js +80 -0
  89. package/dist/turn-shots.js.map +1 -0
  90. package/dist/user-theme.d.ts +143 -0
  91. package/dist/user-theme.js +510 -0
  92. package/dist/user-theme.js.map +1 -0
  93. package/package.json +2 -2
@@ -215,6 +215,21 @@ async function showModels() {
215
215
  return go;
216
216
  };
217
217
 
218
+ // What this vendor has cost lately, from this machine's own records — the
219
+ // question a pasted key leaves open, answered where the key is.
220
+ if (provider.spend30d >= 0.01) {
221
+ const spent = el("div", "muted small");
222
+ spent.appendChild(document.createTextNode(t("models.spend30d", { amount: moneyOf(Math.round(provider.spend30d * 100), "usd") }) + " "));
223
+ const more = el("button", "cn-inline", t("models.spendDetail"));
224
+ more.type = "button";
225
+ more.addEventListener("click", (e) => {
226
+ e.stopPropagation();
227
+ document.getElementById("nav-usage").click();
228
+ });
229
+ spent.appendChild(more);
230
+ cardBody.appendChild(spent);
231
+ }
232
+
218
233
  if (provider.readiness === "no-account-system") {
219
234
  cardBody.appendChild(el("div", "muted small", t("models.noAccounts")));
220
235
  cardBody.appendChild(el("div", "muted small", t("models.noAccountsHow")));
@@ -229,6 +244,8 @@ async function showModels() {
229
244
  cardBody.appendChild(signInButton());
230
245
  } else if (provider.signIn && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
231
246
  cardBody.appendChild(signInButton());
247
+ } else if (provider.vendorSignIn && !provider.configured) {
248
+ cardBody.appendChild(vendorSignIn(provider, null, null));
232
249
  }
233
250
 
234
251
  // The key field, for every vendor that takes one — the account plans
@@ -236,7 +253,7 @@ async function showModels() {
236
253
  // and spends the same balance, and CI needs one because it does not
237
254
  // expire. This is where it belongs, next to every other vendor's key
238
255
  // field, rather than inside the sign-in dialog.
239
- if (!provider.configured || String(provider.source || "").startsWith("env:")) {
256
+ if (!keyless(provider) && (!provider.configured || String(provider.source || "").startsWith("env:"))) {
240
257
  const row = el("div", "keyrow");
241
258
  const field = document.createElement("input");
242
259
  // type=password so the key is not left legible on a screen, and
@@ -462,7 +479,17 @@ function openConnect(providerId, thenModel) {
462
479
  // the providers that are one. For a vendor whose credential is a key it
463
480
  // would be an advertisement in the way of the field they came to fill in.
464
481
  document.getElementById("cn-aside").hidden = !provider.signIn;
465
- document.getElementById("cn-title").textContent = provider.label || provider.id;
482
+ // The column beside it already names OHarness; the title says what this
483
+ // side is for instead of naming it a second time.
484
+ document.getElementById("cn-title").textContent = provider.signIn && provider.readiness !== "no-account-system"
485
+ ? t("models.signIn")
486
+ : provider.label || provider.id;
487
+ // A vendor's own sign-in is that vendor's page the user is about to visit,
488
+ // so the dialog carries its mark: recognising the logo is most of what
489
+ // makes "sign in with" feel safe to click.
490
+ if (!provider.signIn && provider.vendorSignIn) {
491
+ document.getElementById("cn-title").prepend(providerMark(provider.id, provider.label || provider.id, "cn-title-mark"));
492
+ }
466
493
  // What this dialog is answering. A credential that ran out and one that was
467
494
  // never created both arrive here, and telling someone to "sign in" when what
468
495
  // happened is "your session ended" leaves them wondering whether the first
@@ -590,6 +617,54 @@ function openConnect(providerId, thenModel) {
590
617
  const siteButton = site.querySelector("button");
591
618
  const orCode = el("div", "cn-note", ts("models.orCode"));
592
619
 
620
+ // A vendor's own sign-in, above the key field it makes optional — or, for a
621
+ // vendor with no key at all, in place of it.
622
+ const vendor = mode === "key" && provider.vendorSignIn
623
+ ? vendorSignIn(
624
+ provider,
625
+ () => {
626
+ if (dialog.open) void done("signed in to " + provider.id + " — discovering its models…");
627
+ },
628
+ (reason) => {
629
+ hint.textContent = reason;
630
+ hint.hidden = false;
631
+ },
632
+ )
633
+ : null;
634
+ const noKey = keyless(provider);
635
+ const orKey = vendor && !noKey ? el("div", "cn-note", t("models.orPasteKey")) : null;
636
+ // What signing in hands over, said where the hesitation is: right under the
637
+ // button. Only when the sign-in is the whole dialog — beside a key field the
638
+ // key's own note says the same, and two padlocks in a row read as clutter.
639
+ let vendorLocal = null;
640
+ if (vendor && noKey) {
641
+ vendorLocal = el("div", "cn-note cn-local");
642
+ vendorLocal.appendChild(svgIcon("lock"));
643
+ const words = el("span");
644
+ words.appendChild(el("strong", null, t("models.vendorLocalLead", { provider: provider.label || provider.id })));
645
+ words.appendChild(document.createTextNode(t("models.vendorLocalRest")));
646
+ vendorLocal.appendChild(words);
647
+ }
648
+ // Someone with a Claude plan arrives here looking for a way to use it, and
649
+ // a key field alone says nothing to them. The plan works in Claude Code and
650
+ // not in other apps, so the honest answer is a door to Claude Code — their
651
+ // own install, in this project's terminal — rather than a login of ours.
652
+ let claudePlan = null;
653
+ if (provider.id === "anthropic" && currentCwd) {
654
+ claudePlan = el("div", "cn-note");
655
+ claudePlan.appendChild(document.createTextNode(t("models.claudePlan") + " "));
656
+ const open = el("button", "cn-inline", t("models.claudePlanOpen"));
657
+ open.type = "button";
658
+ open.onclick = () => {
659
+ dialog.close();
660
+ void openClaudeCode(currentCwd);
661
+ };
662
+ claudePlan.appendChild(open);
663
+ }
664
+ // The sign-in is the whole dialog then, and the × already closes it: a
665
+ // Cancel alone in the actions row only read as a button left behind.
666
+ dialog.classList.toggle("cn-keyless", noKey);
667
+
593
668
  /**
594
669
  * What the dialog opens focused.
595
670
  *
@@ -602,7 +677,13 @@ function openConnect(providerId, thenModel) {
602
677
  // no account system it is the one that fetches a key, which is the same
603
678
  // shape of thing and deserves the same treatment. The field is where the
604
679
  // cursor goes only when pasting is what is left.
605
- const opener = mode === "account" && !flow ? siteButton : noAccounts ? keyButton : undefined;
680
+ const opener = mode === "account" && !flow
681
+ ? siteButton
682
+ : noAccounts
683
+ ? keyButton
684
+ : vendor
685
+ ? vendor.querySelector("button")
686
+ : undefined;
606
687
  if (opener) opener.focus();
607
688
  else field.focus();
608
689
  };
@@ -661,7 +742,16 @@ function openConnect(providerId, thenModel) {
661
742
  // button above the field: whichever control is the way in carries the
662
743
  // colour, and the actions row holds the fallback. Two filled buttons make
663
744
  // the reader choose by position, and position would pick the wrong one.
664
- save.classList.toggle("approve", mode === "key" && !noAccounts);
745
+ save.classList.toggle("approve", mode === "key" && !noAccounts && !vendor);
746
+ // Nothing to paste for a vendor that takes no key: the sign-in above is
747
+ // the whole dialog, and a field beside it could only ever be refused.
748
+ field.hidden = noKey;
749
+ save.hidden = noKey;
750
+ if (noKey) {
751
+ why.hidden = true;
752
+ keptLocal.hidden = true;
753
+ docs.hidden = true;
754
+ }
665
755
  field.value = "";
666
756
  focusPrimary();
667
757
  };
@@ -814,10 +904,14 @@ function openConnect(providerId, thenModel) {
814
904
  // somebody expecting a sign-in would ask — and they are exactly who opens
815
905
  // the mark.
816
906
  ...(noAccounts ? [keyLink(), el("div", "cn-note", t("models.orPasteKey"))] : []),
907
+ ...(vendor ? [vendor] : []),
908
+ ...(vendorLocal ? [vendorLocal] : []),
909
+ ...(orKey ? [orKey] : []),
817
910
  site,
818
911
  orCode,
819
912
  field,
820
913
  keptLocal,
914
+ ...(claudePlan ? [claudePlan] : []),
821
915
  );
822
916
  // Re-attached on every open: the actions row is markup, so a link left from
823
917
  // the last vendor would otherwise point at their key page. The link is built
@@ -978,7 +1072,7 @@ async function loadAccount() {
978
1072
  const name = body.name || body.email || t("account.signedIn");
979
1073
  row.appendChild(avatarFor(name, body.avatar));
980
1074
  row.appendChild(el("span", "who-name", name));
981
- row.appendChild(svgIcon("chevronsVertical", "acct-caret"));
1075
+ row.appendChild(svgIcon("caret", "acct-caret"));
982
1076
 
983
1077
  // The balance, once it can be read. Running out mid-turn is the failure this
984
1078
  // prevents: a number that appears only in a paywall is a number nobody sees
@@ -1016,6 +1110,7 @@ async function loadAccount() {
1016
1110
  return found ? found.name : t("account.teamUnnamed");
1017
1111
  };
1018
1112
  void getJson("/billing").then((state) => {
1113
+ if (state && !state.error) lastBilling = state;
1019
1114
  if (!state || !state.signedIn || state.unavailable) return;
1020
1115
  sellsCredits = true;
1021
1116
  // The plan, from the same answer the balance comes out of: "0 credits"
@@ -1156,8 +1251,31 @@ async function loadAccount() {
1156
1251
  // Whose credits the next call spends: Personal, or a team the account is
1157
1252
  // on, the current one ticked the way the model menu ticks its choice.
1158
1253
  // Only once there is a team to choose, since a list of one is no choice.
1254
+ // The team page on the site, in the real browser, where a team is started,
1255
+ // its people invited and its seats bought.
1256
+ const openTeams = () => {
1257
+ closeRowMenu();
1258
+ window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
1259
+ };
1159
1260
  if (knownTeams.length) {
1160
- items.push(el("div", "menu-head menu-section", t("account.workspace")));
1261
+ // Managing them sits on the section's own heading, at its right, rather
1262
+ // than as one more row that reads like a workspace to switch to.
1263
+ // The heading and the choices under it on one faint surface; the one
1264
+ // in use is marked by a stronger fill (see .menu-choices).
1265
+ const group = el("div", "menu-choices");
1266
+ const head = el("div", "menu-head menu-section menu-section-row");
1267
+ head.appendChild(el("span", null, t("account.workspace")));
1268
+ if (body.site) {
1269
+ const manage = el("button", "menu-section-action", t("account.manage"));
1270
+ manage.type = "button";
1271
+ manage.title = t("account.manageTeams");
1272
+ manage.addEventListener("click", (e) => {
1273
+ e.stopPropagation();
1274
+ openTeams();
1275
+ });
1276
+ head.appendChild(manage);
1277
+ }
1278
+ group.appendChild(head);
1161
1279
  const choice = (label, id) => {
1162
1280
  const chosen = (body.team || null) === id;
1163
1281
  const item = el("button", "menu-item" + (chosen ? " on" : ""));
@@ -1167,22 +1285,14 @@ async function loadAccount() {
1167
1285
  item.addEventListener("click", () => (chosen ? closeRowMenu() : billTo(id)));
1168
1286
  return item;
1169
1287
  };
1170
- items.push(choice(t("account.personal"), null));
1171
- knownTeams.forEach((team) => items.push(choice(team.name, team.id)));
1288
+ group.appendChild(choice(t("account.personal"), null));
1289
+ knownTeams.forEach((team) => group.appendChild(choice(team.name, team.id)));
1290
+ items.push(group);
1172
1291
  }
1173
- // The way to a workspace at all, for everyone signed in: Create a team for an
1174
- // account on none, Manage workspaces once there is one. Both are the team page
1175
- // on the site, in the real browser, where a team is started, its people
1176
- // invited and its seats bought.
1177
- if (body.site) {
1178
- items.push(
1179
- menuItem(t(knownTeams.length ? "account.manageTeams" : "account.createTeam"), {
1180
- run: () => {
1181
- closeRowMenu();
1182
- window.open(body.site + "/" + LOCALE + "/dashboard/team", "_blank", "noopener");
1183
- },
1184
- }),
1185
- );
1292
+ // The way to a workspace at all, for an account on none. Once there is one,
1293
+ // the section heading above carries Manage instead.
1294
+ if (body.site && !knownTeams.length) {
1295
+ items.push(menuItem(t("account.createTeam"), { run: openTeams }));
1186
1296
  }
1187
1297
  // The two pages this menu had no way to reach, under the workspace rows
1188
1298
  // they belong beside: what a team costs and how to ask for the parts of it
@@ -1190,6 +1300,16 @@ async function loadAccount() {
1190
1300
  // in the real browser like every other outbound item here — in the desktop
1191
1301
  // build the window handler routes them there rather than loading the site
1192
1302
  // inside the window the session lives in.
1303
+ // Feedback goes to the site as this account, so it lives in the menu that
1304
+ // only exists while one is signed in.
1305
+ items.push(
1306
+ menuItem(t("feedback.menu"), {
1307
+ run: () => {
1308
+ closeRowMenu();
1309
+ openFeedback();
1310
+ },
1311
+ }),
1312
+ );
1193
1313
  if (body.site) {
1194
1314
  items.push(
1195
1315
  menuItem(t("account.enterprise"), {
@@ -1215,6 +1335,7 @@ async function loadAccount() {
1215
1335
  cls: "accent",
1216
1336
  }),
1217
1337
  themeRow(),
1338
+ ...customThemeRows(),
1218
1339
  menuItem(t("account.signOut"), {
1219
1340
  run: () => {
1220
1341
  closeRowMenu();
@@ -1284,6 +1405,51 @@ function websiteSignIn(provider, onSignedIn, onUnavailable) {
1284
1405
  return row;
1285
1406
  }
1286
1407
 
1408
+ /**
1409
+ * A vendor whose only way in is its browser sign-in — ChatGPT, whose plan has
1410
+ * no key to paste. Offering a key field for it would be a dead end.
1411
+ */
1412
+ function keyless(provider) {
1413
+ return Boolean(provider.vendorSignIn) && !(provider.envVars || []).length;
1414
+ }
1415
+
1416
+ /**
1417
+ * A vendor's own browser sign-in: Sign in with ChatGPT, OpenRouter.
1418
+ *
1419
+ * Like the website sign-in, the answer does not come back to the click. The
1420
+ * vendor redirects to a listener on this machine, the server finishes the
1421
+ * login there, and models_changed is what tells this page — so the waiter is
1422
+ * registered only once the tab has actually been opened.
1423
+ */
1424
+ function vendorSignIn(provider, onSignedIn, onError) {
1425
+ const row = el("div", "keyrow");
1426
+ const go = el("button", "signin-site", provider.vendorSignIn);
1427
+ go.type = "button";
1428
+ go.addEventListener("click", (e) => {
1429
+ e.preventDefault();
1430
+ e.stopPropagation();
1431
+ go.disabled = true;
1432
+ void api("/signin/vendor", { provider: provider.id })
1433
+ .then((result) => {
1434
+ if (!result || result.error) {
1435
+ const message = (result && result.error) || "could not start the sign-in";
1436
+ if (onError) return void onError(message);
1437
+ return void addNotice(message, "err");
1438
+ }
1439
+ signedInWaiters.set(provider.id, onSignedIn || (() => {
1440
+ if (document.getElementById("nav-models").classList.contains("active")) void showModels();
1441
+ }));
1442
+ window.open(result.url, "_blank", "noopener");
1443
+ addNotice(t("models.signInOpened", { site: provider.label || provider.id }));
1444
+ })
1445
+ .finally(() => {
1446
+ go.disabled = false;
1447
+ });
1448
+ });
1449
+ row.appendChild(go);
1450
+ return row;
1451
+ }
1452
+
1287
1453
  /**
1288
1454
  * The sign-in itself, without a button attached to it.
1289
1455
  *
@@ -1420,6 +1586,67 @@ function applyTheme() {
1420
1586
  applyTheme();
1421
1587
 
1422
1588
  let pwInterval = "month";
1589
+ /**
1590
+ * The last billing answer this window saw — the account menu asks for it when
1591
+ * the account loads, and the paywall on every poll. The paywall opens on it
1592
+ * rather than on "Checking the balance…": the plans and their prices do not
1593
+ * change between one minute and the next, and a dialog that makes someone
1594
+ * wait for a price list it already had reads as slow. The fresh answer
1595
+ * replaces it the moment it lands.
1596
+ */
1597
+ let lastBilling = null;
1598
+ // Set by a click on the Yearly tab and spent by the render that follows it:
1599
+ // the yearly prices count down from twelve months at the monthly price once,
1600
+ // when the reader asks for the year. Not on every repaint — the balance poll
1601
+ // redraws this body every few seconds, and a number that kept counting would
1602
+ // read as a price that kept changing.
1603
+ let pwCount = false;
1604
+
1605
+ /**
1606
+ * The models a plan's credits reach, drawn as marks and names above the plans:
1607
+ * someone stopped mid-conversation by this dialog is deciding whether the
1608
+ * money buys the models they care about, and a name with its mark answers
1609
+ * that faster than a sentence. Named here rather than fetched: this screen has
1610
+ * only the billing catalogue, which knows plans and not models, and these are
1611
+ * the ones the site's pricing page leads with. The marks are inline for the
1612
+ * reason the model menu draws ours inline — no request to anyone to open a
1613
+ * paywall.
1614
+ */
1615
+ const PW_MODELS = [
1616
+ {
1617
+ key: "paywall.chat",
1618
+ models: [
1619
+ { label: "GPT-6 Astra", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>' },
1620
+ { label: "Opus 5.5", svg: '<svg viewBox="0 0 24 24"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"/></svg>' },
1621
+ { label: "DeepSeek V4.1", svg: '<svg viewBox="0 0 24 24"><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"/></svg>' },
1622
+ { label: "Kimi K3", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"/><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"/></svg>' },
1623
+ ],
1624
+ },
1625
+ {
1626
+ key: "paywall.media",
1627
+ models: [
1628
+ { label: "GPT Image 2.5", svg: '<svg fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>' },
1629
+ { label: "Seedance 2.5", svg: '<svg viewBox="0 0 24 24"><path d="M14.944 18.587l-1.704-.445V10.01l1.824-.462c1-.254 1.84-.461 1.88-.453.032 0 .056 2.235.056 4.972v4.973l-.176-.008c-.104 0-.952-.207-1.88-.446z" fill="#00C8D2" fill-rule="nonzero"/><path d="M7 16.542c0-2.736.024-4.98.064-4.98.032-.008.872.2 1.88.454l1.816.461-.016 4.05-.024 4.049-1.632.422c-.896.23-1.736.445-1.856.469L7 21.523v-4.98z" fill="#3C8CFF" fill-rule="nonzero"/><path d="M19.24 12.477c0-9.03.008-9.515.144-9.475.072.024.784.207 1.576.406.792.207 1.576.405 1.744.445l.296.08-.016 8.56-.024 8.568-1.624.414c-.888.23-1.728.437-1.856.47l-.24.055v-9.523z" fill="#78E6DC" fill-rule="nonzero"/><path d="M1 12.509c0-4.678.024-8.505.064-8.505.032 0 .872.207 1.872.454l1.824.461v7.582c0 4.16-.016 7.574-.032 7.574-.024 0-.872.215-1.88.47L1 21.013v-8.505z" fill="#325AB4"/></svg>' },
1630
+ ],
1631
+ },
1632
+ ];
1633
+
1634
+ /** A price that counts down to its own text, once. Reduced motion skips it. */
1635
+ function countDown(node, fromCents, toCents, currency, text) {
1636
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1637
+ const start = performance.now();
1638
+ const tick = (now) => {
1639
+ const p = Math.min(1, (now - start) / 700);
1640
+ const eased = 1 - Math.pow(1 - p, 3);
1641
+ // Whole units on the way down, so the cents do not flicker; the last
1642
+ // frame is the real string.
1643
+ const cents = Math.round((fromCents + (toCents - fromCents) * eased) / 100) * 100;
1644
+ if (!node.isConnected) return;
1645
+ node.textContent = p < 1 ? moneyOf(cents, currency) : text;
1646
+ if (p < 1) requestAnimationFrame(tick);
1647
+ };
1648
+ requestAnimationFrame(tick);
1649
+ }
1423
1650
 
1424
1651
  /**
1425
1652
  * @param buying Opened deliberately, rather than by a turn that could not be
@@ -1594,13 +1821,18 @@ function openPaywall(buying) {
1594
1821
  * months at the same price, and a "Save 0%" badge on it would be a worse
1595
1822
  * lie than silence.
1596
1823
  */
1597
- const savingOn = (plan) => {
1824
+ /** Twelve months of the same tier at the monthly price, where the year costs less. */
1825
+ const fullYearOf = (plan) => {
1598
1826
  if (plan.interval !== "year" || !plan.tier) return 0;
1599
1827
  const monthly = subs.find((p) => p.tier === plan.tier && p.interval === "month");
1600
1828
  if (!monthly || typeof monthly.amountCents !== "number" || typeof plan.amountCents !== "number") return 0;
1829
+ if ((monthly.currency || "usd").toLowerCase() !== (plan.currency || "usd").toLowerCase()) return 0;
1601
1830
  const full = monthly.amountCents * 12;
1602
- if (full <= 0 || plan.amountCents >= full) return 0;
1603
- return Math.round((1 - plan.amountCents / full) * 100);
1831
+ return full > 0 && plan.amountCents < full ? full : 0;
1832
+ };
1833
+ const savingOn = (plan) => {
1834
+ const full = fullYearOf(plan);
1835
+ return full ? Math.round((1 - plan.amountCents / full) * 100) : 0;
1604
1836
  };
1605
1837
 
1606
1838
  /** One row: what it is, how many credits, and how much money. */
@@ -1618,7 +1850,19 @@ function openPaywall(buying) {
1618
1850
  if (plan.popular) name.appendChild(el("span", "pw-tag", t("paywall.popular")));
1619
1851
  top.appendChild(name);
1620
1852
  const money = moneyOf(plan.amountCents, plan.currency);
1621
- if (money) top.appendChild(el("span", "pw-plan-price", money + (suffix || "")));
1853
+ if (money) {
1854
+ const price = el("span", "pw-plan-price");
1855
+ // A yearly rung that saves shows what it saves from: twelve months at
1856
+ // the monthly price, struck through, and — right after the switch to
1857
+ // the year — the price counting down from that number to its own.
1858
+ const full = fullYearOf(plan);
1859
+ if (full) price.appendChild(el("s", "pw-was", moneyOf(full, plan.currency)));
1860
+ const amountNode = el("span", null, money);
1861
+ price.appendChild(amountNode);
1862
+ if (suffix) price.appendChild(document.createTextNode(suffix));
1863
+ if (full && pwCount) countDown(amountNode, full, plan.amountCents, plan.currency, money);
1864
+ top.appendChild(price);
1865
+ }
1622
1866
  row.appendChild(top);
1623
1867
  // The pack's own label is already "2,000 credits", so repeating the
1624
1868
  // number underneath it said the same thing twice. Subscriptions need
@@ -1656,14 +1900,42 @@ function openPaywall(buying) {
1656
1900
  const other = subs.filter((p) => p.interval !== "month" && p.interval !== "year");
1657
1901
 
1658
1902
  if (subs.length) {
1903
+ // What the credits reach, before the plans that sell them: someone who
1904
+ // came here to keep chatting may not know the same balance runs the
1905
+ // other flagship models, and draws pictures and video too.
1906
+ const media = el("div", "pw-media");
1907
+ media.appendChild(el("div", "pw-media-head", t("paywall.included")));
1908
+ PW_MODELS.forEach((group) => {
1909
+ media.appendChild(el("span", "pw-media-kind", t(group.key)));
1910
+ const marks = el("span", "pw-media-models");
1911
+ group.models.forEach((model) => {
1912
+ const chip = el("span", "pw-media-model");
1913
+ const mark = el("span", "pw-media-mark");
1914
+ mark.innerHTML = model.svg;
1915
+ chip.appendChild(mark);
1916
+ chip.appendChild(document.createTextNode(model.label));
1917
+ marks.appendChild(chip);
1918
+ });
1919
+ media.appendChild(marks);
1920
+ });
1921
+ body.appendChild(media);
1922
+
1659
1923
  if (intervals.length > 1) {
1660
1924
  const toggle = el("div", "pw-toggle");
1925
+ // The best a year saves, on the tab itself: the reason to press it,
1926
+ // said before it is pressed.
1927
+ const best = Math.max(0, ...byInterval.year.map(savingOn));
1661
1928
  intervals.forEach((key) => {
1662
1929
  const tab = el("button", "pw-tab" + (key === pwInterval ? " on" : ""));
1663
1930
  tab.type = "button";
1664
1931
  tab.textContent = t(key === "year" ? "paywall.yearly" : "paywall.monthly");
1932
+ if (key === "year" && best > 0) {
1933
+ tab.appendChild(el("span", "pw-tab-save", t("paywall.upTo", { percent: best })));
1934
+ }
1665
1935
  tab.onclick = () => {
1936
+ if (pwInterval === key) return;
1666
1937
  pwInterval = key;
1938
+ pwCount = key === "year";
1667
1939
  render(state);
1668
1940
  };
1669
1941
  toggle.appendChild(tab);
@@ -1675,6 +1947,7 @@ function openPaywall(buying) {
1675
1947
  showing.concat(other).forEach((plan) => {
1676
1948
  list.appendChild(planRow(plan, t(plan.interval === "year" ? "paywall.perYear" : "paywall.perMonth")));
1677
1949
  });
1950
+ pwCount = false;
1678
1951
  body.appendChild(list);
1679
1952
  }
1680
1953
 
@@ -1725,6 +1998,7 @@ function openPaywall(buying) {
1725
1998
 
1726
1999
  const check = () =>
1727
2000
  getJson("/billing").then((state) => {
2001
+ if (state && !state.error) lastBilling = state;
1728
2002
  if (!dialog.open) return;
1729
2003
  if (!state || state.error) {
1730
2004
  return void render({ signedIn: true, unavailable: "unreachable" });
@@ -1743,8 +2017,21 @@ function openPaywall(buying) {
1743
2017
  render(state);
1744
2018
  });
1745
2019
 
1746
- body.replaceChildren(el("div", "cn-note", t("paywall.checking")));
1747
- if (!dialog.open) dialog.showModal();
2020
+ // The cards straight away from what is already known. The balance too when
2021
+ // it was already zero — the usual case, and the line then does not jump in
2022
+ // above the cards a moment later — but not a positive one: that number is
2023
+ // stale by definition, since running out is why this opened, and render
2024
+ // leaves the line out when it is undefined. A team's screen is all its
2025
+ // pooled number, so that one waits for the fresh answer.
2026
+ const cached = lastBilling;
2027
+ if (cached && cached.signedIn && !cached.unavailable && !cached.team
2028
+ && Array.isArray(cached.plans) && cached.plans.length) {
2029
+ if (!dialog.open) dialog.showModal();
2030
+ render({ ...cached, balanceMicro: Number(cached.balanceMicro) === 0 ? 0 : undefined });
2031
+ } else {
2032
+ body.replaceChildren(el("div", "cn-note", t("paywall.checking")));
2033
+ if (!dialog.open) dialog.showModal();
2034
+ }
1748
2035
  void check();
1749
2036
  if (paywallTimer) clearInterval(paywallTimer);
1750
2037
  paywallTimer = setInterval(() => void check(), PAYWALL_POLL_MS);
@@ -1891,5 +2178,261 @@ document.getElementById("nav-models").addEventListener("click", () => {
1891
2178
  panelBody.replaceChildren(panelLoading());
1892
2179
  void showModels();
1893
2180
  });
2181
+
2182
+ /**
2183
+ * The feedback dialog: a kind, a message, screenshots or recordings, and Send.
2184
+ *
2185
+ * Sent through this app's server, which holds the account's token and adds
2186
+ * the platform; the page adds the app version, which only the desktop shell
2187
+ * knows. Files are picked, pasted or dropped, and uploaded one at a time when
2188
+ * Send is pressed — not when attached, so a file removed again was never sent
2189
+ * anywhere. The result is said inside the dialog rather than as a notice in the
2190
+ * transcript, which is not where anybody sending feedback is looking.
2191
+ */
2192
+ const FEEDBACK_KINDS = ["bug", "idea", "other"];
2193
+ const FEEDBACK_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/quicktime"];
2194
+ const FEEDBACK_MAX_FILES = 5;
2195
+ const FEEDBACK_MAX_BYTES = 50 * 1024 * 1024;
2196
+ let feedbackKind = "bug";
2197
+ /** What is attached: the file, a preview URL, and once uploaded, what the server answered. */
2198
+ let feedbackFiles = [];
2199
+ let feedbackWired = false;
2200
+
2201
+ function feedbackSize(bytes) {
2202
+ return bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(bytes / 1024)) + " KB";
2203
+ }
2204
+
2205
+ function feedbackSay(text, error) {
2206
+ const status = document.getElementById("fb-status");
2207
+ status.textContent = text;
2208
+ status.style.color = error ? "var(--err)" : "";
2209
+ }
2210
+
2211
+ /** Take files from the picker, a paste or a drop, saying why any are refused. */
2212
+ function feedbackAdd(files) {
2213
+ for (const file of files) {
2214
+ if (feedbackFiles.length >= FEEDBACK_MAX_FILES) {
2215
+ feedbackSay(t("feedback.tooMany"), true);
2216
+ break;
2217
+ }
2218
+ if (!FEEDBACK_TYPES.includes(file.type)) {
2219
+ feedbackSay(t("feedback.badType", { name: file.name || "file" }), true);
2220
+ continue;
2221
+ }
2222
+ if (file.size > FEEDBACK_MAX_BYTES) {
2223
+ feedbackSay(t("feedback.tooBig", { name: file.name || "file" }), true);
2224
+ continue;
2225
+ }
2226
+ // A pasted screenshot arrives as "image.png" every time; give it a name
2227
+ // that tells two of them apart on the internal page.
2228
+ const name = file.name && file.name !== "image.png" ? file.name : "screenshot-" + (feedbackFiles.length + 1) + ".png";
2229
+ feedbackFiles.push({ file, name, preview: URL.createObjectURL(file), uploaded: null, state: null });
2230
+ }
2231
+ feedbackPaintFiles();
2232
+ }
2233
+
2234
+ function feedbackRemove(entry) {
2235
+ URL.revokeObjectURL(entry.preview);
2236
+ feedbackFiles = feedbackFiles.filter((other) => other !== entry);
2237
+ feedbackPaintFiles();
2238
+ }
2239
+
2240
+ function feedbackClearFiles() {
2241
+ for (const entry of feedbackFiles) URL.revokeObjectURL(entry.preview);
2242
+ feedbackFiles = [];
2243
+ feedbackPaintFiles();
2244
+ }
2245
+
2246
+ function feedbackPaintFiles() {
2247
+ const list = document.getElementById("fb-files");
2248
+ const tiles = feedbackFiles.map((entry) => {
2249
+ const video = entry.file.type.startsWith("video/");
2250
+ const tile = el("div", "fb-tile" + (video ? " is-video" : "") + (entry.state ? " is-" + entry.state : ""));
2251
+ tile.title = entry.name + " \u00b7 " + feedbackSize(entry.file.size);
2252
+ const thumb = el(video ? "video" : "img", "fb-thumb");
2253
+ thumb.src = entry.preview;
2254
+ if (video) {
2255
+ thumb.muted = true;
2256
+ thumb.preload = "metadata";
2257
+ tile.appendChild(thumb);
2258
+ tile.appendChild(el("span", "fb-play"));
2259
+ } else {
2260
+ thumb.alt = entry.name;
2261
+ tile.appendChild(thumb);
2262
+ }
2263
+ // Only while a send is in flight: one tile uploading, the ones before it done.
2264
+ if (entry.state === "uploading") tile.appendChild(el("span", "fb-spinner"));
2265
+ if (entry.state === "done") tile.appendChild(el("span", "fb-done", "\u2713"));
2266
+ const remove = el("button", "fb-remove", "\u00d7");
2267
+ remove.type = "button";
2268
+ remove.title = t("feedback.remove");
2269
+ remove.setAttribute("aria-label", t("feedback.remove") + " " + entry.name);
2270
+ remove.disabled = Boolean(entry.state);
2271
+ remove.addEventListener("click", () => feedbackRemove(entry));
2272
+ tile.appendChild(remove);
2273
+ return tile;
2274
+ });
2275
+ if (feedbackFiles.length < FEEDBACK_MAX_FILES) {
2276
+ const add = el("button", "fb-tile fb-add");
2277
+ add.type = "button";
2278
+ add.setAttribute("aria-label", t("feedback.attach"));
2279
+ add.title = t("feedback.attach");
2280
+ add.append(svgIcon("image", "fb-add-icon"), el("span", "fb-add-label", t("feedback.add")));
2281
+ add.addEventListener("click", () => document.getElementById("fb-file").click());
2282
+ tiles.push(add);
2283
+ }
2284
+ // What can go here and how much, beside the tiles rather than on a line of
2285
+ // its own: before anything is attached it says what to attach and how;
2286
+ // after, how many of the five are used.
2287
+ const caption = el("div", "fb-caption");
2288
+ caption.appendChild(el("span", "fb-caption-title", t("feedback.mediaTitle")));
2289
+ caption.appendChild(
2290
+ el(
2291
+ "span",
2292
+ "fb-caption-sub",
2293
+ feedbackFiles.length
2294
+ ? t("feedback.mediaCount", { n: feedbackFiles.length, max: FEEDBACK_MAX_FILES })
2295
+ : t("feedback.mediaHint"),
2296
+ ),
2297
+ );
2298
+ tiles.push(caption);
2299
+ list.replaceChildren(...tiles);
2300
+ }
2301
+
2302
+ /** Upload one file through this server; the answer is what the row stores. */
2303
+ async function feedbackUpload(entry) {
2304
+ if (entry.uploaded) return entry.uploaded;
2305
+ const response = await fetch(q("/feedback/upload"), {
2306
+ method: "POST",
2307
+ headers: { "content-type": entry.file.type, "x-filename": encodeURIComponent(entry.name) },
2308
+ body: entry.file,
2309
+ signal: AbortSignal.timeout(15 * 60 * 1000),
2310
+ }).catch((err) => ({ ok: false, json: async () => ({ error: String(err.message || err) }) }));
2311
+ const body = await response.json().catch(() => ({}));
2312
+ if (!response.ok || !body.path) throw new Error(body.error || "upload failed");
2313
+ // Kept, so a retry after a later file fails does not send this one twice.
2314
+ entry.uploaded = { path: body.path, name: body.name || entry.name, type: body.type || entry.file.type, size: body.size || entry.file.size };
2315
+ return entry.uploaded;
2316
+ }
2317
+
2318
+ /** The listeners that outlive one opening: the picker, paste and drop. */
2319
+ function wireFeedback(dialog) {
2320
+ if (feedbackWired) return;
2321
+ feedbackWired = true;
2322
+ const picker = document.getElementById("fb-file");
2323
+ picker.addEventListener("change", () => {
2324
+ feedbackAdd([...picker.files]);
2325
+ picker.value = "";
2326
+ });
2327
+ // A screenshot on the clipboard is the most common attachment of all.
2328
+ document.getElementById("fb-message").addEventListener("paste", (e) => {
2329
+ const files = [...(e.clipboardData ? e.clipboardData.files : [])];
2330
+ if (!files.length) return;
2331
+ e.preventDefault();
2332
+ feedbackAdd(files);
2333
+ });
2334
+ dialog.addEventListener("dragover", (e) => {
2335
+ if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return;
2336
+ e.preventDefault();
2337
+ dialog.classList.add("fb-dropping");
2338
+ });
2339
+ dialog.addEventListener("dragleave", (e) => {
2340
+ if (e.target === dialog) dialog.classList.remove("fb-dropping");
2341
+ });
2342
+ dialog.addEventListener("drop", (e) => {
2343
+ dialog.classList.remove("fb-dropping");
2344
+ if (!e.dataTransfer || !e.dataTransfer.files.length) return;
2345
+ e.preventDefault();
2346
+ feedbackAdd([...e.dataTransfer.files]);
2347
+ });
2348
+ }
2349
+
2350
+ function openFeedback() {
2351
+ const dialog = document.getElementById("feedback-dialog");
2352
+ const kinds = document.getElementById("fb-kinds");
2353
+ const message = document.getElementById("fb-message");
2354
+ const send = document.getElementById("fb-send");
2355
+ const cancel = document.getElementById("fb-cancel");
2356
+ wireFeedback(dialog);
2357
+
2358
+ const paintKinds = () => {
2359
+ kinds.replaceChildren(
2360
+ ...FEEDBACK_KINDS.map((kind) => {
2361
+ const chip = el("button", "fb-kind" + (kind === feedbackKind ? " on" : ""), t("feedback.kind." + kind));
2362
+ chip.type = "button";
2363
+ chip.setAttribute("role", "radio");
2364
+ chip.setAttribute("aria-checked", String(kind === feedbackKind));
2365
+ chip.addEventListener("click", () => {
2366
+ feedbackKind = kind;
2367
+ paintKinds();
2368
+ });
2369
+ return chip;
2370
+ }),
2371
+ );
2372
+ };
2373
+
2374
+ paintKinds();
2375
+ feedbackPaintFiles();
2376
+ message.placeholder = t("feedback.placeholder");
2377
+ feedbackSay("");
2378
+ send.disabled = false;
2379
+ send.textContent = t("feedback.send");
2380
+ cancel.onclick = () => dialog.close();
2381
+ // A draft — text and files — survives closing the dialog by accident; it is
2382
+ // cleared only once sent.
2383
+ send.onclick = async () => {
2384
+ const text = message.value.trim();
2385
+ if (!text) {
2386
+ feedbackSay(t("feedback.empty"), true);
2387
+ return void message.focus();
2388
+ }
2389
+ send.disabled = true;
2390
+ const attachments = [];
2391
+ try {
2392
+ for (let i = 0; i < feedbackFiles.length; i++) {
2393
+ const entry = feedbackFiles[i];
2394
+ send.textContent = t("feedback.uploading", { n: i + 1, total: feedbackFiles.length });
2395
+ entry.state = entry.uploaded ? "done" : "uploading";
2396
+ feedbackPaintFiles();
2397
+ attachments.push(await feedbackUpload(entry));
2398
+ entry.state = "done";
2399
+ feedbackPaintFiles();
2400
+ }
2401
+ } catch (err) {
2402
+ // Back to editable; files already uploaded keep their answer, so a
2403
+ // retry only sends the rest.
2404
+ for (const entry of feedbackFiles) entry.state = null;
2405
+ feedbackPaintFiles();
2406
+ send.disabled = false;
2407
+ send.textContent = t("feedback.send");
2408
+ return void feedbackSay(String(err.message || err), true);
2409
+ }
2410
+ send.textContent = t("feedback.sending");
2411
+ const bridge = window.oharness;
2412
+ const result = await api("/feedback", {
2413
+ kind: feedbackKind,
2414
+ message: text,
2415
+ source: bridge && bridge.desktop ? "desktop" : "web",
2416
+ appVersion: (bridge && bridge.app && bridge.app.version) || undefined,
2417
+ attachments,
2418
+ });
2419
+ send.textContent = t("feedback.send");
2420
+ if (!result || result.error) {
2421
+ for (const entry of feedbackFiles) entry.state = null;
2422
+ feedbackPaintFiles();
2423
+ send.disabled = false;
2424
+ return void feedbackSay((result && result.error) || "could not send", true);
2425
+ }
2426
+ track("feedback_sent", { kind: feedbackKind, attachments: attachments.length });
2427
+ message.value = "";
2428
+ feedbackClearFiles();
2429
+ feedbackSay(t("feedback.sent"));
2430
+ setTimeout(() => {
2431
+ if (dialog.open) dialog.close();
2432
+ }, 1400);
2433
+ };
2434
+ if (!dialog.open) dialog.showModal();
2435
+ message.focus();
2436
+ }
1894
2437
  `;
1895
2438
  //# sourceMappingURL=account.js.map