@monster-orange/den 0.4.23 → 0.4.24

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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.24] - 2026-11-10
8
+
9
+ ### Fixed
10
+
11
+ - **Visit opens in Safari (D27).** Visit, in the service panel and the
12
+ rail's ↗, is now a real link (`target=_blank`) instead of a button that
13
+ called `window.open`, which Safari's popup blocker stopped. The panel
14
+ also holds its 5 s repaint while a pointer is down in it, so a click
15
+ is never lost to a repaint between press and release.
16
+ - **Signing in from another machine (D29).** On an unclaimed Den with
17
+ `web.owner_github` set, "Sign in as @login" is the primary button. The
18
+ button that only works on the Den machine says so: "On <host> itself:
19
+ connect GitHub". With only `web.allowed_github_org`, the page says to
20
+ open it on that machine, or to set `web.owner_github`.
21
+ - **Rotation overrides are picked by name (D30).** Settings › Agents ›
22
+ Rotation's Add override form has searchable pickers (type to filter,
23
+ arrow keys, Enter) for the agent, persona, workspace or service, and a
24
+ choice for each value, never a typed id. The overrides table shows
25
+ names; an override for something that is gone is greyed, with Remove.
26
+ At 375 px the table stacks into cards, so no word breaks mid-letter.
27
+ - **doctor, where it runs.** In login mode (as installed), the server and
28
+ session host in the desktop session show ℹ, not ⚠. An older session
29
+ host still serving, with the service's own host standing by, is ℹ
30
+ "Nothing to do", not "unknown ✓". The version-manager node warning at
31
+ `service install` prints once, for the web job.
32
+
7
33
  ## [0.4.23] - 2026-11-10
8
34
 
9
35
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.23",
3
+ "version": "0.4.24",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,8 +110,9 @@ pick_node() {
110
110
  done
111
111
  # Warn when installing; `print` writes the plist and nothing else.
112
112
  if [ "${1:-}" = install ]; then
113
- echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
114
- echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
113
+ echo "⚠ the web job will run $node, a version manager's node: it stops starting" >&2
114
+ echo " when that version is removed (the session host runs Den's own copy of node)." >&2
115
+ echo " Re-run install after switching, or set DEN_SERVICE_NODE." >&2
115
116
  fi
116
117
  echo "$node"
117
118
  }
@@ -165,7 +166,8 @@ host_exe() { # $1 node
165
166
  }
166
167
 
167
168
  write_plist() { # $1 = output file, $2 = web|host, $3 = mode
168
- NODE="$(pick_node "$MODE")"
169
+ # Warned once, for the web job: the host job runs Den's node copy.
170
+ NODE="$(pick_node "$([ "$2" = web ] && echo "$MODE")")"
169
171
  TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
170
172
  local exe="$NODE" label="$LABEL" log="web.log"
171
173
  if [ "$2" = host ]; then
@@ -3369,6 +3369,71 @@ export async function resumeOrRelaunch(
3369
3369
  }
3370
3370
 
3371
3371
  /** D28: the rotation settings, and agent `agent`'s effective values. */
3372
+ /** D30: what the rotation overrides can name, with names to show. */
3373
+ export interface RotationChoices {
3374
+ agents: { id: string; name: string; workspace?: string; state: string }[];
3375
+ personas: { id: string; name: string; category: string }[];
3376
+ workspaces: { id: string; name: string }[];
3377
+ services: { id: string; name: string; workspace: string }[];
3378
+ }
3379
+
3380
+ /**
3381
+ * D30: the overrides form's pickers: every agent (its name, workspace and
3382
+ * state), persona (name and category), workspace and workspace service.
3383
+ * Never starts a session host to ask (a stopped one: agents from their
3384
+ * records). A part that can't be read is empty, not an error.
3385
+ */
3386
+ export async function rotationChoices(
3387
+ d: AgentsResolved,
3388
+ workspaces: string[],
3389
+ ): Promise<RotationChoices> {
3390
+ let sessions: SessionInfo[] = [];
3391
+ try {
3392
+ const c = await d.connectExisting();
3393
+ try {
3394
+ sessions = c ? await c.list() : [];
3395
+ } finally {
3396
+ c?.close();
3397
+ }
3398
+ } catch {
3399
+ sessions = [];
3400
+ }
3401
+ const records = listRecords(d.stateDir());
3402
+ const agents = agentRows(sessions, records, d.stateDir())
3403
+ .filter((r) => r.agent)
3404
+ .map((r) => ({
3405
+ id: r.agent,
3406
+ name: r.displayName || r.sessionTitle || r.agent,
3407
+ ...(r.workspace ? { workspace: r.workspace } : {}),
3408
+ state: r.state,
3409
+ }));
3410
+ let personaList: RotationChoices["personas"] = [];
3411
+ try {
3412
+ personaList = listPersonas(d).map((p) => ({
3413
+ id: p.id,
3414
+ name: p.displayName ? `${p.displayName} (${p.id})` : p.id,
3415
+ category: p.category,
3416
+ }));
3417
+ } catch {
3418
+ personaList = [];
3419
+ }
3420
+ const services: RotationChoices["services"] = [];
3421
+ for (const ws of workspaces)
3422
+ try {
3423
+ for (const sv of d.workspaceServices(ws))
3424
+ if (!services.some((x) => x.id === sv.address))
3425
+ services.push({ id: sv.address, name: sv.address, workspace: ws });
3426
+ } catch {
3427
+ /* a workspace whose services can't be read: none */
3428
+ }
3429
+ return {
3430
+ agents,
3431
+ personas: personaList,
3432
+ workspaces: workspaces.map((w) => ({ id: w, name: w })),
3433
+ services,
3434
+ };
3435
+ }
3436
+
3372
3437
  export function rotationSettings(
3373
3438
  d: AgentsResolved,
3374
3439
  agent?: string,
@@ -69,11 +69,13 @@ import { printJson } from "./json-out.js";
69
69
  import { ownerCheck, ownerLine } from "./doctor-owner.js";
70
70
  import {
71
71
  formatWhere,
72
+ launchdJobPid,
72
73
  serviceJobState,
73
74
  whereRows,
74
75
  type WhereDeps,
75
76
  type WhereRow,
76
77
  } from "./doctor-where.js";
78
+ import { hostLabel } from "./service-command.js";
77
79
 
78
80
  export interface DoctorRow {
79
81
  kind: string;
@@ -420,6 +422,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
420
422
  try {
421
423
  return {
422
424
  launchd: c.hostLaunchd,
425
+ pid: c.hostPid,
426
+ startedAt: c.hostStartedAt || undefined,
423
427
  access: await c.access().catch(() => null),
424
428
  };
425
429
  } finally {
@@ -438,6 +442,24 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
438
442
  return null;
439
443
  }
440
444
  },
445
+ hostJob: () => {
446
+ try {
447
+ const { label } = readWebServiceConfig(
448
+ cfg(),
449
+ getDistRoot(),
450
+ os.homedir(),
451
+ );
452
+ const host = hostLabel(label);
453
+ const job = serviceJobState(
454
+ host,
455
+ os.homedir(),
456
+ process.getuid?.() ?? 0,
457
+ );
458
+ return job?.state === "loaded" ? launchdJobPid(job.domain, host) : null;
459
+ } catch {
460
+ return null;
461
+ }
462
+ },
441
463
  };
442
464
  }
443
465
 
@@ -445,7 +467,8 @@ export async function defaultWhereDeps(): Promise<WhereDeps> {
445
467
  export function whereCheck(r: WhereRow): OnboardCheck {
446
468
  return {
447
469
  id: `where:${r.what.replace(" ", "-")}`,
448
- status: r.status,
470
+ // info is as chosen: not a problem.
471
+ status: r.status === "info" ? "ok" : r.status,
449
472
  detail: `${r.session}${r.detail ? `: ${r.detail}` : ""}`,
450
473
  };
451
474
  }
@@ -41,15 +41,22 @@ export interface WhereDeps {
41
41
  /** The running session host's; null when none runs. */
42
42
  host: () => Promise<{
43
43
  launchd: string | null;
44
+ /** The pid that answers on the socket. */
45
+ pid?: number;
46
+ /** When it started (ISO). */
47
+ startedAt?: string;
44
48
  access?: { exe: string; fullDiskAccess: boolean | null } | null;
45
49
  } | null>;
46
50
  service: () => ServiceJob | null;
51
+ /** The service's own host job's pid (it may stand by), or null. */
52
+ hostJob?: () => number | null;
47
53
  }
48
54
 
49
55
  export interface WhereRow {
50
56
  what: "server" | "session host" | "full disk access" | "service";
51
57
  session: string;
52
- status: "ok" | "warn";
58
+ /** info: as chosen, worth knowing (login mode ends at logout). */
59
+ status: "ok" | "info" | "warn";
53
60
  detail?: string;
54
61
  }
55
62
 
@@ -57,12 +64,15 @@ function sessionRow(
57
64
  what: "server" | "session host",
58
65
  launchd: string | null | undefined,
59
66
  cli: string,
67
+ loginChosen = false,
60
68
  ): WhereRow {
61
69
  const session = launchd || "unknown";
62
70
  return {
63
71
  what,
64
72
  session,
65
- status: endsAtLogout(launchd) ? "warn" : "ok",
73
+ // The service installed in login mode: ending at logout is what was
74
+ // chosen (the default), so it is said, not warned about.
75
+ status: endsAtLogout(launchd) ? (loginChosen ? "info" : "warn") : "ok",
66
76
  ...(sessionWarning(launchd, cli, what)
67
77
  ? { detail: sessionWarning(launchd, cli, what)! }
68
78
  : {}),
@@ -72,10 +82,12 @@ function sessionRow(
72
82
  export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
73
83
  if (d.platform !== "darwin") return [];
74
84
  const rows: WhereRow[] = [];
85
+ const svc = d.service();
86
+ const loginChosen = svc?.mode === "login" && svc.state !== "not-loaded";
75
87
  const server = await d.server().catch(() => null);
76
88
  rows.push(
77
89
  server
78
- ? sessionRow("server", server.launchd, d.cli)
90
+ ? sessionRow("server", server.launchd, d.cli, loginChosen)
79
91
  : {
80
92
  // Not started yet is not a problem (a fresh install).
81
93
  what: "server",
@@ -85,15 +97,38 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
85
97
  },
86
98
  );
87
99
  const host = await d.host().catch(() => null);
100
+ const jobPid = host ? (d.hostJob?.() ?? null) : null;
101
+ // The service's host stands by while another host holds the socket, and
102
+ // takes over when that one exits: nothing to do.
103
+ const standsBy =
104
+ jobPid !== null && host?.pid !== undefined && jobPid !== host.pid;
105
+ const since = host?.startedAt ? `, since ${host.startedAt.slice(0, 10)}` : "";
106
+ const older = `an older session host (pid ${host?.pid ?? "?"}${since})`;
107
+ const switchNow = `To switch now (ends running terminals and agents): ${d.cli} sessions host --stop`;
88
108
  rows.push(
89
- host
90
- ? sessionRow("session host", host.launchd, d.cli)
91
- : {
109
+ host && standsBy
110
+ ? {
92
111
  what: "session host",
93
- session: "not running",
94
- status: "ok",
95
- detail: "it starts when the first terminal or agent does",
96
- },
112
+ session: host.launchd || `older host (pid ${host.pid})`,
113
+ status: "info",
114
+ detail: `${older} is serving; the service's host (pid ${jobPid}) takes over when it exits. Nothing to do. ${switchNow}`,
115
+ }
116
+ : host && !host.launchd
117
+ ? {
118
+ // An older host and no service host behind it: what it lacks.
119
+ what: "session host",
120
+ session: `unknown (older host${host.pid ? `, pid ${host.pid}` : ""})`,
121
+ status: "warn",
122
+ detail: `${older} can't say where it runs, so whether its terminals and agents stop at logout is unknown. It is replaced the next time it exits. ${switchNow}`,
123
+ }
124
+ : host
125
+ ? sessionRow("session host", host.launchd, d.cli, loginChosen)
126
+ : {
127
+ what: "session host",
128
+ session: "not running",
129
+ status: "ok",
130
+ detail: "it starts when the first terminal or agent does",
131
+ },
97
132
  );
98
133
  const fda = host?.access;
99
134
  if (fda && fda.fullDiskAccess !== null) {
@@ -116,7 +151,6 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
116
151
  },
117
152
  );
118
153
  }
119
- const svc = d.service();
120
154
  if (!svc)
121
155
  rows.push({
122
156
  what: "service",
@@ -145,6 +179,17 @@ export async function whereRows(d: WhereDeps): Promise<WhereRow[]> {
145
179
  return rows;
146
180
  }
147
181
 
182
+ /** The pid launchd runs `<domain>/<label>` as, or null (launchctl print). */
183
+ export function launchdJobPid(domain: string, label: string): number | null {
184
+ const r = spawnSync("launchctl", ["print", `${domain}/${label}`], {
185
+ encoding: "utf-8",
186
+ timeout: 3000,
187
+ });
188
+ if (r.status !== 0) return null;
189
+ const m = /^\s*pid = (\d+)/m.exec(r.stdout ?? "");
190
+ return m ? Number(m[1]) : null;
191
+ }
192
+
148
193
  export interface JobProbe {
149
194
  exists: (p: string) => boolean;
150
195
  /** `launchctl print <domain>/<label>`'s exit status (127: no launchctl). */
@@ -195,9 +240,10 @@ export function formatWhere(rows: WhereRow[], title: string): string[] {
195
240
  const lines = ["", `${title} doctor: where it runs`, ""];
196
241
  const w = Math.max(...rows.map((r) => r.session.length));
197
242
  for (const r of rows) {
198
- const mark = r.status === "ok" ? "✓" : "⚠";
243
+ const mark = r.status === "ok" ? "✓" : r.status === "info" ? "ℹ" : "⚠";
199
244
  lines.push(` ${r.what.padEnd(16)} ${r.session.padEnd(w)} ${mark}`);
200
- if (r.detail) lines.push(` ${r.status === "ok" ? "" : "⚠ "}${r.detail}`);
245
+ if (r.detail)
246
+ lines.push(` ${r.status === "warn" ? "⚠ " : ""}${r.detail}`);
201
247
  }
202
248
  return lines;
203
249
  }
@@ -138,6 +138,7 @@ import {
138
138
  resumeOrRelaunch,
139
139
  keepAgentConversation,
140
140
  askForHandoff,
141
+ rotationChoices,
141
142
  rotationSettings,
142
143
  setRotationSetting,
143
144
  rotationTick,
@@ -1328,7 +1329,18 @@ async function rotationRoute(
1328
1329
  const agent = q.get("agent") ?? undefined;
1329
1330
  if ((ws && !NAME_RE.test(ws)) || (agent && !NAME_RE.test(agent)))
1330
1331
  throw new AgentError("not a name", { usage: true });
1331
- sendJson(res, rotationSettings(d, agent, ws));
1332
+ // D30: ?choices=1 adds what the overrides form can pick, by name.
1333
+ sendJson(res, {
1334
+ ...rotationSettings(d, agent, ws),
1335
+ ...(q.get("choices") === "1"
1336
+ ? {
1337
+ choices: await rotationChoices(
1338
+ d,
1339
+ Object.keys(readWorkspacesFile().workspaces ?? {}),
1340
+ ),
1341
+ }
1342
+ : {}),
1343
+ });
1332
1344
  return true;
1333
1345
  }
1334
1346
  if (method !== "PUT") return methodNotAllowed(res, "GET, PUT");
@@ -0,0 +1,150 @@
1
+ /**
2
+ * A searchable picker (D30): a text field (role=combobox) over a listbox.
3
+ * Type to filter (the command palette's ranking, rankCommands), ↑/↓ to
4
+ * move, Enter (or a click) picks, Esc closes. What is picked is an id; what
5
+ * is shown is its name. One shared control, so a form never asks a person
6
+ * to type an id.
7
+ *
8
+ * comboHtml({ id, label, placeholder }) the markup (a <label>, the
9
+ * field, its list)
10
+ * wireCombo(root, id, { items, onPick }) makes it work; items() is read
11
+ * each time it opens, so it
12
+ * follows live data. Returns
13
+ * { value(), set(id), clear() }.
14
+ *
15
+ * An item: { id, label, sub?, keywords?, off? } (off: shown, not pickable).
16
+ */
17
+ import { rankCommands } from "./topbar.js";
18
+
19
+ const esc = (s) =>
20
+ String(s ?? "").replace(
21
+ /[&<>"']/g,
22
+ (c) =>
23
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
24
+ c
25
+ ],
26
+ );
27
+
28
+ export function comboHtml({ id, label, placeholder = "" }) {
29
+ return (
30
+ `<span class="den-combo" data-combo="${esc(id)}">` +
31
+ `<label for="${esc(id)}">${esc(label)}</label>` +
32
+ `<span class="den-combo-box">` +
33
+ `<input type="text" id="${esc(id)}" role="combobox" aria-expanded="false" aria-controls="${esc(id)}-list" aria-autocomplete="list" autocomplete="off" spellcheck="false" placeholder="${esc(placeholder)}">` +
34
+ `<ul id="${esc(id)}-list" role="listbox" aria-label="${esc(label)}" class="den-combo-list" hidden></ul>` +
35
+ `</span></span>`
36
+ );
37
+ }
38
+
39
+ /** Rows ranked for `q` (every item when it is empty). */
40
+ export function comboMatches(items, q) {
41
+ const list = items.map((x) => ({
42
+ ...x,
43
+ keywords: [x.id, x.sub ?? "", ...(x.keywords ?? [])],
44
+ }));
45
+ return rankCommands(list, q, { limit: 50, emptyLimit: 50 });
46
+ }
47
+
48
+ export function wireCombo(root, id, { items, onPick }) {
49
+ const input = root.querySelector(`#${CSS.escape(id)}`);
50
+ const ul = root.querySelector(`#${CSS.escape(id)}-list`);
51
+ if (!input || !ul) return null;
52
+ let picked = "";
53
+ let shown = [];
54
+ let sel = 0;
55
+ const close = () => {
56
+ ul.hidden = true;
57
+ input.setAttribute("aria-expanded", "false");
58
+ input.removeAttribute("aria-activedescendant");
59
+ };
60
+ const draw = () => {
61
+ shown = comboMatches(
62
+ items(),
63
+ input.value === labelOf(picked) ? "" : input.value,
64
+ );
65
+ sel = Math.min(sel, Math.max(0, shown.length - 1));
66
+ ul.innerHTML = shown.length
67
+ ? shown
68
+ .map(
69
+ (x, i) =>
70
+ `<li role="option" id="${esc(id)}-o-${i}" data-i="${i}" aria-selected="${i === sel}"${x.off ? ' aria-disabled="true"' : ""}>` +
71
+ `<span class="den-combo-l">${esc(x.label)}</span>` +
72
+ (x.sub ? `<span class="den-combo-s">${esc(x.sub)}</span>` : "") +
73
+ `</li>`,
74
+ )
75
+ .join("")
76
+ : `<li class="den-combo-none" aria-disabled="true">No match</li>`;
77
+ ul.hidden = false;
78
+ input.setAttribute("aria-expanded", "true");
79
+ if (shown.length)
80
+ input.setAttribute("aria-activedescendant", `${id}-o-${sel}`);
81
+ ul.querySelector(`[data-i="${sel}"]`)?.scrollIntoView?.({
82
+ block: "nearest",
83
+ });
84
+ };
85
+ const labelOf = (v) => items().find((x) => x.id === v)?.label ?? "";
86
+ const pick = (x) => {
87
+ if (!x || x.off) return;
88
+ picked = x.id;
89
+ input.value = x.label;
90
+ input.dataset.value = x.id;
91
+ close();
92
+ onPick?.(x);
93
+ };
94
+ input.addEventListener("focus", () => {
95
+ sel = 0;
96
+ draw();
97
+ });
98
+ input.addEventListener("input", () => {
99
+ picked = "";
100
+ delete input.dataset.value;
101
+ sel = 0;
102
+ draw();
103
+ });
104
+ input.addEventListener("keydown", (e) => {
105
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
106
+ e.preventDefault();
107
+ if (ul.hidden) return draw();
108
+ const n = shown.length;
109
+ if (!n) return;
110
+ sel = (sel + (e.key === "ArrowDown" ? 1 : n - 1)) % n;
111
+ draw();
112
+ } else if (e.key === "Enter") {
113
+ if (ul.hidden) return;
114
+ e.preventDefault();
115
+ pick(shown[sel]);
116
+ } else if (e.key === "Escape") {
117
+ if (!ul.hidden) {
118
+ e.preventDefault();
119
+ e.stopPropagation();
120
+ close();
121
+ }
122
+ }
123
+ });
124
+ // mousedown, not click: the field keeps its focus (no blur close first).
125
+ ul.addEventListener("mousedown", (e) => {
126
+ const li = e.target.closest?.("[data-i]");
127
+ if (!li) return;
128
+ e.preventDefault();
129
+ pick(shown[Number(li.dataset.i)]);
130
+ });
131
+ input.addEventListener("blur", () => {
132
+ close();
133
+ // Leaving with a half-typed name: back to what was picked.
134
+ if (input.value !== labelOf(picked)) input.value = labelOf(picked);
135
+ });
136
+ return {
137
+ value: () => picked,
138
+ set: (v) => {
139
+ picked = v;
140
+ input.value = labelOf(v);
141
+ if (v) input.dataset.value = v;
142
+ else delete input.dataset.value;
143
+ },
144
+ clear: () => {
145
+ picked = "";
146
+ input.value = "";
147
+ delete input.dataset.value;
148
+ },
149
+ };
150
+ }
@@ -305,10 +305,15 @@ function githubBlock(s, t) {
305
305
 
306
306
  /**
307
307
  * D17: a Den with no owner yet that a rule lets someone claim. It leads the
308
- * page: Connect your GitHub account. "This is my computer" (with
309
- * web.allowed_github_org) goes to localhost and back, which only works in a
310
- * browser on the machine Den runs on, then starts the GitHub sign-in with
311
- * that proof; "Sign in as @login" (web.owner_github) works from anywhere.
308
+ * page: Connect your GitHub account.
309
+ *
310
+ * - "Sign in as @login" (web.owner_github) works from any browser: when
311
+ * it is there, it is the primary button.
312
+ * - The localhost trip (web.allowed_github_org) only works in a browser
313
+ * on the machine Den runs on, so its button says so ("On <host>
314
+ * itself: connect GitHub"), never a footnote (D29).
315
+ * - Only the org rule: the page says to open it on <host>, or to set
316
+ * web.owner_github to sign in from anywhere.
312
317
  */
313
318
  function claimBlock(s, t) {
314
319
  const g = s.github;
@@ -316,24 +321,31 @@ function claimBlock(s, t) {
316
321
  const host = s.machine || "";
317
322
  const back = location.origin + location.pathname + location.search;
318
323
  const here = g.attest ? `${g.attest}?return=${encodeURIComponent(back)}` : "";
324
+ const as = g.ownerGithub
325
+ ? `<button type="button" class="btn primary" id="den-gh-start">${esc(t("err.signin.claimAs", { login: g.ownerGithub }))}</button>`
326
+ : "";
327
+ const hereBtn = here
328
+ ? `<a class="btn${as ? "" : " primary"}" id="den-claim-here" href="${esc(here)}">${
329
+ host
330
+ ? withHost(t, "err.signin.claimHereOn", { host })
331
+ : esc(t("err.signin.claimHere"))
332
+ }</a>`
333
+ : "";
319
334
  return (
320
335
  `<div id="den-gh" class="den-claim">` +
321
336
  `<h2 class="den-h" id="den-claim-title">${esc(t("err.signin.claimTitle"))}</h2>` +
322
337
  `<p class="lead" id="den-claim-lead">${esc(t("err.signin.claimLead", { name: s.name || "Den" }))}</p>` +
323
338
  (g.org
324
- ? `<p id="den-claim-org">${withHost(t, "err.signin.claimOrg", { org: g.org, host })}</p>`
339
+ ? `<p id="den-claim-org">${withHost(
340
+ t,
341
+ g.ownerGithub ? "err.signin.claimOrg" : "err.signin.claimOrgOnly",
342
+ { org: g.org, host },
343
+ )}</p>`
325
344
  : "") +
326
345
  `<div class="acts" style="margin-top:0">` +
327
- (here
328
- ? `<a class="btn primary" id="den-claim-here" href="${esc(here)}">${esc(t("err.signin.claimHere"))}</a>`
329
- : "") +
330
- (g.ownerGithub
331
- ? `<button type="button" class="btn${here ? "" : " primary"}" id="den-gh-start">${esc(t("err.signin.claimAs", { login: g.ownerGithub }))}</button>`
332
- : "") +
346
+ as +
347
+ hereBtn +
333
348
  `</div><div id="den-gh-flow" aria-live="polite"></div>` +
334
- (here
335
- ? `<p class="den-gh-note" id="den-claim-note">${withHost(t, "err.signin.claimHereNote", { host })}</p>`
336
- : "") +
337
349
  `</div>`
338
350
  );
339
351
  }
@@ -311,9 +311,10 @@
311
311
  "err.signin.claimOrg":
312
312
  "Los miembros de la organización {org} de GitHub pueden reclamarlo, en un navegador en {host}.",
313
313
  "err.signin.claimHere": "Este es mi ordenador: conectar GitHub",
314
+ "err.signin.claimHereOn": "En {host}: conectar GitHub",
315
+ "err.signin.claimOrgOnly":
316
+ "Los miembros de la organización {org} de GitHub pueden reclamarlo desde un navegador en {host}. Abre esta página en {host}, o define web.owner_github para iniciar sesión desde cualquier sitio.",
314
317
  "err.signin.claimAs": "Iniciar sesión como @{login}",
315
- "err.signin.claimHereNote":
316
- "«Este es mi ordenador» solo funciona en un navegador en {host}. En otro sitio no abre nada.",
317
318
  "err.signin.claimChecking": "Comprobando que esto es {host}…",
318
319
  "err.signin.githubRefused":
319
320
  "@{login} no es el propietario de este {name}. Solo la cuenta del propietario puede usar Iniciar sesión con GitHub. Usa «Aprueba este ordenador» en su lugar.",
@@ -299,9 +299,10 @@
299
299
  "err.signin.claimOrg":
300
300
  "GitHub の {org} 組織のメンバーは、{host} 上のブラウザーでこれを登録できます。",
301
301
  "err.signin.claimHere": "これは自分のコンピューター: GitHub を接続",
302
+ "err.signin.claimHereOn": "{host} 上で: GitHub を接続",
303
+ "err.signin.claimOrgOnly":
304
+ "GitHub の {org} 組織のメンバーは、{host} 上のブラウザーからこれを登録できます。このページを {host} で開くか、どこからでもサインインできるよう web.owner_github を設定してください。",
302
305
  "err.signin.claimAs": "@{login} としてサインイン",
303
- "err.signin.claimHereNote":
304
- "「これは自分のコンピューター」は {host} 上のブラウザーでだけ使えます。ほかの場所では何も開きません。",
305
306
  "err.signin.claimChecking": "ここが {host} か確認しています…",
306
307
  "err.signin.githubRefused":
307
308
  "@{login} はこの {name} のオーナーではありません。GitHub でサインインを使えるのはオーナーのアカウントだけです。代わりに「このコンピューターを承認」を使ってください。",
@@ -30,6 +30,7 @@
30
30
  import { themeNotice, themeOptions } from "./appearance.js";
31
31
  import { qrSvg } from "./qr.js";
32
32
  import { renderPersonasSettings } from "./persona-ui.js";
33
+ import { comboHtml, wireCombo } from "./combobox.js";
33
34
 
34
35
  export const DEN_SETTING_CATEGORIES = [
35
36
  {
@@ -1288,6 +1289,67 @@ const ROT_KEYS = [
1288
1289
  ["on_pr_merged", "When its PR merges", "true or false"],
1289
1290
  ];
1290
1291
  const rotVal = (v) => (v === undefined ? "" : String(v));
1292
+ /** A value as a person reads it (D30): "never", "Auto", "Relaunch". */
1293
+ function rotValText(k, v) {
1294
+ if (k === "on_due")
1295
+ return { auto: "Auto", ask: "Ask", off: "Off" }[v] ?? rotVal(v);
1296
+ if (k === "on_pr_merged")
1297
+ return v === true ? "Relaunch" : v === false ? "Keep going" : rotVal(v);
1298
+ return rotVal(v);
1299
+ }
1300
+
1301
+ /**
1302
+ * D30: an override's target as a name, from the pickers' choices. One that
1303
+ * no longer exists: its id, and gone.
1304
+ */
1305
+ export function rotTargetName(scope, target, c = {}) {
1306
+ const find = (list, id) => (list || []).find((x) => x.id === id);
1307
+ if (scope === "agent") {
1308
+ const a = find(c.agents, target);
1309
+ return a
1310
+ ? { name: a.workspace ? `${a.name} (${a.workspace})` : a.name }
1311
+ : { name: target, gone: true };
1312
+ }
1313
+ if (scope === "persona-workspace") {
1314
+ const [pid, ws] = String(target).split("@");
1315
+ const p = find(c.personas, pid);
1316
+ const w = find(c.workspaces, ws);
1317
+ return p && w
1318
+ ? { name: `${p.name} in ${w.name}` }
1319
+ : { name: target, gone: true };
1320
+ }
1321
+ const list =
1322
+ scope === "persona"
1323
+ ? c.personas
1324
+ : scope === "workspace"
1325
+ ? c.workspaces
1326
+ : c.services;
1327
+ const x = find(list, target);
1328
+ return x ? { name: x.name } : { name: target, gone: true };
1329
+ }
1330
+
1331
+ /** D30: each picker's rows, from GET /api/agents/rotation?choices=1. */
1332
+ export function rotPickItems(kind, c = {}) {
1333
+ if (kind === "agent")
1334
+ return (c.agents || []).map((a) => ({
1335
+ id: a.id,
1336
+ label: a.name,
1337
+ sub: [a.workspace, a.state].filter(Boolean).join(" · "),
1338
+ }));
1339
+ if (kind === "persona")
1340
+ return (c.personas || []).map((p) => ({
1341
+ id: p.id,
1342
+ label: p.name,
1343
+ sub: p.category,
1344
+ }));
1345
+ if (kind === "workspace")
1346
+ return (c.workspaces || []).map((w) => ({ id: w.id, label: w.name }));
1347
+ return (c.services || []).map((x) => ({
1348
+ id: x.id,
1349
+ label: x.name,
1350
+ sub: x.workspace,
1351
+ }));
1352
+ }
1291
1353
 
1292
1354
  /** A value typed in the overrides form, as the key takes it. */
1293
1355
  export function rotationValue(key, raw) {
@@ -1325,11 +1387,16 @@ export function rotationHtml(b) {
1325
1387
  const rows = [];
1326
1388
  for (const [scope, byTarget] of Object.entries(b.overrides || {}))
1327
1389
  for (const [target, vals] of Object.entries(byTarget || {}))
1328
- for (const [k, v] of Object.entries(vals || {}))
1390
+ for (const [k, v] of Object.entries(vals || {})) {
1391
+ const who = rotTargetName(scope, target, b.choices);
1392
+ const setting = (ROT_KEYS.find((x) => x[0] === k) || [k, k])[1];
1329
1393
  rows.push(
1330
- `<tr data-rot-row><td>${escR((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1])}</td><td class="den-mono">${escR(target)}</td><td>${escR(k)}</td><td class="den-mono">${escR(rotVal(v))}</td>` +
1331
- `<td><button type="button" class="btn sm" data-rot-clear data-scope="${escR(scope)}" data-target="${escR(target)}" data-key="${escR(k)}" aria-label="Clear ${escR(k)} for ${escR(scope)} ${escR(target)}">Clear</button></td></tr>`,
1394
+ `<tr data-rot-row${who.gone ? " data-rot-gone" : ""}><td data-l="Scope">${escR((ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1])}</td>` +
1395
+ `<td data-l="For"${who.gone ? ' class="rot-gone"' : ""}>${who.gone ? `<span class="den-mono">${escR(who.name)}</span> (gone)` : escR(who.name)}</td>` +
1396
+ `<td data-l="Setting">${escR(setting)}</td><td data-l="Value">${escR(rotValText(k, v))}</td>` +
1397
+ `<td class="rot-act"><button type="button" class="btn sm" data-rot-clear data-scope="${escR(scope)}" data-target="${escR(target)}" data-key="${escR(k)}" aria-label="${who.gone ? "Remove" : "Clear"} ${escR(setting)} for ${escR(who.name)}">${who.gone ? "Remove" : "Clear"}</button></td></tr>`,
1332
1398
  );
1399
+ }
1333
1400
  return (
1334
1401
  `<div class="rot" data-rotation-settings>` +
1335
1402
  `<div class="rot-inst">` +
@@ -1359,10 +1426,16 @@ export function rotationHtml(b) {
1359
1426
  : `<tr><td colspan="5" class="set-note">No overrides: every agent uses this install's values.</td></tr>`) +
1360
1427
  `</tbody></table></div>` +
1361
1428
  `<form class="rot-add" data-rot-add>` +
1362
- `<label for="rot-a-scope">Scope</label> <select id="rot-a-scope">${ROT_SCOPES.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1363
- `<label for="rot-a-target">For</label> <input type="text" id="rot-a-target" class="den-mono" spellcheck="false" autocomplete="off" placeholder="agent id" required> ` +
1364
- `<label for="rot-a-key">Setting</label> <select id="rot-a-key">${ROT_KEYS.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select> ` +
1365
- `<label for="rot-a-value">Value</label> <input type="text" id="rot-a-value" class="den-mono" spellcheck="false" autocomplete="off" placeholder="a number, or never" required> ` +
1429
+ `<span class="rot-pick"><label for="rot-a-scope">Scope</label> <select id="rot-a-scope">${ROT_SCOPES.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select></span>` +
1430
+ // The target: picked by name, never typed as an id (D30).
1431
+ `<span class="rot-pick" data-for="agent">${comboHtml({ id: "rot-a-agent", label: "Agent", placeholder: "Type to find an agent" })}</span>` +
1432
+ `<span class="rot-pick" data-for="persona persona-workspace" hidden>${comboHtml({ id: "rot-a-persona", label: "Persona", placeholder: "Type to find a persona" })}</span>` +
1433
+ `<span class="rot-pick" data-for="workspace persona-workspace" hidden>${comboHtml({ id: "rot-a-ws", label: "Workspace", placeholder: "Type to find a workspace" })}</span>` +
1434
+ `<span class="rot-pick" data-for="service" hidden>${comboHtml({ id: "rot-a-service", label: "Service", placeholder: "Type to find a service" })}</span>` +
1435
+ `<span class="rot-pick"><label for="rot-a-key">Setting</label> <select id="rot-a-key">${ROT_KEYS.map(([v, l]) => `<option value="${v}">${escR(l)}</option>`).join("")}</select></span>` +
1436
+ `<span class="rot-pick" data-val="after_compactions"><label for="rot-a-n">Value</label> <input type="number" id="rot-a-n" min="1" max="100" step="1" value="3"> <label class="rot-never"><input type="checkbox" id="rot-a-never"> never</label></span>` +
1437
+ `<span class="rot-pick" data-val="on_due" hidden><label for="rot-a-due">Value</label> <select id="rot-a-due"><option value="auto">Auto</option><option value="ask">Ask</option><option value="off">Off</option></select></span>` +
1438
+ `<span class="rot-pick" data-val="on_pr_merged" hidden><label for="rot-a-pr">Value</label> <select id="rot-a-pr"><option value="true">Relaunch</option><option value="false">Keep going</option></select></span>` +
1366
1439
  `<button type="submit" class="btn sm primary">Add override</button></form>` +
1367
1440
  `<div class="rt-err" role="alert" hidden></div>` +
1368
1441
  `</div>`
@@ -1385,14 +1458,54 @@ function renderRotation(host, ctx) {
1385
1458
  return Promise.resolve();
1386
1459
  }
1387
1460
  return ctx.shell
1388
- .api("GET", "/api/agents/rotation")
1461
+ .api("GET", "/api/agents/rotation?choices=1")
1389
1462
  .then((b) => {
1390
1463
  host.innerHTML = rotationHtml(b || {});
1464
+ wireAdd(b?.choices || {});
1391
1465
  })
1392
1466
  .catch((x) => {
1393
1467
  host.innerHTML = `<div class="set-note">${escR(x?.message || "The rotation settings did not load.")}</div>`;
1394
1468
  });
1395
1469
  };
1470
+ // D30: the add form's pickers, filled from live data.
1471
+ let picks = {};
1472
+ const wireAdd = (c) => {
1473
+ picks = {};
1474
+ for (const [kind, id] of [
1475
+ ["agent", "rot-a-agent"],
1476
+ ["persona", "rot-a-persona"],
1477
+ ["workspace", "rot-a-ws"],
1478
+ ["service", "rot-a-service"],
1479
+ ])
1480
+ picks[kind] = wireCombo(host, id, { items: () => rotPickItems(kind, c) });
1481
+ showFor();
1482
+ };
1483
+ const showFor = () => {
1484
+ const scope = host.querySelector("#rot-a-scope")?.value;
1485
+ const key = host.querySelector("#rot-a-key")?.value;
1486
+ for (const el of host.querySelectorAll(".rot-add [data-for]"))
1487
+ el.hidden = !el.dataset.for.split(" ").includes(scope);
1488
+ for (const el of host.querySelectorAll(".rot-add [data-val]"))
1489
+ el.hidden = el.dataset.val !== key;
1490
+ const n = host.querySelector("#rot-a-n");
1491
+ const never = host.querySelector("#rot-a-never");
1492
+ if (n && never) n.disabled = never.checked;
1493
+ };
1494
+ const targetOf = (scope) => {
1495
+ const v = (k) => picks[k]?.value() || "";
1496
+ if (scope === "persona-workspace")
1497
+ return v("persona") && v("workspace")
1498
+ ? `${v("persona")}@${v("workspace")}`
1499
+ : "";
1500
+ return v(scope);
1501
+ };
1502
+ const valueOf = (key) => {
1503
+ if (key === "on_due") return host.querySelector("#rot-a-due").value;
1504
+ if (key === "on_pr_merged")
1505
+ return host.querySelector("#rot-a-pr").value === "true";
1506
+ if (host.querySelector("#rot-a-never").checked) return "never";
1507
+ return Number(host.querySelector("#rot-a-n").value);
1508
+ };
1396
1509
  const put = async (body) => {
1397
1510
  try {
1398
1511
  await ctx.shell.api("PUT", "/api/agents/rotation", body);
@@ -1403,16 +1516,11 @@ function renderRotation(host, ctx) {
1403
1516
  };
1404
1517
  host.addEventListener("change", (e) => {
1405
1518
  const k = e.target.closest("[data-rot-key]")?.dataset.rotKey;
1406
- if (e.target.id === "rot-a-scope") {
1407
- const s = ROT_SCOPES.find((x) => x[0] === e.target.value);
1408
- const t = host.querySelector("#rot-a-target");
1409
- if (s && t) t.placeholder = s[2];
1410
- return;
1411
- }
1412
- if (e.target.id === "rot-a-key") {
1413
- const s = ROT_KEYS.find((x) => x[0] === e.target.value);
1414
- const v = host.querySelector("#rot-a-value");
1415
- if (s && v) v.placeholder = s[2];
1519
+ if (
1520
+ ["rot-a-scope", "rot-a-key", "rot-a-never"].includes(e.target.id) ||
1521
+ e.target.closest?.(".rot-add")
1522
+ ) {
1523
+ showFor();
1416
1524
  return;
1417
1525
  }
1418
1526
  if (!k || k === "after_compactions" || e.target.tagName !== "SELECT")
@@ -1447,12 +1555,18 @@ function renderRotation(host, ctx) {
1447
1555
  if (!f) return;
1448
1556
  e.preventDefault();
1449
1557
  const key = f.querySelector("#rot-a-key").value;
1450
- void put({
1451
- scope: f.querySelector("#rot-a-scope").value,
1452
- target: f.querySelector("#rot-a-target").value.trim(),
1453
- key,
1454
- value: rotationValue(key, f.querySelector("#rot-a-value").value),
1455
- });
1558
+ const scope = f.querySelector("#rot-a-scope").value;
1559
+ const target = targetOf(scope);
1560
+ if (!target) {
1561
+ fail({
1562
+ message:
1563
+ scope === "persona-workspace"
1564
+ ? "Pick a persona and a workspace."
1565
+ : `Pick a ${(ROT_SCOPES.find((x) => x[0] === scope) || [scope, scope])[1].toLowerCase()}.`,
1566
+ });
1567
+ return;
1568
+ }
1569
+ void put({ scope, target, key, value: valueOf(key) });
1456
1570
  });
1457
1571
  void load();
1458
1572
  }
@@ -454,6 +454,87 @@
454
454
  .rot-add {
455
455
  margin-top: 10px;
456
456
  }
457
+ /* D30: the add form: each control with its label, wrapping. */
458
+ .rot-pick {
459
+ display: inline-flex;
460
+ align-items: center;
461
+ gap: 6px;
462
+ min-width: 0;
463
+ flex: 1 1 200px;
464
+ flex-wrap: wrap;
465
+ }
466
+ .rot-pick[hidden] {
467
+ display: none;
468
+ }
469
+ .rot-pick input[type="number"] {
470
+ width: 6em;
471
+ flex: 0 0 auto;
472
+ }
473
+ .rot-never {
474
+ display: inline-flex;
475
+ align-items: center;
476
+ gap: 4px;
477
+ }
478
+ .rot-gone {
479
+ color: var(--muted);
480
+ }
481
+ /* The shared picker (combobox.js). */
482
+ .den-combo {
483
+ display: inline-flex;
484
+ align-items: center;
485
+ gap: 6px;
486
+ min-width: 0;
487
+ flex: 1 1 200px;
488
+ flex-wrap: wrap;
489
+ }
490
+ .den-combo-box {
491
+ position: relative;
492
+ flex: 1 1 160px;
493
+ min-width: 0;
494
+ }
495
+ .den-combo-box input {
496
+ width: 100%;
497
+ box-sizing: border-box;
498
+ }
499
+ .den-combo-list {
500
+ position: absolute;
501
+ z-index: 20;
502
+ left: 0;
503
+ right: 0;
504
+ top: calc(100% + 2px);
505
+ max-height: 240px;
506
+ overflow-y: auto;
507
+ margin: 0;
508
+ padding: 4px;
509
+ list-style: none;
510
+ background: var(--surface);
511
+ border: 1px solid var(--border);
512
+ border-radius: 8px;
513
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
514
+ }
515
+ .den-combo-list[hidden] {
516
+ display: none;
517
+ }
518
+ .den-combo-list li {
519
+ display: flex;
520
+ flex-wrap: wrap;
521
+ justify-content: space-between;
522
+ gap: 2px 8px;
523
+ padding: 5px 8px;
524
+ border-radius: 6px;
525
+ cursor: pointer;
526
+ }
527
+ .den-combo-list li[aria-selected="true"] {
528
+ background: var(--sunken);
529
+ }
530
+ .den-combo-list li[aria-disabled="true"] {
531
+ color: var(--muted);
532
+ cursor: default;
533
+ }
534
+ .den-combo-s {
535
+ color: var(--muted);
536
+ font-size: 12px;
537
+ }
457
538
  .rot-ov caption {
458
539
  text-align: left;
459
540
  color: var(--muted);
@@ -464,7 +545,49 @@
464
545
  .rot-ov td {
465
546
  padding: 4px 10px 4px 0;
466
547
  text-align: left;
467
- overflow-wrap: anywhere;
548
+ /* Break a key at its "_", never mid-word. */
549
+ overflow-wrap: normal;
550
+ word-break: normal;
551
+ }
552
+ /* Narrow: each override is a card of label/value lines, so no
553
+ column is squeezed into breaking words mid-letter. */
554
+ @media (max-width: 560px) {
555
+ .rot-ov thead {
556
+ position: absolute;
557
+ width: 1px;
558
+ height: 1px;
559
+ overflow: hidden;
560
+ clip: rect(0 0 0 0);
561
+ }
562
+ .rot-ov,
563
+ .rot-ov tbody,
564
+ .rot-ov tr,
565
+ .rot-ov td {
566
+ display: block;
567
+ width: 100%;
568
+ }
569
+ .rot-ov caption {
570
+ display: block;
571
+ }
572
+ .rot-ov tr[data-rot-row] {
573
+ padding: 6px 0;
574
+ border-top: 1px solid var(--border);
575
+ }
576
+ .rot-ov td {
577
+ display: flex;
578
+ gap: 8px;
579
+ padding: 2px 0;
580
+ overflow-wrap: anywhere;
581
+ }
582
+ .rot-ov td[data-l]::before {
583
+ content: attr(data-l);
584
+ flex: 0 0 64px;
585
+ color: var(--muted);
586
+ font-family: inherit;
587
+ }
588
+ .rot-ov td.rot-act {
589
+ padding-top: 4px;
590
+ }
468
591
  }
469
592
  .rt-fl {
470
593
  flex-basis: 100%;
@@ -426,9 +426,10 @@
426
426
  "err.signin.claimOrg":
427
427
  "Members of the {org} GitHub org can claim it, in a browser on {host}.",
428
428
  "err.signin.claimHere": "This is my computer: connect GitHub",
429
+ "err.signin.claimHereOn": "On {host} itself: connect GitHub",
430
+ "err.signin.claimOrgOnly":
431
+ "Members of the {org} GitHub org can claim it, from a browser on {host}. Open this page on {host}, or set web.owner_github to sign in from anywhere.",
429
432
  "err.signin.claimAs": "Sign in as @{login}",
430
- "err.signin.claimHereNote":
431
- "This is my computer works only in a browser on {host}. Anywhere else it opens nothing.",
432
433
  "err.signin.claimChecking": "Checking this is {host}…",
433
434
  "err.signin.githubRefused":
434
435
  "@{login} isn't the owner of this {name}. Only the owner's account can use Sign in with GitHub. Use Approve this computer instead.",
@@ -2320,28 +2321,31 @@
2320
2321
  var down = Object.prototype.hasOwnProperty.call(off, id)
2321
2322
  ? !!off[id]
2322
2323
  : state.health[id] === "down";
2324
+ // D27: a real link (never popup-blocked, as window.open was in Safari).
2325
+ var live = !down && /^https?:\/\//i.test(url);
2323
2326
  return (
2324
- '<button type="button" class="den-pvisit" tabindex="-1" data-visit="' +
2327
+ '<a class="den-pvisit" tabindex="-1" data-visit="' +
2325
2328
  esc(id) +
2326
2329
  '" data-url="' +
2327
2330
  esc(url) +
2328
- '" aria-label="' +
2331
+ '"' +
2332
+ (live
2333
+ ? ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer"'
2334
+ : ' role="link" aria-disabled="true"') +
2335
+ ' aria-label="' +
2329
2336
  esc(t("act.visitX", { s: id })) +
2330
2337
  '" title="' +
2331
2338
  esc(down ? t("svp.notRunning") : url) +
2332
- '"' +
2333
- (down ? ' aria-disabled="true"' : "") +
2334
- '><span aria-hidden="true">↗</span></button>'
2339
+ '"><span aria-hidden="true">↗</span></a>'
2335
2340
  );
2336
2341
  }
2342
+ /** A pin's Visit ↗ clicked: true when the link opens by itself. */
2337
2343
  function visitPin(b) {
2338
- if (b.getAttribute("aria-disabled") === "true") {
2344
+ if (b.getAttribute("aria-disabled") === "true" || !b.hasAttribute("href")) {
2339
2345
  toast(t("svp.notRunning"));
2340
- return;
2346
+ return false;
2341
2347
  }
2342
- var url = b.getAttribute("data-url");
2343
- if (url && /^https?:\/\//i.test(url))
2344
- window.open(url, "_blank", "noopener");
2348
+ return true;
2345
2349
  }
2346
2350
  // 0.4.18: the service panel (svc-panel.js registers it).
2347
2351
  var servicePanel = null;
@@ -2653,8 +2657,7 @@
2653
2657
  var tgt = e.target;
2654
2658
  var v = tgt && tgt.closest ? tgt.closest("[data-visit]") : null;
2655
2659
  if (v) {
2656
- e.preventDefault();
2657
- visitPin(v);
2660
+ if (!visitPin(v)) e.preventDefault();
2658
2661
  return;
2659
2662
  }
2660
2663
  // 0.4.18: a plain click (or Enter) on a pinned row opens its panel;
@@ -5018,6 +5021,9 @@
5018
5021
  ".den-btn.primary { background: var(--accent-fill); border-color: var(--accent-fill); color: var(--on-accent); }",
5019
5022
  ".den-btn.sm { padding: 2px 9px; font-size: 12.5px; }",
5020
5023
  ".den-btn:disabled { opacity: .6; cursor: default; }",
5024
+ // D27: Visit is a link styled as a button.
5025
+ "a.den-btn, a.den-pvisit { text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }",
5026
+ "a.den-pvisit { display: grid; }",
5021
5027
  // Status drawer (.scrim, .drawer, .prob-card, .grp, .svl: R15)
5022
5028
  ".den-scrim { position: fixed; inset: 0; background: rgb(0 0 0 / 0.35); z-index: 40; }",
5023
5029
  ".den-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100%); z-index: 41;",
@@ -67,6 +67,9 @@ const S = {
67
67
  testing: new Set(),
68
68
  /** The open panel: { id, el, opener, scrim, timer }. */
69
69
  open: null,
70
+ /** D27: a pointer is down inside the panel: repaint after it is up. */
71
+ pressed: false,
72
+ syncLater: false,
70
73
  };
71
74
 
72
75
  /** Start or Restart from here: "starting…" until health says, or the timeout. */
@@ -228,14 +231,21 @@ function testsHtml(s) {
228
231
  return `<span class="den-svp-${ok ? "ok" : "bad"}">${esc(t(ok ? "svp.testsOk" : "svp.testsBad", { t: at }))}</span>`;
229
232
  }
230
233
 
231
- /** A Visit button (the rail row's ↗, or the panel's primary). */
234
+ /**
235
+ * Visit: a real link, styled as a button (D27). A native target=_blank link
236
+ * is never popup-blocked (Safari blocked window.open from the delegated
237
+ * click) and works with no script. Off (down, starting): no href,
238
+ * aria-disabled, and a click says why (visit()).
239
+ */
232
240
  export function visitHtml(s, cls, label) {
233
241
  if (!s.url) return "";
234
- const on = visitable(s);
242
+ const on = visitable(s) && /^https?:\/\//i.test(s.url);
235
243
  return (
236
- `<button type="button" class="${cls}" data-visit="${esc(s.id)}" data-url="${esc(s.url)}"` +
237
- ` aria-label="${esc(t("act.visitX", { s: s.id }))}" title="${esc(on ? s.url : t("svp.notRunning"))}"` +
238
- `${on ? "" : ' aria-disabled="true"'}>${label}</button>`
244
+ `<a class="${cls}" data-visit="${esc(s.id)}" data-url="${esc(s.url)}"` +
245
+ (on
246
+ ? ` href="${esc(s.url)}" target="_blank" rel="noopener noreferrer"`
247
+ : ` role="link" tabindex="0" aria-disabled="true"`) +
248
+ ` aria-label="${esc(t("act.visitX", { s: s.id }))}" title="${esc(on ? s.url : t("svp.notRunning"))}">${label}</a>`
239
249
  );
240
250
  }
241
251
 
@@ -335,6 +345,12 @@ function sync() {
335
345
  publishUrls();
336
346
  const o = S.open;
337
347
  if (!o) return;
348
+ // D27: never repaint under a press: the button pressed would be replaced
349
+ // between pointerdown and click, and the click would land on the panel.
350
+ if (S.pressed) {
351
+ S.syncLater = true;
352
+ return;
353
+ }
338
354
  const a = document.activeElement;
339
355
  const mine = a && o.el.contains(a) && a !== o.el;
340
356
  const key = mine
@@ -451,15 +467,20 @@ export function panelOpen() {
451
467
  const env = () => S.deps.currentEnv?.() ?? "local";
452
468
  const runs = () => window.denShell?.runs;
453
469
 
454
- /** Visit: the service's URL in a new tab, or why not. */
470
+ /**
471
+ * Visit, clicked: a live link opens by itself (the browser follows it, so
472
+ * true: leave the click alone); an off one says why (false: the caller
473
+ * prevents the click).
474
+ */
455
475
  export function visit(btn) {
456
- if (btn.getAttribute("aria-disabled") === "true") {
476
+ if (
477
+ btn.getAttribute("aria-disabled") === "true" ||
478
+ !btn.hasAttribute("href")
479
+ ) {
457
480
  window.denShell?.toast?.(t("svp.notRunning"));
458
- return;
481
+ return false;
459
482
  }
460
- const url = btn.dataset.url;
461
- if (!url || !/^https?:\/\//i.test(url)) return;
462
- window.open(url, "_blank", "noopener");
483
+ return true;
463
484
  }
464
485
 
465
486
  /** A stream into a runs-dock tab: Start, Stop, Restart (exec), Logs, tests. */
@@ -635,6 +656,27 @@ export function initServicePanel(deps = {}) {
635
656
  sync();
636
657
  });
637
658
  window.addEventListener("den:pins", () => sync());
659
+ // D27: a press inside the open panel holds its repaint until it ends.
660
+ document.addEventListener(
661
+ "pointerdown",
662
+ (e) => {
663
+ if (S.open?.el.contains(e.target)) S.pressed = true;
664
+ },
665
+ true,
666
+ );
667
+ const released = () => {
668
+ if (!S.pressed) return;
669
+ S.pressed = false;
670
+ // After the click this press makes, then the repaint it held.
671
+ setTimeout(() => {
672
+ if (S.syncLater) {
673
+ S.syncLater = false;
674
+ sync();
675
+ }
676
+ }, 0);
677
+ };
678
+ document.addEventListener("pointerup", released, true);
679
+ document.addEventListener("pointercancel", released, true);
638
680
  document.addEventListener(
639
681
  "click",
640
682
  (e) => {
@@ -643,10 +685,7 @@ export function initServicePanel(deps = {}) {
643
685
  // The panel's Visit here; the rail's ↗ is shell.js's (the same visit()).
644
686
  const v = tgt.closest?.("[data-visit]");
645
687
  if (v) {
646
- if (o?.el.contains(v)) {
647
- e.preventDefault();
648
- visit(v);
649
- }
688
+ if (o?.el.contains(v) && !visit(v)) e.preventDefault();
650
689
  return;
651
690
  }
652
691
  const opener = tgt.closest?.("[data-svpanel-open]");
@@ -52,6 +52,8 @@ export const UI_MODULES: readonly string[] = [
52
52
  "/server-agent-page.js",
53
53
  // 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
54
54
  "/svc-panel.js",
55
+ // D30: the shared searchable picker (Settings › Agents › Rotation).
56
+ "/combobox.js",
55
57
  // 0.4.18: where a service's live log comes from (Logs page and panel).
56
58
  "/log-source.js",
57
59
  ];