local-operator-ui 0.30.35 → 0.31.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 (101) hide show
  1. package/out/main/index.js +823 -9
  2. package/out/preload/index.js +77 -0
  3. package/out/renderer/assets/{_basePickBy-DBMIejg7.js → _basePickBy-C4iSwCk_.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-BcqrDipQ.js → _baseUniq-CoR3b7Pb.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-CKouhYcx.js → agent-details-page-BrVmQlq3.js} +2 -2
  6. package/out/renderer/assets/agent-hub-page-DhQgRhtE.js +6 -0
  7. package/out/renderer/assets/agents-page-amh_zIfd.js +2 -0
  8. package/out/renderer/assets/{arc-C7db1al4.js → arc-BgzcPy1_.js} +1 -1
  9. package/out/renderer/assets/architectureDiagram-IEHRJDOE-DBQelo90.js +36 -0
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DFXKZ0Pd.js → blockDiagram-JOT3LUYC-B6Tj9M-m.js} +1 -1
  11. package/out/renderer/assets/book-open-BAndGvXv.js +6 -0
  12. package/out/renderer/assets/brand-mark-Bv0Hu-Cz.png +0 -0
  13. package/out/renderer/assets/browser-page-DXM0wVmK.js +1 -0
  14. package/out/renderer/assets/browser-webauthn-prompt-DRUBwhbd.js +1 -0
  15. package/out/renderer/assets/{c4Diagram-VJAJSXHY-DREcsmeA.js → c4Diagram-VJAJSXHY-DZffC4DW.js} +1 -1
  16. package/out/renderer/assets/channel-YJZLLu8D.js +1 -0
  17. package/out/renderer/assets/{chunk-4BMEZGHF-1AW6sHDl.js → chunk-4BMEZGHF-CzJZJ_Kr.js} +1 -1
  18. package/out/renderer/assets/{chunk-A2AXSNBT-BHYmovGQ.js → chunk-A2AXSNBT-FDhQL1W7.js} +1 -1
  19. package/out/renderer/assets/{chunk-AEK57VVT-VSZdczYO.js → chunk-AEK57VVT-BSVY08cz.js} +1 -1
  20. package/out/renderer/assets/{chunk-D6G4REZN-CHy8bDy2.js → chunk-D6G4REZN-3NnvkvEu.js} +1 -1
  21. package/out/renderer/assets/{chunk-RZ5BOZE2-D7gxwymY.js → chunk-RZ5BOZE2-B6vH5nHy.js} +1 -1
  22. package/out/renderer/assets/{chunk-XZIHB7SX-Bu9gT-3-.js → chunk-XZIHB7SX-DbDfwswt.js} +1 -1
  23. package/out/renderer/assets/classDiagram-GIVACNV2-BA8uIyub.js +1 -0
  24. package/out/renderer/assets/classDiagram-v2-COTLJTTW-BA8uIyub.js +1 -0
  25. package/out/renderer/assets/clone-8Z28G76s.js +1 -0
  26. package/out/renderer/assets/compact-pagination-DacmIceV.js +1 -0
  27. package/out/renderer/assets/{console-mirror-Bjpk3DAz.js → console-mirror-CzhHBJ89.js} +14 -14
  28. package/out/renderer/assets/{consoleCapture-B-P-8vPw.js → consoleCapture-Ff67EfR-.js} +1 -1
  29. package/out/renderer/assets/dagre-OKDRZEBW-DcDJ_xq8.js +4 -0
  30. package/out/renderer/assets/diagram-SSKATNLV-B_9mY_Gx.js +43 -0
  31. package/out/renderer/assets/diagram-VNBRO52H-N-VUNAsp.js +24 -0
  32. package/out/renderer/assets/en-US-BkS8Ixxl.js +1 -0
  33. package/out/renderer/assets/{erDiagram-Q7BY3M3F-Dzwd2fLB.js → erDiagram-Q7BY3M3F-CoBFM5wz.js} +1 -1
  34. package/out/renderer/assets/error-boundary-OyyPDgmi.js +126 -0
  35. package/out/renderer/assets/{flowDiagram-4HSFHLVR-DpqxYeov.js → flowDiagram-4HSFHLVR-U31R127Z.js} +1 -1
  36. package/out/renderer/assets/{ganttDiagram-APWFNJXF-CJqzp0mX.js → ganttDiagram-APWFNJXF-B6HIP3LX.js} +3 -3
  37. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-CLTQlpsI.js +65 -0
  38. package/out/renderer/assets/{graph-BKZN53d3.js → graph-CPZjs5mz.js} +1 -1
  39. package/out/renderer/assets/index-CE03vul4.js +7 -0
  40. package/out/renderer/assets/index-CSPo12AE.css +1 -0
  41. package/out/renderer/assets/{index-C6pxQu_W.js → index-CXCGQLAf.js} +404 -453
  42. package/out/renderer/assets/index-DapmdGGl.css +1 -0
  43. package/out/renderer/assets/index-Dgpr-4wJ.js +1 -0
  44. package/out/renderer/assets/infoDiagram-PH2N3AL5-Bn8r1GBu.js +2 -0
  45. package/out/renderer/assets/installer-D8YScrG7.js +1 -0
  46. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BG7areA5.js → journeyDiagram-U35MCT3I-B90sRmxY.js} +1 -1
  47. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-BgMtdkNP.js → kanban-definition-NDS4AKOZ-CdQk4JVL.js} +1 -1
  48. package/out/renderer/assets/{layout-Bw_CgLbc.js → layout-DKBN0F9A.js} +1 -1
  49. package/out/renderer/assets/{legacy-agents-page-NU8mTf6U.js → legacy-agents-page-L5nl_vP8.js} +4 -4
  50. package/out/renderer/assets/{mermaid.core-Dz6HoxZc.js → mermaid.core-D22o0-Xw.js} +19 -19
  51. package/out/renderer/assets/mindmap-definition-ALO5MXBD-lMzKnedt.js +95 -0
  52. package/out/renderer/assets/{page-header-CYtvpazS.js → page-header-BosNuEEY.js} +1 -1
  53. package/out/renderer/assets/{parseISO-BIBIiSY3.js → parseISO-CG_r30eq.js} +2 -2
  54. package/out/renderer/assets/{pieDiagram-IB7DONF6-DR4ycBv-.js → pieDiagram-IB7DONF6-BPCgHz97.js} +1 -1
  55. package/out/renderer/assets/publication-validation-B1Ho4y5X.js +1 -0
  56. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-DeXlOkVg.js → quadrantDiagram-7GDLP6J5-BN7q6nRl.js} +1 -1
  57. package/out/renderer/assets/{radar-MK3ICKWK-Dr73Vn9F.js → radar-MK3ICKWK-DVteWlkm.js} +1 -1
  58. package/out/renderer/assets/radient-auth-buttons-DsuZI5F-.js +6 -0
  59. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-DHeR1FW9.js → requirementDiagram-KVF5MWMF-B19ClXnf.js} +1 -1
  60. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-gohXveM_.js +10 -0
  61. package/out/renderer/assets/{schedules-page-C_-aK6li.js → schedules-page-BJYsnsA_.js} +8 -8
  62. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-BBJ035AA.js → sequenceDiagram-X6HHIX6F-DAxdPXaF.js} +1 -1
  63. package/out/renderer/assets/settings-page-Cn4iJPhy.js +307 -0
  64. package/out/renderer/assets/stateDiagram-DGXRK772-CmAoNGXc.js +1 -0
  65. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DH3xoVHZ.js +1 -0
  66. package/out/renderer/assets/{timeline-definition-BDJGKUSR-BbDHYeOO.js → timeline-definition-BDJGKUSR-Cxg2i86G.js} +1 -1
  67. package/out/renderer/assets/use-download-agent-mutation-B3fB2cnC.js +61 -0
  68. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-B4F8fbBE.js → xychartDiagram-VJFVF3MP-Bm7vUCym.js} +1 -1
  69. package/out/renderer/console-capture.html +4 -4
  70. package/out/renderer/index.html +6 -6
  71. package/out/renderer/installer.html +4 -4
  72. package/package.json +3 -3
  73. package/out/renderer/assets/agent-hub-page-CVdr01hB.js +0 -6
  74. package/out/renderer/assets/agents-page-HLrle3JE.js +0 -2
  75. package/out/renderer/assets/architectureDiagram-IEHRJDOE-CIccpocP.js +0 -36
  76. package/out/renderer/assets/browser-page-DA_7uhAS.js +0 -1
  77. package/out/renderer/assets/browser-webauthn-prompt-DIhdYBWb.js +0 -1
  78. package/out/renderer/assets/channel-Bpdhm4cc.js +0 -1
  79. package/out/renderer/assets/classDiagram-GIVACNV2-Dy2svcuX.js +0 -1
  80. package/out/renderer/assets/classDiagram-v2-COTLJTTW-Dy2svcuX.js +0 -1
  81. package/out/renderer/assets/clone-DnsDN_54.js +0 -1
  82. package/out/renderer/assets/compact-pagination-BRR4OtYY.js +0 -1
  83. package/out/renderer/assets/dagre-OKDRZEBW-CeR0cM5J.js +0 -4
  84. package/out/renderer/assets/diagram-SSKATNLV-Dhu6_Dgz.js +0 -43
  85. package/out/renderer/assets/diagram-VNBRO52H-R0bWQ_Bk.js +0 -24
  86. package/out/renderer/assets/gitGraphDiagram-7IBYFJ6S-PSAaXIbu.js +0 -65
  87. package/out/renderer/assets/icon-Cf2gAQ3W.js +0 -126
  88. package/out/renderer/assets/index-BIOY3qsR.css +0 -1
  89. package/out/renderer/assets/index-BV47Bsxl.js +0 -7
  90. package/out/renderer/assets/index-ByGvbPtr.css +0 -1
  91. package/out/renderer/assets/index-CeIHQLkR.js +0 -1
  92. package/out/renderer/assets/infoDiagram-PH2N3AL5-0WYVxV0b.js +0 -2
  93. package/out/renderer/assets/installer-DIgdF65j.js +0 -1
  94. package/out/renderer/assets/mindmap-definition-ALO5MXBD-C9GjbpP_.js +0 -95
  95. package/out/renderer/assets/radient-auth-buttons--2wV70ya.js +0 -6
  96. package/out/renderer/assets/sankeyDiagram-QLVOVGJD-BwC3hgaf.js +0 -10
  97. package/out/renderer/assets/settings-page-BxyC2IfK.js +0 -307
  98. package/out/renderer/assets/stateDiagram-DGXRK772-DAU2jfYz.js +0 -1
  99. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-DJY1Hfi_.js +0 -1
  100. package/out/renderer/assets/use-agent-like-mutation-asZxgbHQ.js +0 -1
  101. /package/out/renderer/assets/{index-7OErfuvQ.js → index-Brz4xf_N.js} +0 -0
package/out/main/index.js CHANGED
@@ -17,7 +17,7 @@ const promises = require("node:fs/promises");
17
17
  const node_net = require("node:net");
18
18
  const headless = require("@xterm/headless");
19
19
  const node_module = require("node:module");
20
- const node_http = require("node:http");
20
+ const http = require("node:http");
21
21
  const https = require("node:https");
22
22
  const electronUpdater = require("electron-updater");
23
23
  function _interopNamespaceDefault(e) {
@@ -37,6 +37,7 @@ function _interopNamespaceDefault(e) {
37
37
  return Object.freeze(n);
38
38
  }
39
39
  const path__namespace = /* @__PURE__ */ _interopNamespaceDefault(path$1);
40
+ const http__namespace = /* @__PURE__ */ _interopNamespaceDefault(http);
40
41
  const https__namespace = /* @__PURE__ */ _interopNamespaceDefault(https);
41
42
  const icon = path.join(__dirname, "../../resources/icon.png");
42
43
  const BACKEND_STATUS_CHANNEL = "backend-status";
@@ -389,7 +390,18 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
389
390
  * actionable is strictly additive, and a `null` here would be a
390
391
  * different request for every caller that never asked.
391
392
  */
392
- model: modelSelection.optional()
393
+ model: modelSelection.optional(),
394
+ /*
395
+ * The id a `sessions.draft` mint handed the pane, when it has one: the
396
+ * create then adopts that id (and the runtime already engaged for it)
397
+ * instead of minting a fresh session id. OMITTED when the pane never
398
+ * minted — an older backend, a draft the user sent before the first
399
+ * keystroke's mint answered, or a set of fields that changed since the
400
+ * mint (see the store's drop rule) — so the body is byte-for-byte the
401
+ * request this op sent before the draft could be warmed, and a backend
402
+ * that cannot resolve the id mints fresh rather than failing the send.
403
+ */
404
+ draftId: sessionId.optional()
393
405
  }).strict(),
394
406
  /*
395
407
  * What a NEW conversation's readings would be, without creating anything.
@@ -423,6 +435,49 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
423
435
  first turn will actually get. */
424
436
  model: modelSelection.optional()
425
437
  }).strict(),
438
+ /*
439
+ * Mint the id a NEW chat's runtime is warmed and then born on, from the
440
+ * pane's first keystroke.
441
+ *
442
+ * WHY THE OP EXISTS. A draft pane has no session to address, so there is
443
+ * nothing to warm: `sessions.warm` needs an id, and the multi-second engage
444
+ * the first send pays is exactly what the draft cannot pre-empt without one.
445
+ * The mint allocates that id and registers it with the daemon (fast, no
446
+ * engage), and the id then unlocks the same three doors a session pane uses
447
+ * — `events`, `watch` (the lease) and `warm` — through a deliberately narrow
448
+ * allow-list on the backend. `sessions.create` adopts the id on send, so the
449
+ * conversation the user lands in IS the one that was warmed; a daemon that
450
+ * has never seen the id (restart, expiry, eviction) mints fresh and the send
451
+ * works exactly as it did before this op existed.
452
+ *
453
+ * THE MINT ENGAGES NOTHING, and that is the lifetime design rather than an
454
+ * omission: the pane's own subscription and watch lease hold the bridge that
455
+ * keeps a warm alive, exactly as they do for a session, so abandoning the
456
+ * pane cancels an in-flight warm through the same `_detach` and a runtime
457
+ * nobody holds is reaped by the residency drain. There is no second, warmer-
458
+ * owned lifetime to get wrong.
459
+ *
460
+ * `requestId` IS A RECEIPT KEY, unlike `sessions.preview`'s token: a mint is
461
+ * fired once per pane, and a retry — a fast second keystroke, a lost
462
+ * response — must replay the SAME id, because two ids for one pane would
463
+ * warm two runtimes and leave a registry entry nobody can ever consume.
464
+ *
465
+ * The body is `sessions.create`'s first half, deliberately: same `cwd` bounds,
466
+ * same optional `target`, same optional `model` and the same 422 for an
467
+ * unresolvable profile. The pane is asking the question it will ask for real
468
+ * on the first send, so both derive from one selection and cannot disagree.
469
+ *
470
+ * Deliberately NOT a `MESSAGE_OPS` member (see `desktopRequestByteBudget`):
471
+ * a path and two optional short ids are not prose, so the mint costs the
472
+ * control budget — which is what lets a KEYSTROKE issue it.
473
+ */
474
+ zod.z.object({
475
+ op: zod.z.literal("sessions.draft"),
476
+ requestId,
477
+ cwd: zod.z.string().min(1).max(4096),
478
+ target: target.optional(),
479
+ model: modelSelection.optional()
480
+ }).strict(),
426
481
  zod.z.object({ op: zod.z.literal("sessions.get"), sessionId }).strict(),
427
482
  zod.z.object({
428
483
  op: zod.z.literal("sessions.history"),
@@ -859,6 +914,29 @@ const desktopRequestSchema = zod.z.discriminatedUnion("op", [
859
914
  untilMs: zod.z.number().int().nonnegative().optional(),
860
915
  days: zod.z.number().int().min(1).max(366).optional()
861
916
  }).strict(),
917
+ /*
918
+ * The per-model rate table's own read, and a SEPARATE op on purpose.
919
+ *
920
+ * Its rows come from a grouped scan of the RAW LEDGER rather than from the
921
+ * rollup `analytics.get` reads, which is what makes it cover the operator's
922
+ * whole existing history rather than only the days since the rollup shipped
923
+ * — and is also what makes it cost seconds on a large ledger. Riding
924
+ * `analytics.get` would add that scan to every analytics panel load,
925
+ * including the ones that never scroll to the table, so it is fetched on its
926
+ * own and its wait is bounded and stated on its own section.
927
+ *
928
+ * Same arguments as `analytics.get`, same `.strict()` door, same window
929
+ * bounds: the two ops are windowed by the same `since_ms`/`until_ms`, which
930
+ * is the one property that lets a reader hold a row here against the
931
+ * headline Total above it.
932
+ */
933
+ zod.z.object({
934
+ op: zod.z.literal("analytics.models"),
935
+ sessionId: sessionId.optional(),
936
+ sinceMs: zod.z.number().int().nonnegative().optional(),
937
+ untilMs: zod.z.number().int().nonnegative().optional(),
938
+ days: zod.z.number().int().min(1).max(366).optional()
939
+ }).strict(),
862
940
  /*
863
941
  * The two diagnostics reads. They ride their own capability key
864
942
  * (`diagnostics`) rather than the catalogue one, because `/analytics` and
@@ -1291,6 +1369,15 @@ const DESKTOP_CONTROL_DEADLINE_MS = 2e4;
1291
1369
  const DESKTOP_LONG_READ_DEADLINE_MS = 9e4;
1292
1370
  const LEDGER_READ_OPS = /* @__PURE__ */ new Set([
1293
1371
  "analytics.get",
1372
+ /*
1373
+ * The worst case of the same shape, and the reason the set is spelled by
1374
+ * shape rather than by measured cost: `/analytics/models` is a grouped scan
1375
+ * of the raw ledger with no covering index, measured in SECONDS on a 1.95 M
1376
+ * row ledger where `analytics.get` measures 12-40 s cold. On the control
1377
+ * budget it would be abandoned mid-read on nearly every open, so it is here
1378
+ * beside the op it is a slower sibling of.
1379
+ */
1380
+ "analytics.models",
1294
1381
  "sessions.report"
1295
1382
  ]);
1296
1383
  const PROVIDER_READ_OPS = /* @__PURE__ */ new Set(["usage.get"]);
@@ -1307,6 +1394,7 @@ const READ_ONLY_OPS = /* @__PURE__ */ new Set([
1307
1394
  "capabilities",
1308
1395
  "accounts.list",
1309
1396
  "analytics.get",
1397
+ "analytics.models",
1310
1398
  "commands.entities",
1311
1399
  "commands.list",
1312
1400
  "config.get",
@@ -1346,6 +1434,7 @@ const READ_ONLY_OPS = /* @__PURE__ */ new Set([
1346
1434
  ]);
1347
1435
  const PANEL_READ_OPS = /* @__PURE__ */ new Set([
1348
1436
  "analytics.get",
1437
+ "analytics.models",
1349
1438
  "usage.get",
1350
1439
  "sessions.report",
1351
1440
  "info.get",
@@ -1493,7 +1582,10 @@ function desktopEndpoint(request) {
1493
1582
  request_id: request.requestId,
1494
1583
  cwd: request.cwd,
1495
1584
  ...request.target ? { target: request.target } : {},
1496
- ...request.model ? { model: request.model } : {}
1585
+ ...request.model ? { model: request.model } : {},
1586
+ // Omitted, not nulled, when the pane has no minted id: see the field's
1587
+ // own note for the byte-identity promise this keeps.
1588
+ ...request.draftId ? { draft_id: request.draftId } : {}
1497
1589
  }
1498
1590
  };
1499
1591
  case "sessions.preview":
@@ -1507,6 +1599,17 @@ function desktopEndpoint(request) {
1507
1599
  ...request.model ? { model: request.model } : {}
1508
1600
  }
1509
1601
  };
1602
+ case "sessions.draft":
1603
+ return {
1604
+ path: "/v1/desktop/sessions/draft",
1605
+ method: "POST",
1606
+ body: {
1607
+ request_id: request.requestId,
1608
+ cwd: request.cwd,
1609
+ ...request.target ? { target: request.target } : {},
1610
+ ...request.model ? { model: request.model } : {}
1611
+ }
1612
+ };
1510
1613
  case "sessions.get":
1511
1614
  return {
1512
1615
  path: `/v1/desktop/sessions/${request.sessionId}`,
@@ -1902,6 +2005,25 @@ function desktopEndpoint(request) {
1902
2005
  query.set("until_ms", String(request.untilMs));
1903
2006
  return { path: `/v1/desktop/analytics?${query}`, method: "GET" };
1904
2007
  }
2008
+ /*
2009
+ * `/analytics/models`, NOT `/analytics?models=1`.
2010
+ *
2011
+ * The path segment is what keeps the two reads separable at the
2012
+ * transport: `desktopRequestDeadlineMs` sizes a wait from the op, and an
2013
+ * op that sometimes means "the cheap rollup" and sometimes "a 1.95 M-row
2014
+ * ledger scan" cannot have one honest budget. The two spellings of the
2015
+ * query string below are deliberately identical to `analytics.get`'s, so
2016
+ * the server resolves one window from either.
2017
+ */
2018
+ case "analytics.models": {
2019
+ const query = new URLSearchParams({ days: String(request.days ?? 30) });
2020
+ if (request.sessionId) query.set("session_id", request.sessionId);
2021
+ if (request.sinceMs !== void 0)
2022
+ query.set("since_ms", String(request.sinceMs));
2023
+ if (request.untilMs !== void 0)
2024
+ query.set("until_ms", String(request.untilMs));
2025
+ return { path: `/v1/desktop/analytics/models?${query}`, method: "GET" };
2026
+ }
1905
2027
  case "info.get":
1906
2028
  return { path: "/v1/desktop/info", method: "GET" };
1907
2029
  case "sessions.report": {
@@ -2136,6 +2258,115 @@ function readLaunchTarget(argv) {
2136
2258
  if (argv.includes(OPEN_CATALOGUE_FLAG)) return { kind: "catalogue" };
2137
2259
  return { kind: "restore" };
2138
2260
  }
2261
+ const WINDOW_CHROME_MODES = ["integrated", "native"];
2262
+ const WINDOW_CHROME_FLAG = "--window-chrome";
2263
+ const WINDOW_CHROME_ENV = "LOCAL_OPERATOR_UI_WINDOW_CHROME";
2264
+ const WINDOW_CHROME_ARG = "--lo-window-chrome";
2265
+ const WINDOW_CHROME_HEIGHT = 40;
2266
+ const WINDOW_CHROME_TRANSPARENT = "#00000000";
2267
+ const DEFAULT_WINDOW_CHROME_COLORS = {
2268
+ ground: "#22201c",
2269
+ symbol: "#c2bcaf",
2270
+ symbolInactive: "#a6a091"
2271
+ };
2272
+ function chromePlatformFor(platform) {
2273
+ if (platform === "darwin") return "mac";
2274
+ if (platform === "win32") return "win";
2275
+ return "linux";
2276
+ }
2277
+ function parseWindowChromeMode(value) {
2278
+ if (value === void 0) return null;
2279
+ const normalized = value.trim().toLowerCase();
2280
+ return WINDOW_CHROME_MODES.includes(normalized) ? normalized : null;
2281
+ }
2282
+ function readWindowChromeFlag(argv, name = WINDOW_CHROME_FLAG) {
2283
+ let found = false;
2284
+ let valueless = false;
2285
+ let value;
2286
+ for (let index = 0; index < argv.length; index += 1) {
2287
+ const argument = argv[index];
2288
+ if (argument === name) {
2289
+ const next = argv[index + 1];
2290
+ found = true;
2291
+ if (next === void 0 || next.startsWith("--")) {
2292
+ valueless = true;
2293
+ continue;
2294
+ }
2295
+ value = next;
2296
+ continue;
2297
+ }
2298
+ if (argument.startsWith(`${name}=`)) {
2299
+ found = true;
2300
+ value = argument.slice(name.length + 1);
2301
+ }
2302
+ }
2303
+ return { found, value, valueless };
2304
+ }
2305
+ function resolveWindowChrome(input) {
2306
+ const problems = [];
2307
+ const argv = input.argv ?? [];
2308
+ const env = input.env ?? {};
2309
+ const persisted = input.persisted ?? null;
2310
+ const flag = readWindowChromeFlag(argv);
2311
+ if (flag.found) {
2312
+ const parsed = parseWindowChromeMode(flag.value);
2313
+ if (parsed !== null) return { mode: parsed, source: "argument", problems };
2314
+ if (flag.valueless) {
2315
+ problems.push(
2316
+ `${WINDOW_CHROME_FLAG} was given with no value and names nothing`
2317
+ );
2318
+ } else {
2319
+ problems.push(
2320
+ `${WINDOW_CHROME_FLAG}=${flag.value} is not one of ${WINDOW_CHROME_MODES.join(", ")}`
2321
+ );
2322
+ }
2323
+ }
2324
+ const fromEnv = env[WINDOW_CHROME_ENV];
2325
+ if (fromEnv !== void 0 && fromEnv.trim() !== "") {
2326
+ const parsed = parseWindowChromeMode(fromEnv);
2327
+ if (parsed !== null)
2328
+ return { mode: parsed, source: "environment", problems };
2329
+ problems.push(
2330
+ `${WINDOW_CHROME_ENV}=${fromEnv} is not one of ${WINDOW_CHROME_MODES.join(", ")}`
2331
+ );
2332
+ }
2333
+ if (persisted !== null)
2334
+ return { mode: persisted, source: "persisted", problems };
2335
+ return { mode: "integrated", source: "default", problems };
2336
+ }
2337
+ function windowChromeArgument(facts) {
2338
+ return `${WINDOW_CHROME_ARG}=${facts.platform}:${facts.mode}`;
2339
+ }
2340
+ const WINDOW_CHROME_REPORT_CHANNEL = "window-chrome:report";
2341
+ const WINDOW_CHROME_STATE_CHANNEL = "window-chrome:state";
2342
+ const WINDOW_CHROME_MENU_CHANNEL = "window-chrome:popup-app-menu";
2343
+ const WINDOW_CHROME_FILE = "window-chrome.json";
2344
+ function windowChromeFactsFor(platform, mode) {
2345
+ return { platform: chromePlatformFor(platform), mode };
2346
+ }
2347
+ function windowChromeArgumentFor(platform, mode) {
2348
+ return windowChromeArgument(windowChromeFactsFor(platform, mode));
2349
+ }
2350
+ function resolveWindowChromeForLaunch(input) {
2351
+ const resolved = resolveWindowChrome({
2352
+ argv: input.argv,
2353
+ env: input.env,
2354
+ persisted: input.persisted?.mode ?? null
2355
+ });
2356
+ const problems = [...resolved.problems];
2357
+ const macRequestedNative = input.platform === "darwin" && resolved.mode === "native";
2358
+ if (macRequestedNative) {
2359
+ problems.push(
2360
+ "macOS has no native-frame fallback (the frame options are #462's shipped design and the renderer's 32px lane depends on them), so --window-chrome=native is ignored here: the app runs integrated"
2361
+ );
2362
+ }
2363
+ return {
2364
+ mode: macRequestedNative ? "integrated" : resolved.mode,
2365
+ colors: input.persisted?.colors ?? DEFAULT_WINDOW_CHROME_COLORS,
2366
+ source: input.persisted?.colors ? `${resolved.source}+persisted-colors` : resolved.source,
2367
+ problems
2368
+ };
2369
+ }
2139
2370
  const SERVE_RUN_DIRNAME = "run/serve";
2140
2371
  const REAPED_DIRNAME = "reaped";
2141
2372
  const REAPED_MAX_FILES = 200;
@@ -5376,9 +5607,9 @@ const SILENCE_TIMEOUT_MS = 45e3;
5376
5607
  const WATCHDOG_TICK_MS = 1e3;
5377
5608
  const MAX_FRAME_BYTES$1 = 8 * 1024 * 1024;
5378
5609
  const LINE_ENDING = /\r\n|\n|\r/;
5379
- const BLANK_LINE = /(?:\r\n|\n|\r)(?:\r\n|\n|\r)/;
5610
+ const BLANK_LINE$1 = /(?:\r\n|\n|\r)(?:\r\n|\n|\r)/;
5380
5611
  function nextRecord(buffer) {
5381
- const blank = BLANK_LINE.exec(buffer);
5612
+ const blank = BLANK_LINE$1.exec(buffer);
5382
5613
  if (blank === null) return null;
5383
5614
  const data = [];
5384
5615
  for (const line of buffer.slice(0, blank.index).split(LINE_ENDING)) {
@@ -7350,8 +7581,9 @@ function runBounded(command, args, env, bounds) {
7350
7581
  );
7351
7582
  });
7352
7583
  }
7584
+ const NEWLINES = /\r?\n/;
7353
7585
  const errorTail = (text, lines = 3) => {
7354
- const kept = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
7586
+ const kept = text.split(NEWLINES).map((line) => line.trim()).filter(Boolean);
7355
7587
  if (kept.length === 0) return "";
7356
7588
  const tail = kept.slice(-lines).join(" | ");
7357
7589
  return kept.length > lines ? `… ${tail}` : tail;
@@ -21025,7 +21257,7 @@ function validateRequest(raw) {
21025
21257
  };
21026
21258
  }
21027
21259
  async function startRpcServer(options) {
21028
- const server = node_http.createServer((req, res) => {
21260
+ const server = http.createServer((req, res) => {
21029
21261
  void handle(req, res, options);
21030
21262
  });
21031
21263
  const bound = await new Promise(
@@ -23868,6 +24100,41 @@ function describeTelemetryLaunch(decision) {
23868
24100
  const detail = [decision.offReason, ...decision.problems].filter((part) => part !== null && part !== void 0).join("; ");
23869
24101
  return `[telemetry] off: ${detail}`;
23870
24102
  }
24103
+ function titlebarOptions(platform, chrome) {
24104
+ if (platform === "darwin") {
24105
+ return { titleBarStyle: "hidden" };
24106
+ }
24107
+ if (platform !== "win32" && platform !== "linux") {
24108
+ return {};
24109
+ }
24110
+ if (chrome.mode === "native") return {};
24111
+ const chromePlatform = chromePlatformFor(platform);
24112
+ return {
24113
+ titleBarStyle: "hidden",
24114
+ titleBarOverlay: {
24115
+ color: chromePlatform === "win" ? (
24116
+ /* Transparent: the app's own ground shows through. See the docblock. */
24117
+ WINDOW_CHROME_TRANSPARENT
24118
+ ) : (
24119
+ /* Opaque reported ground: Linux forces alpha to ff, so a transparent
24120
+ value would paint the glyphs on black. The renderer reports the
24121
+ resolved ground of the element owning the controls' corner and main
24122
+ re-applies it with `setTitleBarOverlay`; this is the value for the
24123
+ first frame, before any report has arrived, which is why it is the
24124
+ palette's `canvas` rather than a guessed pane ground. */
24125
+ chrome.colors.ground
24126
+ ),
24127
+ symbolColor: chrome.colors.symbol,
24128
+ /*
24129
+ * 40 is the app's own toolbar step, so the caption buttons are exactly as
24130
+ * tall as the row they end: the chat header, every pane toolbar and the
24131
+ * sidebar's brand row are all `h-10`. It sits inside Microsoft's 32-48
24132
+ * range. `height` is an integer; a fractional one is rejected.
24133
+ */
24134
+ height: WINDOW_CHROME_HEIGHT
24135
+ }
24136
+ };
24137
+ }
23871
24138
  const CHROMIUM_TRANSPORT_CODES = [
23872
24139
  "ERR_INTERNET_DISCONNECTED",
23873
24140
  "ERR_NETWORK_CHANGED",
@@ -24365,6 +24632,273 @@ function dropStream(stream) {
24365
24632
  const destroy = stream?.destroy;
24366
24633
  if (typeof destroy === "function") destroy.call(stream);
24367
24634
  }
24635
+ const SERVER_RELEASE_REPO = "damianvtran/local-operator";
24636
+ const GITHUB_API_ORIGIN = "https://api.github.com";
24637
+ const RELEASE_NOTES_CACHE_FILE = "server-release-notes.json";
24638
+ const SUMMARY_LIMIT = 400;
24639
+ const RELEASE_NOTES_TIMEOUT_MS = 4e3;
24640
+ const CACHE_LIMIT = 12;
24641
+ const MAX_RESPONSE_BYTES = 512 * 1024;
24642
+ const TAG_PREFIX = /^v/i;
24643
+ function releaseTag(version) {
24644
+ const trimmed = String(version ?? "").trim();
24645
+ if (!trimmed) return "";
24646
+ return TAG_PREFIX.test(trimmed) ? trimmed : `v${trimmed}`;
24647
+ }
24648
+ function releasePageUrl(version) {
24649
+ return `https://github.com/${SERVER_RELEASE_REPO}/releases/tag/${releaseTag(version)}`;
24650
+ }
24651
+ function releaseNotesCachePath(userDataDir) {
24652
+ return path$1.join(userDataDir, RELEASE_NOTES_CACHE_FILE);
24653
+ }
24654
+ function normaliseCache(parsed) {
24655
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
24656
+ const cache = {};
24657
+ for (const [version, entry] of Object.entries(
24658
+ parsed
24659
+ )) {
24660
+ if (!entry || typeof entry !== "object") continue;
24661
+ const { version: named, summary, url } = entry;
24662
+ if (typeof summary !== "string" || !summary.trim()) continue;
24663
+ if (typeof url !== "string" || !url.trim()) continue;
24664
+ cache[version] = {
24665
+ version: typeof named === "string" && named ? named : version,
24666
+ summary,
24667
+ url
24668
+ };
24669
+ }
24670
+ return cache;
24671
+ }
24672
+ function readReleaseNotesCache(userDataDir) {
24673
+ try {
24674
+ return normaliseCache(
24675
+ JSON.parse(fs.readFileSync(releaseNotesCachePath(userDataDir), "utf8"))
24676
+ );
24677
+ } catch {
24678
+ return {};
24679
+ }
24680
+ }
24681
+ function writeReleaseNotesCache(userDataDir, cache) {
24682
+ try {
24683
+ fs.mkdirSync(userDataDir, { recursive: true });
24684
+ fs.writeFileSync(
24685
+ releaseNotesCachePath(userDataDir),
24686
+ `${JSON.stringify(cache, null, 2)}
24687
+ `,
24688
+ "utf8"
24689
+ );
24690
+ } catch {
24691
+ }
24692
+ }
24693
+ function rememberReleaseNotes(cache, notes) {
24694
+ const next = { ...cache, [notes.version]: notes };
24695
+ const versions = Object.keys(next);
24696
+ if (versions.length <= CACHE_LIMIT) return next;
24697
+ const trimmed = {};
24698
+ for (const version of versions.slice(versions.length - CACHE_LIMIT)) {
24699
+ trimmed[version] = next[version];
24700
+ }
24701
+ return trimmed;
24702
+ }
24703
+ function cachedReleaseNotes(cache, version) {
24704
+ return cache[String(version ?? "").trim()] ?? null;
24705
+ }
24706
+ function summariseReleaseBody(body, limit = SUMMARY_LIMIT) {
24707
+ if (typeof body !== "string") return null;
24708
+ const blocks = body.replace(CRLF, "\n").split(BLANK_LINE);
24709
+ for (const block of blocks) {
24710
+ const text = flattenBlock(block);
24711
+ if (text && !isMachineRow(text)) return truncateSummary(text, limit);
24712
+ }
24713
+ return null;
24714
+ }
24715
+ function isMachineRow(text) {
24716
+ return PR_HEADLINE.test(text) || MERGE_CITATION.test(text);
24717
+ }
24718
+ const CRLF = /\r\n?/g;
24719
+ const BLANK_LINE = /\n{2,}/;
24720
+ const HEADING_LINE = /^#{1,6}\s/;
24721
+ const RULE_LINE = /^(?:[-*_]\s*){3,}$/;
24722
+ const COMMENT_OPEN = /^<!--/;
24723
+ const COMMENT_CLOSE = /-->$/;
24724
+ const BLOCKQUOTE_MARKER = /^>\s?/;
24725
+ const LIST_MARKER = /^(?:[-*+]|\d+[.)])\s+/;
24726
+ const IMAGE = /!\[([^\]]*)\]\([^)]*\)/g;
24727
+ const LINK = /\[([^\]]*)\]\([^)]*\)/g;
24728
+ const CODE_SPAN = /`([^`]*)`/g;
24729
+ const CODE_PLACEHOLDER = /\uE000(\d+)\uE001/g;
24730
+ const MASK_ALPHABET = /[\uE000\uE001]/g;
24731
+ const EMPHASIS = /(?<!\*)\*{3}([\s\S]+?)(?<!\*)\*{3}(?!\*)|(?<!\*)\*{2}([\s\S]+?)(?<!\*)\*{2}(?!\*)|(?<!\*)\*(?!\*)([\s\S]+?)(?<!\*)\*(?!\*)/g;
24732
+ const WHITESPACE_RUN = /\s+/g;
24733
+ const PR_HEADLINE = /^#\d+\s+[—–-]\s/;
24734
+ const MERGE_CITATION = /\(merge [0-9a-f]{7,40}\)$/i;
24735
+ function flattenBlock(block) {
24736
+ const lines = block.split("\n").flatMap((line) => {
24737
+ const trimmed = line.trim();
24738
+ if (!trimmed) return [];
24739
+ if (HEADING_LINE.test(trimmed)) return [];
24740
+ if (RULE_LINE.test(trimmed)) return [];
24741
+ if (COMMENT_OPEN.test(trimmed) || COMMENT_CLOSE.test(trimmed)) return [];
24742
+ return [trimmed.replace(BLOCKQUOTE_MARKER, "").replace(LIST_MARKER, "")];
24743
+ });
24744
+ if (lines.length === 0) return "";
24745
+ return collapseWhitespace(flattenInline(lines.join(" ")));
24746
+ }
24747
+ function flattenInline(text) {
24748
+ const code = [];
24749
+ const masked = text.replace(MASK_ALPHABET, " ").replace(IMAGE, "$1").replace(LINK, "$1").replace(CODE_SPAN, (_span, inner) => {
24750
+ code.push(inner);
24751
+ return `${code.length - 1}`;
24752
+ });
24753
+ const unwrapped = masked.replace(EMPHASIS, unwrapEmphasis).replace(EMPHASIS, unwrapEmphasis);
24754
+ return unwrapped.replace(CODE_PLACEHOLDER, (placeholder, index) => {
24755
+ return code[Number(index)] ?? placeholder;
24756
+ });
24757
+ }
24758
+ function unwrapEmphasis(_match, triple, strong, italic) {
24759
+ return triple ?? strong ?? italic ?? "";
24760
+ }
24761
+ function collapseWhitespace(text) {
24762
+ return text.replace(WHITESPACE_RUN, " ").trim();
24763
+ }
24764
+ function truncateSummary(text, limit) {
24765
+ if (text.length <= limit) return text;
24766
+ const cut = text.slice(0, Math.max(0, limit - 3));
24767
+ const lastSpace = cut.lastIndexOf(" ");
24768
+ const words = lastSpace > limit / 2 ? cut.slice(0, lastSpace) : cut;
24769
+ const trimmed = words.trimEnd();
24770
+ const dropped = DanglingWord.test(trimmed) ? trimmed.slice(0, trimmed.lastIndexOf(" ")).trimEnd() : trimmed;
24771
+ const balanced = stripUnpairedEmphasis(dropped);
24772
+ const sentence = balanced.replace(TRAILING_STOP, "");
24773
+ return `${sentence}...`;
24774
+ }
24775
+ const DanglingWord = /(?:^|\s)(?:a|an|as|at|by|in|of|on|or|to)$/i;
24776
+ const TRAILING_STOP = /[.!?]$/;
24777
+ function stripUnpairedEmphasis(text) {
24778
+ const leading = text.match(/^\*{1,3}/);
24779
+ if (leading && !text.slice(leading[0].length).includes("*")) {
24780
+ return text.slice(leading[0].length).trimStart();
24781
+ }
24782
+ const trailing = text.match(/\*{1,3}$/);
24783
+ if (trailing && !text.slice(0, -trailing[0].length).includes("*")) {
24784
+ return text.slice(0, -trailing[0].length).trimEnd();
24785
+ }
24786
+ return text;
24787
+ }
24788
+ function parseReleaseResponse(payload, version) {
24789
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
24790
+ return null;
24791
+ }
24792
+ const release = payload;
24793
+ const summary = summariseReleaseBody(release.body);
24794
+ if (!summary) return null;
24795
+ const url = typeof release.html_url === "string" && release.html_url.trim() ? release.html_url.trim() : releasePageUrl(version);
24796
+ return { version: String(version).trim(), summary, url };
24797
+ }
24798
+ async function fetchServerReleaseNotes(version, options) {
24799
+ const named = String(version ?? "").trim();
24800
+ const tag = releaseTag(named);
24801
+ if (!tag) return { status: "absent", reason: "the offer named no version" };
24802
+ const cache = readReleaseNotesCache(options.userDataDir);
24803
+ const hit = cachedReleaseNotes(cache, named);
24804
+ if (hit) return { status: "found", notes: hit, cached: true };
24805
+ const origin = options.apiOrigin ?? GITHUB_API_ORIGIN;
24806
+ const url = `${origin}/repos/${SERVER_RELEASE_REPO}/releases/tags/${tag}`;
24807
+ let answer;
24808
+ try {
24809
+ answer = await getJson(url, options.timeoutMs ?? RELEASE_NOTES_TIMEOUT_MS);
24810
+ } catch (error) {
24811
+ return {
24812
+ status: "absent",
24813
+ reason: `the lookup for ${tag} failed: ${error instanceof Error ? error.message : String(error)}`
24814
+ };
24815
+ }
24816
+ if (answer.status !== 200) {
24817
+ return {
24818
+ status: "absent",
24819
+ reason: `GitHub answered ${answer.status} for ${tag}`
24820
+ };
24821
+ }
24822
+ let payload;
24823
+ try {
24824
+ payload = JSON.parse(answer.body);
24825
+ } catch {
24826
+ return {
24827
+ status: "absent",
24828
+ reason: `GitHub's answer for ${tag} was not JSON`
24829
+ };
24830
+ }
24831
+ const notes = parseReleaseResponse(payload, named);
24832
+ if (!notes) {
24833
+ return { status: "absent", reason: `the ${tag} release carries no notes` };
24834
+ }
24835
+ writeReleaseNotesCache(
24836
+ options.userDataDir,
24837
+ rememberReleaseNotes(cache, notes)
24838
+ );
24839
+ return { status: "found", notes, cached: false };
24840
+ }
24841
+ function getJson(url, timeoutMs) {
24842
+ return new Promise((resolve, reject) => {
24843
+ let settled = false;
24844
+ const settle2 = (finish) => {
24845
+ if (settled) return;
24846
+ settled = true;
24847
+ finish();
24848
+ };
24849
+ const fail = (error) => settle2(
24850
+ () => reject(error instanceof Error ? error : new Error(String(error)))
24851
+ );
24852
+ let parsed;
24853
+ try {
24854
+ parsed = new URL(url);
24855
+ } catch (error) {
24856
+ fail(error);
24857
+ return;
24858
+ }
24859
+ const transport = parsed.protocol === "http:" ? http__namespace : https__namespace;
24860
+ const request = transport.get(
24861
+ parsed,
24862
+ {
24863
+ timeout: timeoutMs,
24864
+ headers: {
24865
+ // GitHub refuses a request with no user agent, and the versioned
24866
+ // media type is what pins the payload shape this module parses.
24867
+ "user-agent": "local-operator-ui",
24868
+ accept: "application/vnd.github+json"
24869
+ }
24870
+ },
24871
+ (response) => {
24872
+ const chunks = [];
24873
+ let length = 0;
24874
+ response.on("data", (chunk) => {
24875
+ length += chunk.length;
24876
+ if (length > MAX_RESPONSE_BYTES) {
24877
+ request.destroy();
24878
+ fail(new Error("the answer was larger than this lookup accepts"));
24879
+ return;
24880
+ }
24881
+ chunks.push(chunk);
24882
+ });
24883
+ response.on(
24884
+ "end",
24885
+ () => settle2(
24886
+ () => resolve({
24887
+ status: response.statusCode ?? 0,
24888
+ body: Buffer.concat(chunks).toString("utf8")
24889
+ })
24890
+ )
24891
+ );
24892
+ response.on("error", fail);
24893
+ }
24894
+ );
24895
+ request.setTimeout(timeoutMs, () => {
24896
+ request.destroy();
24897
+ fail(new Error(`no answer within ${timeoutMs}ms`));
24898
+ });
24899
+ request.on("error", fail);
24900
+ });
24901
+ }
24368
24902
  function serverUpdateFailureRemedy(input) {
24369
24903
  if (input.rebuildRoute && /REFUSING to release a stale ref/i.test(input.diagnosis)) {
24370
24904
  return "This checkout is behind its remote, which is what `lop-update` refused to build from: bring the checkout up to date, and the next press here will build it.";
@@ -27473,6 +28007,51 @@ ${heal.removed.join("\n")}`,
27473
28007
  });
27474
28008
  });
27475
28009
  }
28010
+ /**
28011
+ * What changed in the server version this offer names, or null.
28012
+ *
28013
+ * The read is off the CRITICAL PATH in the sense that matters: it cannot fail
28014
+ * the offer. Every way it can come back empty - no release for the tag, an
28015
+ * unreachable host, an answer that is not JSON, a body with no prose in it -
28016
+ * is a `null` here and a line in this log, and the panel renders without the
28017
+ * paragraph rather than not at all. That is the shape the app's own channel
28018
+ * has always had (`updateInfo.releaseNotes` is optional and the card is drawn
28019
+ * either way), and it is the one a panel asking a reader to move has to keep:
28020
+ * the notes are the reason to move, never the condition for being told.
28021
+ *
28022
+ * AWAITED RATHER THAN SENT LATER, deliberately. The alternative is a second
28023
+ * renderer event carrying the notes after the offer is already on screen,
28024
+ * which buys a panel that grows under the reader's eyes and a state machine to
28025
+ * review in exchange for saving the one bounded round trip this makes per
28026
+ * version (every later check reads the cache). The bound is
28027
+ * `RELEASE_NOTES_TIMEOUT_MS`, and it is smaller than the wait the version read
28028
+ * above it can already impose with no bound at all.
28029
+ */
28030
+ async readServerReleaseNotes(version) {
28031
+ try {
28032
+ const lookup = await fetchServerReleaseNotes(version, {
28033
+ userDataDir: electron.app.getPath("userData")
28034
+ });
28035
+ if (lookup.status === "found") {
28036
+ logger.info(
28037
+ `Release notes for ${version} read from ${lookup.cached ? "the cache" : "GitHub"} (${lookup.notes.summary.length} chars)`,
28038
+ LogFileType.UPDATE_SERVICE
28039
+ );
28040
+ return lookup.notes;
28041
+ }
28042
+ logger.info(
28043
+ `No release notes for ${version}: ${lookup.reason}`,
28044
+ LogFileType.UPDATE_SERVICE
28045
+ );
28046
+ return null;
28047
+ } catch (error) {
28048
+ logger.warn(
28049
+ `Could not read release notes for ${version}: ${error}`,
28050
+ LogFileType.UPDATE_SERVICE
28051
+ );
28052
+ return null;
28053
+ }
28054
+ }
27476
28055
  /**
27477
28056
  * Check for backend updates using health API and PyPI
27478
28057
  * @param silent - Whether to suppress notifications on no update
@@ -27924,9 +28503,11 @@ ${heal.removed.join("\n")}`,
27924
28503
  `New backend version available: ${latestVersion} (installed: ${installedVersion})`,
27925
28504
  LogFileType.UPDATE_SERVICE
27926
28505
  );
28506
+ const releaseNotes = await this.readServerReleaseNotes(latestVersion);
27927
28507
  const updateInfo = {
27928
28508
  currentVersion: installedVersion,
27929
28509
  latestVersion,
28510
+ releaseNotes,
27930
28511
  /*
27931
28512
  * THE PROCESS'S OWN READING, not `/health`'s (review round 2, T1): the
27932
28513
  * panel's sentence is "the server you are using is running X until it
@@ -30162,6 +30743,193 @@ class ViewerRecordPublisher {
30162
30743
  }
30163
30744
  }
30164
30745
  }
30746
+ function windowChromeFilePath(userDataDir) {
30747
+ return path$1.join(userDataDir, WINDOW_CHROME_FILE);
30748
+ }
30749
+ function readPersistedWindowChrome(userDataDir) {
30750
+ const raw = readJson(
30751
+ windowChromeFilePath(userDataDir)
30752
+ );
30753
+ if (raw === null || typeof raw !== "object") return null;
30754
+ const mode = raw.mode === "native" || raw.mode === "integrated" ? raw.mode : null;
30755
+ if (mode === null) return null;
30756
+ const colors = parseChromeColors(raw.colors);
30757
+ return {
30758
+ mode,
30759
+ ...colors ? { colors } : {},
30760
+ ...typeof raw.themeId === "string" ? { themeId: raw.themeId } : {}
30761
+ };
30762
+ }
30763
+ function writePersistedWindowChrome(userDataDir, value) {
30764
+ return writeJsonAtomic(windowChromeFilePath(userDataDir), value);
30765
+ }
30766
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
30767
+ const FUNCTIONAL_COLOR = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/;
30768
+ function isValidChromeColor(value) {
30769
+ if (typeof value !== "string") return false;
30770
+ const trimmed = value.trim();
30771
+ return HEX_COLOR.test(trimmed) || FUNCTIONAL_COLOR.test(trimmed);
30772
+ }
30773
+ function mergeChromeColors(current, reported) {
30774
+ if (reported === void 0 || reported === null) return current;
30775
+ if (typeof reported !== "object") return null;
30776
+ const record = reported;
30777
+ const next = { ...current };
30778
+ let sawOne = false;
30779
+ for (const key of [
30780
+ "ground",
30781
+ "symbol",
30782
+ "symbolInactive"
30783
+ ]) {
30784
+ if (!(key in record)) continue;
30785
+ const value = record[key];
30786
+ if (!isValidChromeColor(value)) return null;
30787
+ sawOne = true;
30788
+ next[key] = value.trim();
30789
+ }
30790
+ return sawOne ? next : null;
30791
+ }
30792
+ function parseChromeColors(value) {
30793
+ if (value === null || typeof value !== "object") return null;
30794
+ const record = value;
30795
+ const ground = record.ground;
30796
+ const symbol = record.symbol;
30797
+ const symbolInactive = record.symbolInactive;
30798
+ if (!isValidChromeColor(ground) || !isValidChromeColor(symbol) || !isValidChromeColor(symbolInactive)) {
30799
+ return null;
30800
+ }
30801
+ return { ground, symbol, symbolInactive };
30802
+ }
30803
+ function resolveLaunchWindowChrome(input) {
30804
+ let persisted = null;
30805
+ try {
30806
+ persisted = readPersistedWindowChrome(input.userDataDir);
30807
+ } catch {
30808
+ persisted = null;
30809
+ }
30810
+ return resolveWindowChromeForLaunch({
30811
+ argv: input.argv,
30812
+ env: input.env,
30813
+ persisted,
30814
+ platform: input.platform
30815
+ });
30816
+ }
30817
+ function attachWindowChrome(options) {
30818
+ const { window, mode, userDataDir, headless: headless2, log } = options;
30819
+ let colors = options.colors;
30820
+ let cornerGround;
30821
+ let themeId;
30822
+ const platform = chromePlatformFor(process.platform);
30823
+ const hasOverlay = platform !== "mac" && mode === "integrated";
30824
+ const applyOverlay = (focused) => {
30825
+ if (!hasOverlay) return;
30826
+ const focusedNow = focused && !window.isDestroyed();
30827
+ try {
30828
+ window.setTitleBarOverlay({
30829
+ ...platform === "linux" ? { color: cornerGround ?? colors.ground } : {},
30830
+ symbolColor: focusedNow ? colors.symbol : colors.symbolInactive
30831
+ });
30832
+ } catch (error) {
30833
+ log(
30834
+ `[window-chrome] setTitleBarOverlay refused (${String(error)}) — the window keeps its previous caption colours`
30835
+ );
30836
+ }
30837
+ };
30838
+ const currentState = () => ({
30839
+ fullScreen: window.isDestroyed() ? false : window.isFullScreen(),
30840
+ maximized: window.isDestroyed() ? false : window.isMaximized(),
30841
+ focused: window.isDestroyed() ? false : window.isFocused()
30842
+ });
30843
+ const pushState = () => {
30844
+ if (window.isDestroyed()) return;
30845
+ window.webContents.send(WINDOW_CHROME_STATE_CHANNEL, currentState());
30846
+ };
30847
+ const persist = () => {
30848
+ writePersistedWindowChrome(userDataDir, {
30849
+ mode,
30850
+ colors,
30851
+ ...themeId ? { themeId } : {}
30852
+ });
30853
+ };
30854
+ const onReport = (event, report) => {
30855
+ if (event.sender !== window.webContents) return false;
30856
+ const next = mergeChromeColors(colors, report?.colors);
30857
+ if (next === null) {
30858
+ log(
30859
+ "[window-chrome] refused a report whose colours did not parse — the caption colours are unchanged"
30860
+ );
30861
+ return false;
30862
+ }
30863
+ colors = next;
30864
+ const nextCorner = isValidChromeColor(report?.cornerGround) ? report.cornerGround.trim() : void 0;
30865
+ cornerGround = nextCorner;
30866
+ if (typeof report?.themeId === "string") themeId = report.themeId;
30867
+ window.setBackgroundColor(colors.ground);
30868
+ applyOverlay(!window.isDestroyed() && window.isFocused());
30869
+ persist();
30870
+ log(
30871
+ `[window-chrome] applied themeId=${themeId ?? "(none)"} ground=${colors.ground} symbol=${colors.symbol}${cornerGround ? ` cornerGround=${cornerGround}` : ""}`
30872
+ );
30873
+ return true;
30874
+ };
30875
+ const onPopupAppMenu = (event) => {
30876
+ if (event.sender !== window.webContents) return false;
30877
+ if (headless2) {
30878
+ log(
30879
+ "[window-chrome] app menu popup refused: this launch is headless, so there is no window to hang it from"
30880
+ );
30881
+ return false;
30882
+ }
30883
+ if (window.isDestroyed()) return false;
30884
+ const menu = electron.Menu.getApplicationMenu();
30885
+ if (menu === null) {
30886
+ log("[window-chrome] app menu popup refused: no application menu is set");
30887
+ return false;
30888
+ }
30889
+ const [x, y] = window.getPosition();
30890
+ menu.popup({ window, x, y: y + WINDOW_CHROME_HEIGHT });
30891
+ return true;
30892
+ };
30893
+ electron.ipcMain.handle(WINDOW_CHROME_REPORT_CHANNEL, onReport);
30894
+ electron.ipcMain.handle(WINDOW_CHROME_MENU_CHANNEL, onPopupAppMenu);
30895
+ const onFullScreen = () => {
30896
+ pushState();
30897
+ };
30898
+ const onFocus = () => {
30899
+ applyOverlay(true);
30900
+ pushState();
30901
+ };
30902
+ const onBlur = () => {
30903
+ applyOverlay(false);
30904
+ pushState();
30905
+ };
30906
+ const onResizeState = () => {
30907
+ pushState();
30908
+ };
30909
+ window.on("enter-full-screen", onFullScreen);
30910
+ window.on("leave-full-screen", onFullScreen);
30911
+ window.on("maximize", onResizeState);
30912
+ window.on("unmaximize", onResizeState);
30913
+ window.on("focus", onFocus);
30914
+ window.on("blur", onBlur);
30915
+ window.setBackgroundColor(colors.ground);
30916
+ const sync = () => {
30917
+ applyOverlay(!window.isDestroyed() && window.isFocused());
30918
+ window.setBackgroundColor(colors.ground);
30919
+ pushState();
30920
+ };
30921
+ const dispose = () => {
30922
+ electron.ipcMain.removeHandler(WINDOW_CHROME_REPORT_CHANNEL);
30923
+ electron.ipcMain.removeHandler(WINDOW_CHROME_MENU_CHANNEL);
30924
+ window.removeListener("enter-full-screen", onFullScreen);
30925
+ window.removeListener("leave-full-screen", onFullScreen);
30926
+ window.removeListener("maximize", onResizeState);
30927
+ window.removeListener("unmaximize", onResizeState);
30928
+ window.removeListener("focus", onFocus);
30929
+ window.removeListener("blur", onBlur);
30930
+ };
30931
+ return { sync, dispose };
30932
+ }
30165
30933
  const BASE64_FILE_EXTENSIONS = ["csv", "tsv", "xls", "xlsx", "ods"];
30166
30934
  const resolveUserPath = (filePath, cwd) => resolveUserPath$1(filePath, cwd, electron.app.getPath("home"));
30167
30935
  const windowLaunch = resolveWindowLaunchPlan({
@@ -30430,7 +31198,36 @@ const ownLaunchRequest = () => ({
30430
31198
  trigger: "initial-present"
30431
31199
  });
30432
31200
  function createWindow(initialSession = null, openCatalogue = false, request = ownLaunchRequest()) {
31201
+ const chrome = resolveLaunchWindowChrome({
31202
+ argv: process.argv,
31203
+ env: launchEnv,
31204
+ userDataDir: electron.app.getPath("userData"),
31205
+ platform: process.platform
31206
+ });
31207
+ for (const problem of chrome.problems) {
31208
+ logger.warn(`[window-chrome] ${problem}`, LogFileType.BACKEND);
31209
+ console.log(`[window-chrome] ${problem}`);
31210
+ }
30433
31211
  const mainWindow2 = new electron.BrowserWindow({
31212
+ /*
31213
+ * The frame, per platform, from a pure function so the matrix is exercised on
31214
+ * any host (`scripts/titlebar-options.test.mjs`). macOS hides its title bar
31215
+ * and keeps the native traffic lights, which is why the renderer reserves their
31216
+ * 32px lane at the top of the shell (`chat-layout.tsx`) - the lane exists
31217
+ * because this line does, and shipping one without the other is either 32px of
31218
+ * blank space under a native title bar or lights drawn over the app's own row.
31219
+ */
31220
+ ...titlebarOptions(process.platform, {
31221
+ mode: chrome.mode,
31222
+ colors: chrome.colors
31223
+ }),
31224
+ /*
31225
+ * The ground the OS paints before the renderer's first frame. Without it
31226
+ * Electron's default is `#FFF`, so every launch on a dark palette opened with a
31227
+ * white flash - the same defect `INSTALL_WINDOW_CANVAS` fixed for the installer
31228
+ * window, and a hard-coded palette value that a test keeps honest.
31229
+ */
31230
+ backgroundColor: chrome.colors.ground,
30434
31231
  width: windowLaunch.width,
30435
31232
  height: windowLaunch.height,
30436
31233
  // The layout is verified down to 800x600 and not below: the app rail,
@@ -30501,9 +31298,18 @@ function createWindow(initialSession = null, openCatalogue = false, request = ow
30501
31298
  * silent. `rendererArgumentFlags` below concatenates them, and is `{}` when
30502
31299
  * neither applies, so "adds no option at all" stays literally true.
30503
31300
  */
30504
- ...rendererArgumentFlags(initialSession, openCatalogue)
31301
+ ...rendererArgumentFlags(initialSession, openCatalogue, chrome.mode)
30505
31302
  }
30506
31303
  });
31304
+ const chromeController = attachWindowChrome({
31305
+ window: mainWindow2,
31306
+ mode: chrome.mode,
31307
+ colors: chrome.colors,
31308
+ userDataDir: electron.app.getPath("userData"),
31309
+ headless: windowLaunch.mode === "headless",
31310
+ log: (line) => logger.info(line, LogFileType.BACKEND)
31311
+ });
31312
+ mainWindow2.on("closed", () => chromeController.dispose());
30507
31313
  if (initialSession)
30508
31314
  holdPresentUntilConversation(mainWindow2, initialSession, request);
30509
31315
  mainWindow2.webContents.on("console-message", (_event, level, message) => {
@@ -30651,8 +31457,16 @@ function launchArgumentFlags(initialSession, openCatalogue) {
30651
31457
  if (openCatalogue) return { additionalArguments: [OPEN_CATALOGUE_FLAG] };
30652
31458
  return {};
30653
31459
  }
30654
- function rendererArgumentFlags(initialSession, openCatalogue) {
31460
+ function rendererArgumentFlags(initialSession, openCatalogue, chromeMode) {
30655
31461
  const flags = [
31462
+ /*
31463
+ * The chrome facts, and they are ALWAYS written - unlike the conversation or
31464
+ * the dev driver's arming entry, which appear only when they apply. The
31465
+ * renderer chooses between two incompatible layouts on this value, so a reader
31466
+ * that had to infer it from an absent entry would be guessing; see
31467
+ * `readWindowChromeArgument` for why the guess fails towards `native`.
31468
+ */
31469
+ windowChromeArgumentFor(process.platform, chromeMode),
30656
31470
  ...launchArgumentFlags(initialSession, openCatalogue).additionalArguments ?? [],
30657
31471
  ...devDriverWebPreferences.additionalArguments ?? [],
30658
31472
  ...telemetryWebPreferences.additionalArguments ?? []