@mind-harness/mh-browser 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.cjs CHANGED
@@ -163,6 +163,17 @@ window.__ModuleLoader__.load({
163
163
  };
164
164
  //#endregion
165
165
  //#region lib/typert.remote-client.js
166
+ let _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0$schema$value;
167
+ const _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0$schema = () => _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0$schema$value ??= zod.z.union([
168
+ zod.z.literal("chrome"),
169
+ zod.z.literal("msedge"),
170
+ zod.z.literal("chromium")
171
+ ]);
172
+ let _mind_harness_mh_browser_mhBrowser_locateFor_result$schema$value;
173
+ const _mind_harness_mh_browser_mhBrowser_locateFor_result$schema = () => _mind_harness_mh_browser_mhBrowser_locateFor_result$schema$value ??= zod.z.union([zod.z.object({ "found": zod.z.string() }), zod.z.object({ "missing": zod.z.array(zod.z.object({
174
+ "path": zod.z.string(),
175
+ "reason": zod.z.string()
176
+ })) })]);
166
177
  let _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0$schema$value;
167
178
  const _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0$schema = () => _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0$schema$value ??= zod.z.string();
168
179
  let _mind_harness_mh_browser_mhBrowser_revealHandover_result$schema$value;
@@ -171,60 +182,115 @@ window.__ModuleLoader__.load({
171
182
  zod.z.literal("not-handing-over"),
172
183
  zod.z.literal("no-tab")
173
184
  ]) });
185
+ let _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0_strict$schema$value;
186
+ const _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0_strict$schema = () => _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0_strict$schema$value ??= zod.z.union([
187
+ zod.z.literal("chrome"),
188
+ zod.z.literal("msedge"),
189
+ zod.z.literal("chromium")
190
+ ]);
174
191
  let _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0_strict$schema$value;
175
192
  const _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0_strict$schema = () => _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0_strict$schema$value ??= zod.z.string();
176
193
  const TYPERT_REMOTE = createTypertRemoteInfo({
177
194
  package: "@mind-harness/mh-browser",
178
- methods: { "mhBrowser/revealHandover": {
179
- service: "mhBrowser",
180
- description: "The person asked to see the page this Session handed over.",
181
- invocation: { kind: "direct" },
182
- parameters: [{
183
- name: "sessionId",
184
- source: "json",
185
- codec: {
195
+ methods: {
196
+ "mhBrowser/locateFor": {
197
+ service: "mhBrowser",
198
+ description: "Where `browser` would be launched from when no path is configured. The configured path is not consulted: the answer is what an empty path uses.",
199
+ invocation: { kind: "direct" },
200
+ parameters: [{
201
+ name: "browser",
202
+ source: "json",
203
+ codec: {
204
+ mode: "strict",
205
+ typeSymbol: "@mind-harness/mh-browser/location#BrowserName",
206
+ create: _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0$schema,
207
+ jsonSchema: {
208
+ "type": "string",
209
+ "enum": [
210
+ "chrome",
211
+ "msedge",
212
+ "chromium"
213
+ ]
214
+ }
215
+ },
216
+ strictCodec: {
217
+ mode: "strict",
218
+ typeSymbol: "@mind-harness/mh-browser/location#BrowserName",
219
+ create: _mind_harness_mh_browser_mhBrowser_locateFor_parameter_0_strict$schema,
220
+ jsonSchema: {
221
+ "type": "string",
222
+ "enum": [
223
+ "chrome",
224
+ "msedge",
225
+ "chromium"
226
+ ]
227
+ }
228
+ }
229
+ }],
230
+ cancellation: { parameter: "signal" },
231
+ result: {
186
232
  mode: "strict",
187
- typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:sessionId",
188
- create: _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0$schema,
189
- jsonSchema: { "type": "string" }
233
+ typeSymbol: "@mind-harness/mh-browser/location#BrowserLocation",
234
+ create: _mind_harness_mh_browser_mhBrowser_locateFor_result$schema,
235
+ jsonSchemaError: "typert tool schema: selected union branches overlap or have no required discriminator"
190
236
  },
191
- strictCodec: {
192
- mode: "strict",
193
- typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:sessionId",
194
- create: _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0_strict$schema,
195
- jsonSchema: { "type": "string" }
196
- }
197
- }],
198
- cancellation: { parameter: "signal" },
199
- result: {
200
- mode: "strict",
201
- typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:result",
202
- create: _mind_harness_mh_browser_mhBrowser_revealHandover_result$schema,
203
- jsonSchema: {
204
- "type": "object",
205
- "properties": { "status": {
206
- "type": "string",
207
- "enum": [
208
- "revealed",
209
- "not-handing-over",
210
- "no-tab"
211
- ]
212
- } },
213
- "required": ["status"],
214
- "additionalProperties": true
237
+ sourceLocation: {
238
+ "file": "packages/mh-browser/src/service/service.ts",
239
+ "line": 474,
240
+ "column": 9
215
241
  }
216
242
  },
217
- sourceLocation: {
218
- "file": "packages/mh-browser/src/service/service.ts",
219
- "line": 624,
220
- "column": 9
243
+ "mhBrowser/revealHandover": {
244
+ service: "mhBrowser",
245
+ description: "The person asked to see the page this Session handed over.",
246
+ invocation: { kind: "direct" },
247
+ parameters: [{
248
+ name: "sessionId",
249
+ source: "json",
250
+ codec: {
251
+ mode: "strict",
252
+ typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:sessionId",
253
+ create: _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0$schema,
254
+ jsonSchema: { "type": "string" }
255
+ },
256
+ strictCodec: {
257
+ mode: "strict",
258
+ typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:sessionId",
259
+ create: _mind_harness_mh_browser_mhBrowser_revealHandover_parameter_0_strict$schema,
260
+ jsonSchema: { "type": "string" }
261
+ }
262
+ }],
263
+ cancellation: { parameter: "signal" },
264
+ result: {
265
+ mode: "strict",
266
+ typeSymbol: "@mind-harness/mh-browser#mhBrowser/revealHandover:result",
267
+ create: _mind_harness_mh_browser_mhBrowser_revealHandover_result$schema,
268
+ jsonSchema: {
269
+ "type": "object",
270
+ "properties": { "status": {
271
+ "type": "string",
272
+ "enum": [
273
+ "revealed",
274
+ "not-handing-over",
275
+ "no-tab"
276
+ ]
277
+ } },
278
+ "required": ["status"],
279
+ "additionalProperties": true
280
+ }
281
+ },
282
+ sourceLocation: {
283
+ "file": "packages/mh-browser/src/service/service.ts",
284
+ "line": 734,
285
+ "column": 9
286
+ }
221
287
  }
222
- } }
288
+ }
223
289
  }, zod.z);
224
290
  //#endregion
225
291
  //#region src/client/card.ts
226
292
  /** Host theme tokens only; the rules unmount with the row. */
227
- const STYLE = `
293
+ const STYLE$1 = `
228
294
  .mh-br { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 20px; }
229
295
  .mh-br-line { margin: 0; }
230
296
  .mh-br-status { margin: 0; color: var(--dsw-alias-label-secondary); }
@@ -233,7 +299,7 @@ window.__ModuleLoader__.load({
233
299
  /** The open handover: the reason and the button that raises its window. */
234
300
  function HandoverRow(props) {
235
301
  const [note, setNote] = (0, react.useState)(void 0);
236
- return (0, react.createElement)("section", { className: "mh-br" }, (0, react.createElement)("style", null, STYLE), (0, react.createElement)("p", { className: "mh-br-line" }, props.t("waiting", { reason: props.reason })), (0, react.createElement)("button", {
302
+ return (0, react.createElement)("section", { className: "mh-br" }, (0, react.createElement)("style", null, STYLE$1), (0, react.createElement)("p", { className: "mh-br-line" }, props.t("waiting", { reason: props.reason })), (0, react.createElement)("button", {
237
303
  type: "button",
238
304
  className: "mh-br-button",
239
305
  onClick: () => {
@@ -260,14 +326,58 @@ window.__ModuleLoader__.load({
260
326
  reveal: "去浏览器",
261
327
  notHandingOver: "这次交接已经结束",
262
328
  noTab: "没有可以去的页面",
263
- failed: "没能切到浏览器"
329
+ failed: "没能切到浏览器",
330
+ browserLabel: "浏览器",
331
+ browserHint: "保存后,浏览器空闲时会关掉再按这个选择打开。已经打开的标签页会关掉。",
332
+ pathLabel: "浏览器路径",
333
+ pathHint: "留空就用自动找到的。可以填绝对路径,也可以用 ~。保存后同样在空闲时重启。",
334
+ pathInvalid: "需要绝对路径,或以 ~ 开头",
335
+ pathSearching: "正在查找…",
336
+ pathMissing: "未找到{name},请填写路径",
337
+ pathHelp: "说明",
338
+ pathDoesNotExist: "不存在",
339
+ pathIsDirectory: "是目录",
340
+ pathDenied: "没有权限({code})",
341
+ chrome: "Google Chrome",
342
+ msedge: "Microsoft Edge",
343
+ chromium: "Chromium",
344
+ overridden: "已覆盖",
345
+ reset: "恢复",
346
+ save: "保存",
347
+ saving: "保存中",
348
+ settingsUnavailable: "这个配置现在不可用",
349
+ settingsReadOnly: "这次启动的配置不能在页面上改",
350
+ settingsSaveFailed: "没有保存:设置可能在别处改过,或这个值不被接受。修改还在,检查后可以再保存",
351
+ discard: "放弃修改"
264
352
  };
265
353
  const en = {
266
354
  waiting: "The browser is yours: {reason}",
267
355
  reveal: "Go to the browser",
268
356
  notHandingOver: "This handover has already ended",
269
357
  noTab: "There is no page to open",
270
- failed: "Could not switch to the browser"
358
+ failed: "Could not switch to the browser",
359
+ browserLabel: "Browser",
360
+ browserHint: "After saving, the browser restarts once it is idle. Tabs that are open will close.",
361
+ pathLabel: "Browser path",
362
+ pathHint: "Leave empty to use the browser that was found. An absolute path, or one starting with ~, is accepted. Saving restarts the browser once it is idle.",
363
+ pathInvalid: "Use an absolute path, or one starting with ~",
364
+ pathSearching: "Looking…",
365
+ pathMissing: "{name} was not found. Enter its path",
366
+ pathHelp: "Details",
367
+ pathDoesNotExist: "does not exist",
368
+ pathIsDirectory: "is a directory",
369
+ pathDenied: "permission denied ({code})",
370
+ chrome: "Google Chrome",
371
+ msedge: "Microsoft Edge",
372
+ chromium: "Chromium",
373
+ overridden: "Overridden",
374
+ reset: "Reset",
375
+ save: "Save",
376
+ saving: "Saving",
377
+ settingsUnavailable: "This setting is not available",
378
+ settingsReadOnly: "This launch keeps the setting read-only",
379
+ settingsSaveFailed: "Not saved: the setting may have changed elsewhere, or the value was not accepted. Your changes are kept; check them and save again.",
380
+ discard: "Discard changes"
271
381
  };
272
382
  //#endregion
273
383
  //#region src/client/row.ts
@@ -278,25 +388,25 @@ window.__ModuleLoader__.load({
278
388
  */
279
389
  const HANDOVER_KIND = "browser-handover";
280
390
  const HANDOVER_TOOL = "browser_handover";
281
- function isRecord(value) {
391
+ function isRecord$1(value) {
282
392
  return typeof value === "object" && value !== null && !Array.isArray(value);
283
393
  }
284
394
  function turnOf(data) {
285
- if (!isRecord(data) || typeof data.turn !== "number") return void 0;
395
+ if (!isRecord$1(data) || typeof data.turn !== "number") return void 0;
286
396
  return data.turn;
287
397
  }
288
398
  function reasonOf(value) {
289
399
  if (typeof value !== "string") return "";
290
400
  try {
291
401
  const parsed = JSON.parse(value);
292
- if (!isRecord(parsed) || typeof parsed.reason !== "string") return "";
402
+ if (!isRecord$1(parsed) || typeof parsed.reason !== "string") return "";
293
403
  return parsed.reason;
294
404
  } catch {
295
405
  return "";
296
406
  }
297
407
  }
298
408
  function callOf(data) {
299
- if (!isRecord(data) || typeof data.callId !== "string" || typeof data.name !== "string") return void 0;
409
+ if (!isRecord$1(data) || typeof data.callId !== "string" || typeof data.name !== "string") return void 0;
300
410
  return {
301
411
  callId: data.callId,
302
412
  name: data.name,
@@ -305,9 +415,9 @@ window.__ModuleLoader__.load({
305
415
  }
306
416
  /** A result names its call on `message.source` and again on `message.toolCallId`. */
307
417
  function callIdOf(data) {
308
- if (!isRecord(data) || !isRecord(data.message)) return void 0;
418
+ if (!isRecord$1(data) || !isRecord$1(data.message)) return void 0;
309
419
  const message = data.message;
310
- if (isRecord(message.source) && typeof message.source.callId === "string" && message.source.callId !== "") return message.source.callId;
420
+ if (isRecord$1(message.source) && typeof message.source.callId === "string" && message.source.callId !== "") return message.source.callId;
311
421
  if (typeof message.toolCallId === "string" && message.toolCallId !== "") return message.toolCallId;
312
422
  }
313
423
  function hidden(current) {
@@ -394,6 +504,615 @@ window.__ModuleLoader__.load({
394
504
  }
395
505
  };
396
506
  //#endregion
507
+ //#region src/client/settings-form.ts
508
+ /** Drafts over one entry's fields, written together on save. */
509
+ var StagedForm = class {
510
+ scope;
511
+ specs;
512
+ staged = /* @__PURE__ */ new Map();
513
+ listeners = /* @__PURE__ */ new Set();
514
+ baseline;
515
+ saving = false;
516
+ failed = false;
517
+ unsubscribe;
518
+ constructor(scope, specs) {
519
+ this.scope = scope;
520
+ this.specs = new Map(specs.map((spec) => [spec.field, spec]));
521
+ this.unsubscribe = scope.subscribe(() => {
522
+ this.publish();
523
+ });
524
+ }
525
+ /** A store over `project`, rebuilt whenever the entry or a draft changes. */
526
+ bind(project) {
527
+ let current = project();
528
+ const subscribers = /* @__PURE__ */ new Set();
529
+ this.listeners.add(() => {
530
+ current = project();
531
+ for (const listener of subscribers) listener();
532
+ });
533
+ return {
534
+ getSnapshot: () => current,
535
+ subscribe: (listener) => {
536
+ subscribers.add(listener);
537
+ return () => {
538
+ subscribers.delete(listener);
539
+ };
540
+ }
541
+ };
542
+ }
543
+ shell() {
544
+ const snapshot = this.scope.getSnapshot();
545
+ const plan = this.plan();
546
+ return {
547
+ available: snapshot.status === "ready",
548
+ writable: snapshot.writable,
549
+ dirty: plan.length > 0,
550
+ invalid: plan.some((item) => item.op === void 0),
551
+ saving: this.saving,
552
+ failed: this.failed
553
+ };
554
+ }
555
+ field(field) {
556
+ const spec = this.spec(field);
557
+ const staged = this.staged.get(field);
558
+ if (staged === void 0) return {
559
+ text: spec.format(this.read("value", field)),
560
+ overridden: this.stored(field),
561
+ invalid: false
562
+ };
563
+ const write = staged.clear ? { kind: "clear" } : spec.parse(staged.text);
564
+ return {
565
+ text: staged.text,
566
+ overridden: write?.kind === "set",
567
+ invalid: write === void 0
568
+ };
569
+ }
570
+ edit(field, text) {
571
+ this.stage(field, {
572
+ text,
573
+ clear: false
574
+ });
575
+ }
576
+ /** Stage removing the field from the user layer. The draft shows what it falls back to. */
577
+ reset(field) {
578
+ this.stage(field, {
579
+ text: this.spec(field).format(this.read("base", field)),
580
+ clear: true
581
+ });
582
+ }
583
+ discard() {
584
+ if (this.staged.size === 0 && !this.failed) return;
585
+ this.staged.clear();
586
+ this.baseline = void 0;
587
+ this.failed = false;
588
+ this.publish();
589
+ }
590
+ /**
591
+ * Write every staged edit. A save the Host refuses keeps the drafts, and the
592
+ * next save is fenced on what the Host holds now, so trying again can land.
593
+ */
594
+ async save() {
595
+ const plan = this.plan();
596
+ if (plan.length === 0 || this.saving || !this.scope.getSnapshot().writable) return;
597
+ if (plan.some((item) => item.op === void 0)) return;
598
+ this.saving = true;
599
+ this.failed = false;
600
+ this.publish();
601
+ try {
602
+ const ops = plan.flatMap((item) => item.op === void 0 ? [] : [item.op]);
603
+ const landed = ops.length === 0 || await this.scope.mutate(ops, this.baseline?.revision);
604
+ if (landed) {
605
+ this.staged.clear();
606
+ this.baseline = void 0;
607
+ } else this.baseline = this.scope.getSnapshot();
608
+ this.failed = !landed;
609
+ } catch {
610
+ this.baseline = this.scope.getSnapshot();
611
+ this.failed = true;
612
+ } finally {
613
+ this.saving = false;
614
+ this.publish();
615
+ }
616
+ }
617
+ dispose() {
618
+ this.unsubscribe();
619
+ this.listeners.clear();
620
+ }
621
+ plan() {
622
+ const plan = [];
623
+ for (const [field, staged] of this.staged) {
624
+ const spec = this.spec(field);
625
+ if (staged.clear) {
626
+ if (this.stored(field)) plan.push({
627
+ field,
628
+ op: {
629
+ op: "unset",
630
+ path: [field]
631
+ }
632
+ });
633
+ continue;
634
+ }
635
+ if (staged.text === spec.format(this.read("value", field))) continue;
636
+ const write = spec.parse(staged.text);
637
+ if (write === void 0) plan.push({ field });
638
+ else if (write.kind === "clear") plan.push({
639
+ field,
640
+ op: {
641
+ op: "unset",
642
+ path: [field]
643
+ }
644
+ });
645
+ else plan.push({
646
+ field,
647
+ op: {
648
+ op: "set",
649
+ path: [field],
650
+ value: write.value
651
+ }
652
+ });
653
+ }
654
+ return plan;
655
+ }
656
+ stage(field, edit) {
657
+ this.baseline ??= this.scope.getSnapshot();
658
+ this.staged.set(field, edit);
659
+ this.failed = false;
660
+ this.publish();
661
+ }
662
+ spec(field) {
663
+ const spec = this.specs.get(field);
664
+ if (spec === void 0) throw new Error(`settings form has no field ${field}`);
665
+ return spec;
666
+ }
667
+ read(layer, field) {
668
+ const section = this.scope.getSnapshot()[layer];
669
+ return isRecord(section) ? section[field] : void 0;
670
+ }
671
+ stored(field) {
672
+ const user = this.scope.getSnapshot().user;
673
+ return isRecord(user) && Object.hasOwn(user, field);
674
+ }
675
+ publish() {
676
+ for (const listener of this.listeners) listener();
677
+ }
678
+ };
679
+ /** Free text. An empty draft clears the field. */
680
+ function textField(field) {
681
+ return {
682
+ field,
683
+ format: (value) => typeof value === "string" ? value : "",
684
+ parse: (text) => {
685
+ const trimmed = text.trim();
686
+ return trimmed === "" ? { kind: "clear" } : {
687
+ kind: "set",
688
+ value: trimmed
689
+ };
690
+ }
691
+ };
692
+ }
693
+ const STYLE = `
694
+ .mh-browser-form { display: flex; flex-direction: column; }
695
+ .mh-browser-status { margin: 0 0 12px; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); }
696
+ .mh-browser-footer { display: flex; align-items: flex-start; gap: 12px; padding-top: 16px; }
697
+ .mh-browser-save { flex: none; }
698
+ .mh-browser-failure { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding-top: 5px; }
699
+ .mh-browser-failed { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-error); }
700
+ .mh-browser-save { appearance: none; border: 1px solid transparent; border-radius: var(--dsw-radius-md); padding: 5px 14px; font: inherit; font-size: 13px; line-height: 1.5; cursor: pointer; background: var(--dsw-alias-label-primary); color: var(--dsw-alias-bg-layer-3); }
701
+ .mh-browser-save:disabled { opacity: 0.4; cursor: default; }
702
+ .mh-browser-save:focus-visible, .mh-browser-help-button:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 1px; }
703
+ .mh-browser-field { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; }
704
+ .mh-browser-field + .mh-browser-field { border-top: 0.5px solid var(--dsw-alias-border-l2); }
705
+ .mh-browser-head { display: flex; align-items: center; gap: 8px; }
706
+ .mh-browser-label-group { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
707
+ .mh-browser-label { flex: 0 1 auto; min-width: 0; font-size: 13px; font-weight: 500; line-height: 1.5; color: var(--dsw-alias-label-primary); }
708
+ .mh-browser-help-button { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--dsw-radius-sm); background: none; color: var(--dsw-alias-label-tertiary); cursor: pointer; }
709
+ .mh-browser-help-button[aria-expanded='true'] { background: var(--dsw-alias-bg-layer-4); color: var(--dsw-alias-label-secondary); }
710
+ .mh-browser-help { padding: 10px 0 0; font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; }
711
+ .mh-browser-badges { display: inline-flex; align-items: center; gap: 8px; }
712
+ .mh-browser-tag { display: inline-flex; align-items: center; border-radius: 999px; padding: 1px 8px; font-size: 11px; line-height: 17px; font-weight: 500; white-space: nowrap; background: var(--dsw-alias-bg-module-platform); color: var(--dsw-alias-label-secondary); }
713
+ .mh-browser-reset { border: none; background: none; padding: 0; font: inherit; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); cursor: pointer; }
714
+ .mh-browser-reset:disabled { cursor: default; }
715
+ .mh-browser-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 12px; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md); background: var(--dsw-alias-bg-layer-3); font: inherit; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary); }
716
+ .mh-browser-input:focus-visible { outline: none; border-color: var(--dsw-alias-state-business-primary); }
717
+ .mh-browser-input:disabled { color: var(--dsw-alias-label-tertiary); cursor: default; }
718
+ .mh-browser-input[aria-invalid='true'] { border-color: var(--dsw-alias-state-error-primary); }
719
+ .mh-browser-choice { position: relative; display: flex; }
720
+ .mh-browser-choice .mh-browser-input { appearance: none; padding-right: 32px; cursor: pointer; }
721
+ .mh-browser-choice .mh-browser-input:disabled { cursor: default; }
722
+ .mh-browser-choice option { background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); }
723
+ .mh-browser-chevron { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary); pointer-events: none; }
724
+ .mh-browser-invalid { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-state-error-primary); }
725
+ .mh-browser-hint { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-tertiary); }
726
+ @media (hover: hover) {
727
+ .mh-browser-help-button:hover { background: var(--dsw-alias-bg-layer-4); color: var(--dsw-alias-label-secondary); }
728
+ .mh-browser-reset:hover:not(:disabled) { color: var(--dsw-alias-label-primary); }
729
+ }
730
+ `;
731
+ /** The page frame: read-only notice, the fields, and Save. Unmounting drops every draft. */
732
+ function SettingsFrame(props) {
733
+ const discard = (0, react.useRef)(props.onDiscard);
734
+ discard.current = props.onDiscard;
735
+ (0, react.useEffect)(() => () => {
736
+ discard.current();
737
+ }, []);
738
+ const { state, labels } = props;
739
+ if (!state.available) return (0, react.createElement)("div", null, (0, react.createElement)("style", null, STYLE), (0, react.createElement)("p", {
740
+ className: "mh-browser-status",
741
+ role: "status"
742
+ }, labels.unavailable));
743
+ const blocked = !state.dirty || state.invalid || state.saving || !state.writable;
744
+ return (0, react.createElement)("div", { className: "mh-browser-form" }, (0, react.createElement)("style", null, STYLE), state.writable ? null : (0, react.createElement)("p", {
745
+ className: "mh-browser-status",
746
+ role: "status"
747
+ }, labels.readOnly), props.children, (0, react.createElement)("div", { className: "mh-browser-footer" }, (0, react.createElement)("button", {
748
+ type: "button",
749
+ className: "mh-browser-save",
750
+ disabled: blocked,
751
+ onClick: props.onSave
752
+ }, state.saving ? labels.saving : labels.save), state.failed ? (0, react.createElement)("div", { className: "mh-browser-failure" }, (0, react.createElement)("p", {
753
+ className: "mh-browser-failed",
754
+ role: "status"
755
+ }, labels.saveFailed), (0, react.createElement)("button", {
756
+ type: "button",
757
+ className: "mh-browser-reset",
758
+ onClick: props.onDiscard
759
+ }, labels.discard)) : null));
760
+ }
761
+ function TextField(props) {
762
+ return (0, react.createElement)(Field, {
763
+ ...props,
764
+ control: (describedBy) => (0, react.createElement)("input", {
765
+ id: props.id,
766
+ className: "mh-browser-input",
767
+ type: "text",
768
+ ...props.invalid ? { "aria-invalid": true } : {},
769
+ "aria-describedby": describedBy,
770
+ value: props.text,
771
+ placeholder: props.placeholder ?? "",
772
+ disabled: props.disabled,
773
+ onChange: (event) => {
774
+ props.onEdit(event.target.value);
775
+ }
776
+ })
777
+ });
778
+ }
779
+ function ChoiceField(props) {
780
+ return (0, react.createElement)(Field, {
781
+ ...props,
782
+ control: (describedBy) => (0, react.createElement)("div", { className: "mh-browser-choice" }, (0, react.createElement)("select", {
783
+ id: props.id,
784
+ className: "mh-browser-input",
785
+ "aria-describedby": describedBy,
786
+ value: props.text,
787
+ disabled: props.disabled,
788
+ onChange: (event) => {
789
+ props.onEdit(event.target.value);
790
+ }
791
+ }, props.options.map((option) => (0, react.createElement)("option", {
792
+ key: option.value,
793
+ value: option.value
794
+ }, option.label))), (0, react.createElement)(Chevron))
795
+ });
796
+ }
797
+ function Field(props) {
798
+ const [helpOpen, setHelpOpen] = (0, react.useState)(false);
799
+ const helpId = `${props.id}-help`;
800
+ const messageId = `${props.id}-message`;
801
+ const invalid = props.invalid && props.invalidLabel !== void 0;
802
+ const hasMessage = invalid || props.hint !== void 0 && props.hint !== "";
803
+ const describedBy = [hasMessage ? messageId : "", helpOpen ? helpId : ""].filter((id) => id !== "").join(" ");
804
+ return (0, react.createElement)("div", { className: "mh-browser-field" }, (0, react.createElement)("div", { className: "mh-browser-head" }, (0, react.createElement)("div", { className: "mh-browser-label-group" }, (0, react.createElement)("label", {
805
+ className: "mh-browser-label",
806
+ htmlFor: props.id
807
+ }, props.label), props.help === void 0 ? null : (0, react.createElement)("button", {
808
+ type: "button",
809
+ className: "mh-browser-help-button",
810
+ "aria-label": props.help.label,
811
+ "aria-expanded": helpOpen,
812
+ "aria-controls": helpId,
813
+ onClick: () => {
814
+ setHelpOpen(!helpOpen);
815
+ }
816
+ }, (0, react.createElement)(InfoIcon))), props.overridden ? (0, react.createElement)("span", { className: "mh-browser-badges" }, (0, react.createElement)("span", { className: "mh-browser-tag" }, props.overriddenLabel), (0, react.createElement)("button", {
817
+ type: "button",
818
+ className: "mh-browser-reset",
819
+ disabled: props.disabled,
820
+ onClick: props.onReset
821
+ }, props.resetLabel)) : null), props.control(describedBy === "" ? void 0 : describedBy), hasMessage ? (0, react.createElement)("p", {
822
+ id: messageId,
823
+ className: invalid ? "mh-browser-invalid" : "mh-browser-hint"
824
+ }, invalid ? props.invalidLabel : props.hint) : null, props.help !== void 0 && helpOpen ? (0, react.createElement)("div", {
825
+ id: helpId,
826
+ className: "mh-browser-help",
827
+ role: "region",
828
+ "aria-label": props.help.label
829
+ }, props.help.content) : null);
830
+ }
831
+ function InfoIcon() {
832
+ return (0, react.createElement)("svg", {
833
+ width: 12,
834
+ height: 12,
835
+ viewBox: "0 0 12 12",
836
+ fill: "none",
837
+ stroke: "currentColor",
838
+ strokeWidth: 1,
839
+ strokeLinecap: "round",
840
+ "aria-hidden": true
841
+ }, (0, react.createElement)("circle", {
842
+ cx: 6,
843
+ cy: 6,
844
+ r: 5
845
+ }), (0, react.createElement)("path", { d: "M6 5.5v3" }), (0, react.createElement)("circle", {
846
+ cx: 6,
847
+ cy: 3.75,
848
+ r: .5,
849
+ fill: "currentColor",
850
+ stroke: "none"
851
+ }));
852
+ }
853
+ function Chevron() {
854
+ return (0, react.createElement)("svg", {
855
+ className: "mh-browser-chevron",
856
+ width: 12,
857
+ height: 12,
858
+ viewBox: "0 0 12 12",
859
+ fill: "none",
860
+ stroke: "currentColor",
861
+ strokeWidth: 1.25,
862
+ strokeLinecap: "round",
863
+ strokeLinejoin: "round",
864
+ "aria-hidden": true
865
+ }, (0, react.createElement)("path", { d: "M3 4.5l3 3 3-3" }));
866
+ }
867
+ function isRecord(value) {
868
+ return typeof value === "object" && value !== null;
869
+ }
870
+ //#endregion
871
+ //#region src/client/settings.ts
872
+ /** Loader row id, and the settings namespace for that row. */
873
+ const ENTRY_ID = "mh-browser";
874
+ /** `plugins.row.config` key: the bundle package, then the row id. */
875
+ const ROW_KEY = "@mind-harness/mh-bundle#mh-browser";
876
+ const BROWSER_CHOICES = [
877
+ "chrome",
878
+ "msedge",
879
+ "chromium"
880
+ ];
881
+ const LOADING = { status: "loading" };
882
+ /** Empty text clears the field. Anything else must be absolute, or start with `~`. */
883
+ function parseExecutable(text) {
884
+ const trimmed = text.trim();
885
+ if (trimmed === "") return { kind: "clear" };
886
+ if (trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) return {
887
+ kind: "set",
888
+ value: trimmed
889
+ };
890
+ if (trimmed.startsWith("/") || /^[A-Za-z]:[\\/]/.test(trimmed)) return {
891
+ kind: "set",
892
+ value: trimmed
893
+ };
894
+ }
895
+ /** Placeholder for an empty path field: the path an empty value would use. */
896
+ function pathPlaceholder(lookup, browser, t) {
897
+ if (lookup.status === "loading") return t("pathSearching");
898
+ if (lookup.status === "found") return lookup.path;
899
+ if (lookup.status === "missing") return t("pathMissing", { name: t(browser) });
900
+ }
901
+ /** The full path, or every place that was tried. Absent while looking or after a failed lookup. */
902
+ function pathHelpText(lookup, t) {
903
+ if (lookup.status === "found") return lookup.path;
904
+ if (lookup.status !== "missing") return void 0;
905
+ return lookup.tried.map((item) => `${item.path} (${reasonText(item.reason, t)})`).join("\n");
906
+ }
907
+ /**
908
+ * One browser name's lookup, kept until the settings page unmounts.
909
+ * A newer lookup cancels the one still running.
910
+ */
911
+ var ExecutableLookup = class {
912
+ locate;
913
+ cache = /* @__PURE__ */ new Map();
914
+ inflight;
915
+ controller;
916
+ listeners = /* @__PURE__ */ new Set();
917
+ constructor(locate) {
918
+ this.locate = locate;
919
+ }
920
+ subscribe = (listener) => {
921
+ this.listeners.add(listener);
922
+ return () => {
923
+ this.listeners.delete(listener);
924
+ };
925
+ };
926
+ /** Cached answer for `browser`, or loading when that answer is not in yet. */
927
+ view(browser) {
928
+ return this.cache.get(browser) ?? LOADING;
929
+ }
930
+ watch(browser) {
931
+ if (this.cache.has(browser) || this.inflight === browser) return;
932
+ this.controller?.abort();
933
+ const controller = new AbortController();
934
+ this.controller = controller;
935
+ this.inflight = browser;
936
+ this.locate(browser, controller.signal).then((result) => {
937
+ if (controller.signal.aborted) return;
938
+ if (this.inflight === browser) this.inflight = void 0;
939
+ this.cache.set(browser, viewOf(result));
940
+ this.publish();
941
+ }, () => {
942
+ if (controller.signal.aborted) return;
943
+ if (this.inflight === browser) this.inflight = void 0;
944
+ this.cache.set(browser, { status: "failed" });
945
+ this.publish();
946
+ });
947
+ }
948
+ dispose() {
949
+ this.controller?.abort();
950
+ this.controller = void 0;
951
+ this.inflight = void 0;
952
+ this.cache.clear();
953
+ }
954
+ publish() {
955
+ for (const listener of this.listeners) listener();
956
+ }
957
+ };
958
+ /** Staged browser choice and path. A save the host accepts restarts the browser when it is idle. */
959
+ var BrowserForm = class {
960
+ form;
961
+ lookup;
962
+ state;
963
+ constructor(scope, locate) {
964
+ this.form = new StagedForm(scope, [browserField(), executableField()]);
965
+ this.lookup = new ExecutableLookup(locate);
966
+ this.state = this.form.bind(() => ({
967
+ ...this.form.shell(),
968
+ browser: this.form.field("browser"),
969
+ executablePath: this.form.field("executablePath")
970
+ }));
971
+ }
972
+ edit(field, text) {
973
+ this.form.edit(field, text);
974
+ }
975
+ reset(field) {
976
+ this.form.reset(field);
977
+ }
978
+ save() {
979
+ this.form.save();
980
+ }
981
+ discard() {
982
+ this.form.discard();
983
+ }
984
+ dispose() {
985
+ this.lookup.dispose();
986
+ this.form.dispose();
987
+ }
988
+ };
989
+ function registerSettings(ctx) {
990
+ const form = new BrowserForm(ctx.configForms.get(ENTRY_ID), (browser, signal) => ctx.remote.mhBrowser.locateFor(browser, signal));
991
+ ctx.effect(() => () => {
992
+ form.dispose();
993
+ });
994
+ ctx.slots.inject("plugins.row.config", () => ctx.slots.register({
995
+ name: "plugins.row.config",
996
+ key: ROW_KEY,
997
+ locale: NS
998
+ }, (props) => {
999
+ if (props.view === "summary") return null;
1000
+ return (0, react.createElement)(BrowserSettings, {
1001
+ t: props.t,
1002
+ form
1003
+ });
1004
+ }));
1005
+ }
1006
+ function BrowserSettings(props) {
1007
+ const state = (0, react.useSyncExternalStore)(props.form.state.subscribe, props.form.state.getSnapshot, props.form.state.getSnapshot);
1008
+ const browser = choiceOf(state.browser.text);
1009
+ const lookup = (0, react.useSyncExternalStore)(props.form.lookup.subscribe, () => props.form.lookup.view(browser), () => props.form.lookup.view(browser));
1010
+ (0, react.useEffect)(() => {
1011
+ props.form.lookup.watch(browser);
1012
+ }, [browser, props.form]);
1013
+ (0, react.useEffect)(() => {
1014
+ const lookup = props.form.lookup;
1015
+ return () => {
1016
+ lookup.dispose();
1017
+ };
1018
+ }, [props.form]);
1019
+ const disabled = !state.writable || state.saving;
1020
+ const help = helpOf(lookup, props.t);
1021
+ const placeholder = state.executablePath.text === "" ? pathPlaceholder(lookup, browser, props.t) : void 0;
1022
+ return (0, react.createElement)(SettingsFrame, {
1023
+ labels: formLabels(props.t),
1024
+ state,
1025
+ onSave: () => props.form.save(),
1026
+ onDiscard: () => props.form.discard()
1027
+ }, (0, react.createElement)(ChoiceField, {
1028
+ id: "mh-browser-name",
1029
+ label: props.t("browserLabel"),
1030
+ hint: props.t("browserHint"),
1031
+ ...state.browser,
1032
+ text: browser,
1033
+ options: BROWSER_CHOICES.map((name) => ({
1034
+ value: name,
1035
+ label: props.t(name)
1036
+ })),
1037
+ overriddenLabel: props.t("overridden"),
1038
+ resetLabel: props.t("reset"),
1039
+ disabled,
1040
+ onEdit: (value) => props.form.edit("browser", value),
1041
+ onReset: () => props.form.reset("browser")
1042
+ }), (0, react.createElement)(TextField, {
1043
+ id: "mh-browser-path",
1044
+ label: props.t("pathLabel"),
1045
+ hint: props.t("pathHint"),
1046
+ ...state.executablePath,
1047
+ overriddenLabel: props.t("overridden"),
1048
+ resetLabel: props.t("reset"),
1049
+ invalidLabel: props.t("pathInvalid"),
1050
+ disabled,
1051
+ ...placeholder === void 0 ? {} : { placeholder },
1052
+ ...help === void 0 ? {} : { help },
1053
+ onEdit: (text) => props.form.edit("executablePath", text),
1054
+ onReset: () => props.form.reset("executablePath")
1055
+ }));
1056
+ }
1057
+ function browserField() {
1058
+ return {
1059
+ field: "browser",
1060
+ format: (value) => typeof value === "string" && isChoice(value) ? value : "chrome",
1061
+ parse: (text) => isChoice(text) ? {
1062
+ kind: "set",
1063
+ value: text
1064
+ } : void 0
1065
+ };
1066
+ }
1067
+ function executableField() {
1068
+ return {
1069
+ ...textField("executablePath"),
1070
+ parse: parseExecutable
1071
+ };
1072
+ }
1073
+ function formLabels(t) {
1074
+ return {
1075
+ unavailable: t("settingsUnavailable"),
1076
+ readOnly: t("settingsReadOnly"),
1077
+ saveFailed: t("settingsSaveFailed"),
1078
+ discard: t("discard"),
1079
+ save: t("save"),
1080
+ saving: t("saving")
1081
+ };
1082
+ }
1083
+ function choiceOf(text) {
1084
+ return isChoice(text) ? text : "chrome";
1085
+ }
1086
+ function isChoice(value) {
1087
+ return BROWSER_CHOICES.includes(value);
1088
+ }
1089
+ function viewOf(result) {
1090
+ if (!result.ok) return { status: "failed" };
1091
+ if ("found" in result.value) return {
1092
+ status: "found",
1093
+ path: result.value.found
1094
+ };
1095
+ return {
1096
+ status: "missing",
1097
+ tried: result.value.missing
1098
+ };
1099
+ }
1100
+ function helpOf(lookup, t) {
1101
+ const text = pathHelpText(lookup, t);
1102
+ if (text === void 0) return void 0;
1103
+ return {
1104
+ label: t("pathHelp"),
1105
+ content: text
1106
+ };
1107
+ }
1108
+ function reasonText(reason, t) {
1109
+ if (reason === "does not exist") return t("pathDoesNotExist");
1110
+ if (reason === "is a directory") return t("pathIsDirectory");
1111
+ const code = /^permission denied: (\S+)$/.exec(reason)?.[1];
1112
+ if (code !== void 0) return t("pathDenied", { code });
1113
+ return reason;
1114
+ }
1115
+ //#endregion
397
1116
  //#region src/client/client.ts
398
1117
  /**
399
1118
  * Cordis services this client reads. `dsh.client.inject` is package load
@@ -403,7 +1122,8 @@ window.__ModuleLoader__.load({
403
1122
  "slots",
404
1123
  "locale",
405
1124
  "remote",
406
- "uiConversation"
1125
+ "uiConversation",
1126
+ "configForms"
407
1127
  ];
408
1128
  const namespaceInject = [...inject, "remote.mhBrowser"];
409
1129
  async function apply(ctx) {
@@ -411,6 +1131,7 @@ window.__ModuleLoader__.load({
411
1131
  let release = () => void 0;
412
1132
  await ctx.inject(namespaceInject, (inner) => {
413
1133
  release = start(inner);
1134
+ registerSettings(inner);
414
1135
  });
415
1136
  return async () => {
416
1137
  release();