@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 +26 -0
- package/package.json +1 -1
- package/service/install.sh +5 -3
- package/src/ui/cli/agent-ops.ts +65 -0
- package/src/ui/cli/doctor-command.ts +24 -1
- package/src/ui/cli/doctor-where.ts +59 -13
- package/src/ui/server/agent-routes.ts +13 -1
- package/src/ui/server/public/combobox.js +150 -0
- package/src/ui/server/public/error-page.js +26 -14
- package/src/ui/server/public/i18n/es.js +3 -2
- package/src/ui/server/public/i18n/ja.js +3 -2
- package/src/ui/server/public/settings-registry.js +138 -24
- package/src/ui/server/public/settings.html +124 -1
- package/src/ui/server/public/shell.js +20 -14
- package/src/ui/server/public/svc-panel.js +54 -15
- package/src/ui/server/ui-assets.ts +2 -0
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
package/service/install.sh
CHANGED
|
@@ -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
|
|
114
|
-
echo " when that version is removed
|
|
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
|
-
|
|
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
|
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
91
|
-
: {
|
|
109
|
+
host && standsBy
|
|
110
|
+
? {
|
|
92
111
|
what: "session host",
|
|
93
|
-
session:
|
|
94
|
-
status: "
|
|
95
|
-
detail:
|
|
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)
|
|
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
|
-
|
|
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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
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(
|
|
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
|
-
|
|
328
|
-
|
|
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
|
|
1331
|
-
`<td
|
|
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
|
-
|
|
1364
|
-
`<
|
|
1365
|
-
`<
|
|
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 (
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
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
|
-
'<
|
|
2327
|
+
'<a class="den-pvisit" tabindex="-1" data-visit="' +
|
|
2325
2328
|
esc(id) +
|
|
2326
2329
|
'" data-url="' +
|
|
2327
2330
|
esc(url) +
|
|
2328
|
-
'"
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
`<
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
|
|
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
|
];
|