toolroll 0.9.85 → 0.9.87
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 +83 -0
- package/dist/agent-fence.d.ts +6 -2
- package/dist/agent-fence.js +3 -2
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/assignment.js +4 -3
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -68
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +363 -177
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-actions.d.ts +11 -1
- package/dist/chat-actions.js +53 -1
- package/dist/chat-channel.js +1 -0
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-quiet.js +3 -2
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/claim.d.ts +3 -0
- package/dist/claim.js +4 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +3 -0
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +4 -1
- package/dist/contracts/console-api.js +4 -1
- package/dist/contracts/flow-propose.d.ts +4 -1
- package/dist/contracts/flow-propose.js +1 -1
- package/dist/contracts/flow.d.ts +41 -0
- package/dist/contracts/flow.js +4 -2
- package/dist/contracts/lead-tools.d.ts +21 -0
- package/dist/contracts/lead-tools.js +2 -0
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/trigger-payloads.d.ts +20 -0
- package/dist/contracts/trigger-payloads.js +4 -0
- package/dist/coordinator.js +1 -0
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/deploy-gate.js +4 -0
- package/dist/design/kit.css +685 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +19 -1
- package/dist/discord-settings.js +8 -4
- package/dist/dispatch.d.ts +3 -0
- package/dist/dispatch.js +4 -1
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-browser.d.ts +103 -0
- package/dist/flow-browser.js +204 -0
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-code.d.ts +5 -1
- package/dist/flow-code.js +4 -1
- package/dist/flow-engine.js +16 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-live.js +9 -1
- package/dist/flow-pull-request.js +15 -7
- package/dist/flow-share.js +3 -1
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-steps.d.ts +9 -1
- package/dist/flow-steps.js +55 -5
- package/dist/flow-triggers.js +55 -0
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.d.ts +19 -0
- package/dist/flows-cli.js +54 -5
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +152 -32
- package/dist/flows.js +5 -2
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/lead-tools.js +35 -6
- package/dist/lead-voice.js +16 -5
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +249 -6
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-delete.js +7 -3
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-flow.js +35 -4
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +21 -0
- package/dist/release.js +40 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/result-follow-ups.js +4 -0
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +8 -0
- package/dist/scope.js +13 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +20 -18
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +31 -2
- package/dist/server/flows.js +79 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +69 -0
- package/dist/server/kit.d.ts +405 -0
- package/dist/server/kit.js +361 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +35 -190
- package/dist/server/render-pages.js +154 -563
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +43 -5
- package/dist/server/render-tasks.js +131 -91
- package/dist/server/route-table.js +31 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +90 -2
- package/dist/server/tasks.d.ts +18 -0
- package/dist/server/tasks.js +148 -41
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/settings-proofs.js +1 -0
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +182 -5
- package/dist/store.js +512 -38
- package/dist/surface.js +23 -3
- package/dist/task-archive.d.ts +10 -0
- package/dist/task-archive.js +16 -0
- package/dist/task-control.d.ts +1 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/tidy.d.ts +130 -0
- package/dist/tidy.js +503 -0
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.d.ts +24 -0
- package/dist/work-index.js +56 -30
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +8 -4
- package/package.json +1 -1
|
@@ -1,148 +1,133 @@
|
|
|
1
1
|
/** Settings pages: the settings home, lead, capabilities and their cards. */
|
|
2
|
-
import { ACCENT_PRESETS, DEFAULT_ACCENT } from "../accent-colors.js";
|
|
3
|
-
import {} from "../brand-icons.js";
|
|
4
|
-
import { brandIconHtml } from "../brand-mark.js";
|
|
5
|
-
import {} from "../browser-workspace.js";
|
|
6
|
-
import { connectionWords } from "../control-ui.js";
|
|
7
2
|
import { isSubscriptionChatProvider, PRICED_MODELS } from "../converse.js";
|
|
8
|
-
import { digestTimes } from "../digest-times.js";
|
|
9
3
|
import { describeCapability } from "../gaps.js";
|
|
10
4
|
import { ABOUT_YOU_LINE_MAX, ABOUT_YOU_MAX_LINES } from "../lead-about.js";
|
|
11
5
|
import { LEAD_NAME_MAX, LEAD_PERSONA_MAX } from "../lead-identity.js";
|
|
12
6
|
import { projectName } from "../project.js";
|
|
13
|
-
import {} from "../provider-connection.js";
|
|
14
|
-
import {} from "../provider.js";
|
|
15
|
-
import { qualityModeTitle } from "../quality.js";
|
|
16
7
|
import { latestVersionNow } from "../releases.js";
|
|
17
|
-
import { RESULT_SHOT_CHOICES } from "../result-shots.js";
|
|
18
8
|
import { isAlive as runnerAlive } from "../runner.js";
|
|
19
|
-
import {} from "../scope.js";
|
|
20
|
-
import { ASSISTANTS } from "../setup-guide.js";
|
|
21
9
|
import {} from "../store.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { chatMoney, screen, strokeIcon, when, whenTime } from "./chrome.js";
|
|
10
|
+
import {} from "../telegram.js";
|
|
11
|
+
import { chatMoney, screen, when } from "./chrome.js";
|
|
25
12
|
import { taskHref } from "./http.js";
|
|
26
|
-
import { html, joinHtml, postForm
|
|
27
|
-
import {
|
|
13
|
+
import { html, joinHtml, postForm } from "../html.js";
|
|
14
|
+
import { acts, btn, chip, codeLine, disclose, facts, formRow, input, note, notice, select, settingsLink, settingsRecord, settingsSection, textarea } from "./kit.js";
|
|
15
|
+
import { kitSectionForm, SECRET_OPT_OUTS } from "../settings-kit.js";
|
|
16
|
+
import { settingsSubPage } from "../settings-groups-ui.js";
|
|
28
17
|
/** Settings → Lead: one line for what runs the lead and one action; the full form, turning it off and stored keys under Advanced. */
|
|
29
18
|
export function leadSettingsHtml(data) {
|
|
30
19
|
const { config, facts } = data;
|
|
31
20
|
const returnTo = "/settings/lead";
|
|
32
|
-
const
|
|
21
|
+
const runs = config === null
|
|
33
22
|
? data.signedIn !== null
|
|
34
|
-
?
|
|
35
|
-
|
|
23
|
+
? settingsSection({ id: "lead-runs", title: "What runs the lead", lead: "The lead is off.",
|
|
24
|
+
body: postForm("/settings/lead/on", acts(btn(`Use your ${data.signedIn} sign-in`, { variant: "primary", type: "submit" })), { returnTo }) })
|
|
25
|
+
: settingsSection({ id: "lead-runs", title: "What runs the lead", lead: "The lead is off. Sign in an agent on this computer to turn it on:",
|
|
26
|
+
body: joinHtml([codeLine(data.command), postForm("/lead/check", acts(btn("Check again", { sm: true, type: "submit" })), { returnTo })]) })
|
|
36
27
|
: data.words !== null
|
|
37
|
-
?
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
28
|
+
? settingsSection({ id: "lead-runs", title: "What runs the lead", lead: `The lead uses ${data.words}.`,
|
|
29
|
+
body: note(`${config.model === "default" ? "Its default model" : `Model ${config.model}`} · up to ${config.dailyTurns} turns a day · no dollar spend. Every action it proposes waits for you to confirm it.`) })
|
|
30
|
+
: settingsSection({ id: "lead-runs", title: "What runs the lead", lead: `The lead uses the ${config.provider === "anthropic-api" ? "Anthropic" : "OpenRouter"} API with ${config.model}.`,
|
|
31
|
+
body: note(`Up to ${chatMoney(config.weeklyCeilingMicrousd)} a week · up to ${config.dailyTurns} turns a day.`) });
|
|
32
|
+
const savedLine = (shown) => shown ? html `<span class="k-ssave-said" role="status">Saved.</span>` : html ``;
|
|
33
|
+
const identity = data.identity === undefined ? "" : settingsSection({ id: "lead-identity", title: "Name and persona",
|
|
34
|
+
body: postForm("/settings/lead/identity", joinHtml([
|
|
35
|
+
formRow("Name your lead", null, input({ id: "lead-name", name: "name", value: data.identity.name, attrs: { maxlength: LEAD_NAME_MAX, autocomplete: "off", required: true, "aria-label": "Name your lead" } })),
|
|
36
|
+
formRow("Persona", "How it talks to you.", textarea({ id: "lead-persona", name: "persona", rows: 4, value: data.identity.persona, attrs: { maxlength: LEAD_PERSONA_MAX, "aria-label": "Persona" } }), "top"),
|
|
37
|
+
html `<div class="k-ssave">${savedLine(data.saved === true)}${btn("Save", { sm: true, type: "submit" })}</div>`,
|
|
38
|
+
]), { attrs: { "data-lead-identity": true } }) });
|
|
39
|
+
// What the lead knows about this person: lines they confirmed in chat or wrote here. Every project, every chat.
|
|
40
|
+
const about = data.about === undefined ? "" : settingsSection({ id: "lead-about", title: "What your lead knows about you",
|
|
41
|
+
body: postForm("/settings/lead/about", joinHtml([
|
|
42
|
+
formRow("About you", `One per line, up to ${ABOUT_YOU_MAX_LINES}. Used in every project and every chat.`, textarea({ id: "lead-about-text", name: "about", rows: Math.min(10, Math.max(4, data.about.length + 1)), value: data.about.join("\n"),
|
|
43
|
+
attrs: { maxlength: ABOUT_YOU_MAX_LINES * (ABOUT_YOU_LINE_MAX + 1), placeholder: "Keep copy terse.\nI test changes myself.", "aria-label": "What your lead knows about you" } }), "top"),
|
|
44
|
+
html `<div class="k-ssave">${savedLine(data.aboutSaved === true)}${btn("Save", { sm: true, type: "submit" })}</div>`,
|
|
45
|
+
]), { attrs: { "data-lead-about": true } }) });
|
|
46
|
+
// What the lead promised to follow up on; it reports each once, here in chat, and drops it after 7 days.
|
|
47
|
+
const promises = (data.promises ?? []).length === 0 ? "" : settingsSection({ id: "lead-promises", title: "Promises", lead: "What the lead said it would follow up on. Each is reported once, then dropped after 7 days.",
|
|
48
|
+
body: joinHtml(data.promises.map(one => settingsRecord({
|
|
49
|
+
title: one.what, sub: `${one.when.charAt(0).toUpperCase() + one.when.slice(1)} · until ${new Date(one.until).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}`,
|
|
50
|
+
action: postForm("/settings/lead/promise/cancel", btn("Cancel", { sm: true, type: "submit" }), { returnTo, hidden: { promise: one.id } }),
|
|
51
|
+
attrs: { "data-promise": one.id },
|
|
52
|
+
}))) });
|
|
53
|
+
// D5: the lead's subagents, one line each; everything else about one is on its own page.
|
|
54
|
+
const subagents = data.subagents === undefined ? "" : settingsSection({ id: "subagents", title: "Subagents",
|
|
55
|
+
right: btn("Add subagent", { sm: true, href: "/settings/lead/subagents?add=1#add", attrs: { "data-add-subagent": true } }),
|
|
56
|
+
body: data.subagents.length === 0 ? note("None yet. Your lead can ask one for help once it's here.")
|
|
57
|
+
: html `<div class="k-slinks">${data.subagents.map(one => settingsLink({ href: `/settings/lead/subagents/${one.id}`, title: one.label, status: `${one.project}${one.paused ? " · Paused" : ""}` }))}</div>` });
|
|
58
|
+
// The lead's own key: where each provider's key comes from (never the key), and Forget for a stored one.
|
|
59
|
+
const keys = settingsSection({ id: "lead-key", title: "The lead's own key",
|
|
60
|
+
lead: "A direct API key the lead uses instead of a membership. A pasted key is kept in a file only this account can read, never in the database, and never shown again. ANTHROPIC_API_KEY or OPENROUTER_API_KEY in the environment always wins.",
|
|
61
|
+
body: joinHtml(facts.keyFacts.map(one => settingsRecord({
|
|
62
|
+
title: one.provider,
|
|
63
|
+
sub: one.state === "none" ? "No key yet" : one.state === "environment" ? "From the environment" : `stored ${one.tail ?? ""}`.trim(),
|
|
64
|
+
action: one.state !== "stored" ? "" : kitSectionForm({ proof: "lead-config", action: "/chat/config", id: `lead-forget-${one.provider}`, hidden: { return: returnTo, "forget-key": one.provider },
|
|
65
|
+
save: { label: "Forget", variant: "danger" }, confirm: `Forget the lead's own ${one.provider} key`, body: html `` }),
|
|
66
|
+
}))) });
|
|
67
|
+
const off = config === null ? "" : settingsSection({ id: "lead-off", title: "Turn the lead off",
|
|
68
|
+
body: kitSectionForm({ proof: "lead-config", action: "/chat/config", id: "lead-off-form", hidden: { return: returnTo, off: "1" },
|
|
69
|
+
save: { label: "Turn the lead off", variant: "danger" }, confirm: "Turn the lead off", body: note("The lead stops answering until you turn it on again.") }) });
|
|
70
|
+
return settingsSubPage("agents", "Lead", joinHtml([
|
|
71
|
+
data.problem ? notice(data.problem.charAt(0).toUpperCase() + data.problem.slice(1)) : data.said === null ? "" : html `<p class="k-notice info" role="status">${data.said}</p>`,
|
|
72
|
+
runs, identity, about, promises, subagents,
|
|
73
|
+
disclose("Advanced", joinHtml([leadConfigForm(config, facts), keys, off])),
|
|
74
|
+
], "\n"));
|
|
65
75
|
}
|
|
66
|
-
/** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and
|
|
76
|
+
/** The lead's full settings (Settings → Lead → Advanced): provider, model, limits and the lead's own key. */
|
|
67
77
|
export function leadConfigForm(current, data) {
|
|
68
78
|
const anthropicModels = PRICED_MODELS.filter(one => !one.includes("/"));
|
|
69
79
|
const openrouterModels = data.openrouterModels ?? PRICED_MODELS.filter(one => one.includes("/"));
|
|
70
80
|
const currentSubscription = current !== null && isSubscriptionChatProvider(current.provider);
|
|
71
81
|
const labels = new Map((data.liveModels ?? []).map(one => [one.value, one.label]));
|
|
72
82
|
const models = [...new Set(["default", ...labels.keys(), ...anthropicModels, ...openrouterModels, ...(current === null ? [] : [current.model])])];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
data.openrouterModels
|
|
86
|
-
? html `<p class="meta">With OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog — each model priced by the party that bills it</p>`
|
|
87
|
-
: html `<p class="meta">${data.openrouterModels.length} models live from OpenRouter's catalog; saving pins today's price — re-save to re-pin</p>`,
|
|
88
|
-
currentSubscription
|
|
89
|
-
? html `<p class="meta"><strong>No dollar maximum.</strong> Membership chat uses the plan attached to the logged-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.</p>`
|
|
90
|
-
: html `<label>Weekly ceiling <span class="meta">(direct API only; leave blank when choosing a membership)</span>\
|
|
91
|
-
<input type="text" name="weekly-usd" inputmode="decimal" style="width:8rem" value="${current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2)}"></label>`,
|
|
92
|
-
html `<label>Daily turns <span class="meta">(default 50)</span>\
|
|
93
|
-
<input type="text" name="daily-turns" inputmode="numeric" style="width:8rem" value="${current === null ? "" : String(current.dailyTurns)}"></label>`,
|
|
94
|
-
currentSubscription
|
|
95
|
-
? html `<p class="meta">Authenticate on this machine first with ${current?.provider === "codex-subscription" ? html `<span class="mono">codex login</span>` : html `the <span class="mono">claude</span> CLI`}. Toolroll reuses that cached login and never stores it.</p>`
|
|
96
|
-
: html `<label>API key <span class="meta">(${data.keyFacts
|
|
97
|
-
.map(one => one.state === "none"
|
|
98
|
-
? `${one.provider}: none yet`
|
|
99
|
-
: one.state === "environment"
|
|
100
|
-
? `${one.provider}: from the environment`
|
|
101
|
-
: `${one.provider}: stored ${one.tail ?? ""}`)
|
|
102
|
-
.join(" · ")})</span>\
|
|
103
|
-
<input type="password" name="key" placeholder="direct API only — leave empty to keep" autocomplete="off"></label>`,
|
|
104
|
-
html `<label>Your password <span class="meta">(typed again to change the provider)</span>\
|
|
105
|
-
${reauth(html `<input type="password" name="token" autocomplete="current-password">`)}</label>`,
|
|
106
|
-
html `<button type="submit">${current === null ? "turn chat on" : "save"}</button>`,
|
|
107
|
-
"",
|
|
108
|
-
], "\n"), { attrs: { class: "card" }, returnTo: data.returnTo }),
|
|
83
|
+
const keyWords = data.keyFacts.map(one => one.state === "none" ? `${one.provider}: none yet` : one.state === "environment" ? `${one.provider}: from the environment` : `${one.provider}: stored ${one.tail ?? ""}`.trim()).join(" · ");
|
|
84
|
+
const body = joinHtml([
|
|
85
|
+
formRow("Provider", null, select({ id: "lead-provider", name: "provider", label: "Provider", ...(current === null ? {} : { current: current.provider }), items: [
|
|
86
|
+
["codex-subscription", "Codex membership (signed-in CLI)"],
|
|
87
|
+
["claude-subscription", "Anthropic membership (signed-in CLI)"],
|
|
88
|
+
["anthropic-api", "Anthropic API (direct)"],
|
|
89
|
+
["openrouter-api", "OpenRouter API (direct)"],
|
|
90
|
+
] })),
|
|
91
|
+
formRow("Model", "Use default for your membership's current model. A direct API model needs a pinned price.", html `${input({ id: "lead-model", name: "model", value: current?.model ?? "default", attrs: { list: "chat-models", "aria-label": "Model" } })}<datalist id="chat-models">\
|
|
92
|
+
${models.map(model => html `<option value="${model}">${labels.get(model) ?? ""}</option>`)}</datalist>`),
|
|
93
|
+
note(data.openrouterModels === null
|
|
94
|
+
? "With OPENROUTER_API_KEY in the serve environment, this list becomes OpenRouter's full live catalog, each model priced by the party that bills it."
|
|
95
|
+
: `${data.openrouterModels.length} models live from OpenRouter's catalog. Saving pins today's price; save again to pin it again.`),
|
|
109
96
|
currentSubscription
|
|
110
|
-
?
|
|
111
|
-
:
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
? note("No dollar maximum. Membership chat uses the plan of the signed-in CLI; the conversation stays live until you end it, and the daily turn limit still applies.")
|
|
98
|
+
: formRow("Weekly ceiling", "Direct API only, in dollars. Leave blank when choosing a membership.", input({ id: "lead-weekly", name: "weekly-usd", value: current === null ? "" : (current.weeklyCeilingMicrousd / 1_000_000).toFixed(2), attrs: { inputmode: "decimal", "aria-label": "Weekly ceiling" } })),
|
|
99
|
+
formRow("Daily turns", "50 when left blank.", input({ id: "lead-turns", name: "daily-turns", value: current === null ? "" : String(current.dailyTurns), attrs: { inputmode: "numeric", "aria-label": "Daily turns" } })),
|
|
100
|
+
currentSubscription
|
|
101
|
+
? note(html `Sign in on this computer first with ${current?.provider === "codex-subscription" ? html `<span class="k-mono">codex login</span>` : html `the <span class="k-mono">claude</span> CLI`}. Toolroll reuses that sign-in and never stores it.`)
|
|
102
|
+
: formRow("The lead's own key", `Direct API only. Leave empty to keep the one you have. ${keyWords}.`, html `<input class="k-input" id="lead-own-key" type="password" name="key" autocomplete="off" spellcheck="false" autocapitalize="none" data-so-secret data-1p-ignore data-lpignore="${SECRET_OPT_OUTS["data-lpignore"]}" data-bwignore data-form-type="other" aria-label="The lead's own key" placeholder="Paste a key">`),
|
|
103
|
+
]);
|
|
104
|
+
return settingsSection({ id: "lead-config", title: "Model and limits",
|
|
105
|
+
lead: currentSubscription
|
|
106
|
+
? "The membership provider runs without repository tools, in a temporary directory. Only Toolroll can turn an action it proposes into a confirmation card."
|
|
107
|
+
: "Changing these always asks for your password, typed.",
|
|
108
|
+
body: kitSectionForm({ proof: "lead-config", action: "/chat/config", id: "lead-config-form", ...(current === null ? {} : { gate: true }), hidden: { return: data.returnTo },
|
|
109
|
+
save: { label: current === null ? "Turn the lead on" : "Save" }, confirm: current === null ? "Turn the lead on" : "Change the lead's model and limits", body }) });
|
|
114
110
|
}
|
|
115
111
|
export function capsPage(chrome, caps, gaps, repo, now) {
|
|
116
112
|
if (caps === null) {
|
|
117
|
-
return screen("requirements",
|
|
118
|
-
html `<h1>Requirements</h1>`,
|
|
119
|
-
html `<p class="meta">Open a project to see its requirements — <a href="/projects">projects</a></p>`,
|
|
120
|
-
], "\n"), { chrome });
|
|
113
|
+
return screen("requirements", settingsSubPage("approval", "Requirements", note(html `Open a project to see its requirements: <a class="k-link" href="/projects">Projects</a>.`)), { chrome });
|
|
121
114
|
}
|
|
122
|
-
const
|
|
123
|
-
?
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return screen("requirements", joinHtml([
|
|
139
|
-
html `<h1>Requirements</h1>`,
|
|
140
|
-
html `<p class="hint">tools and credentials builds need — each is probed on the worker before any build spends money; values never leave your machine</p>`,
|
|
141
|
-
list,
|
|
142
|
-
html `<h2>Missing, ranked by what filling them frees</h2>`,
|
|
143
|
-
blocked,
|
|
144
|
-
html `<p class="meta">Read-only here: checks are shell commands you wrote, and a web button that runs shell would need its own security review</p>`,
|
|
145
|
-
], "\n"), { chrome });
|
|
115
|
+
const recorded = settingsSection({ id: "recorded", title: "Recorded",
|
|
116
|
+
body: caps.length === 0 ? note("Nothing recorded.") : joinHtml(caps.map(capability => settingsRecord({
|
|
117
|
+
title: html `<span class="k-mono">${capability.kind}:${capability.name}</span>`, sub: describeCapability(capability, now ?? new Date()),
|
|
118
|
+
}))) });
|
|
119
|
+
const missing = settingsSection({ id: "missing", title: "Missing", lead: "Ranked by what filling them frees.",
|
|
120
|
+
body: gaps.length === 0 ? note("Nothing missing. Everything recorded is verified.") : joinHtml(gaps.map(gap => settingsRecord({
|
|
121
|
+
title: html `<span class="k-mono">${gap.key}</span>${chip(gap.state)}`,
|
|
122
|
+
sub: gap.unblocks.length > 0
|
|
123
|
+
? `Filling it starts ${gap.unblocks.length} task${gap.unblocks.length === 1 ? "" : "s"}: ${gap.unblocks.join(", ")}`
|
|
124
|
+
: gap.alsoBlocks.length > 0 ? `Part of what holds ${gap.alsoBlocks.join(", ")}` : "Nothing queued needs it yet",
|
|
125
|
+
more: facts([["Check", codeLine(gap.verify)], ["How to fill it", gap.instructions]]),
|
|
126
|
+
}))) });
|
|
127
|
+
return screen("requirements", settingsSubPage("approval", "Requirements", joinHtml([
|
|
128
|
+
recorded, missing,
|
|
129
|
+
note("Read-only here: the checks are shell commands you wrote, and a web button that runs them would need its own security review."),
|
|
130
|
+
], "\n"), { lead: "Tools and credentials builds need. Each is checked on the builder before any build spends money; values never leave your computer." }), { chrome });
|
|
146
131
|
}
|
|
147
132
|
/** Whether the bot's saved delivery state reports a problem (no new check runs). */
|
|
148
133
|
export function telegramTrouble(store, bot) {
|
|
@@ -164,270 +149,6 @@ export function notificationProjects(store, account) {
|
|
|
164
149
|
.map(one => ({ repo: one.path, name: one.name, muted: muted.has(one.path) }))
|
|
165
150
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
166
151
|
}
|
|
167
|
-
export function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null, firstResult = null, chatNotices = null, telegramFailing = false, phone = null) {
|
|
168
|
-
const permissionCard = permissionDefault === null
|
|
169
|
-
? ""
|
|
170
|
-
: joinHtml([
|
|
171
|
-
html `<h2>Unattended permissions</h2>`,
|
|
172
|
-
permissionDefault.canManage && csrf !== ""
|
|
173
|
-
? postForm("/settings/permission-default", html `${permissionModeChoices("permission-mode", permissionDefault.mode)}${permissionDefault.mode === "bypassPermissions" ? "" : reauth(html `<label>Your password <span class="meta">(needed for Full access)</span><input type="password" name="password" autocomplete="current-password"></label>`)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy" } })
|
|
174
|
-
: html `<div class="card"><p><strong>${permissionDefault.mode === "bypassPermissions" ? "Full access" : "Auto"}</strong></p><p class="meta">An approver can change this default</p></div>`,
|
|
175
|
-
permissionDefault.updatedAt === null
|
|
176
|
-
? ""
|
|
177
|
-
: html `<p class="meta settings-changed">Changed ${whenTime(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${permissionDefault.updatedBy}`}</p>`,
|
|
178
|
-
], "\n");
|
|
179
|
-
const qualityCard = qualityDefault === null
|
|
180
|
-
? ""
|
|
181
|
-
: joinHtml([
|
|
182
|
-
html `<h2>Quality mode</h2>`,
|
|
183
|
-
qualityDefault.canManage && csrf !== ""
|
|
184
|
-
? postForm("/settings/quality-default", html `${qualityModeChoices("quality-mode", qualityDefault.mode)}<button type="submit">Save default</button>`, { attrs: { class: "card permission-policy", "data-autosave": true } })
|
|
185
|
-
: html `<div class="card"><p><strong>${qualityModeTitle(qualityDefault.mode)}</strong></p><p class="meta">An approver can change this default</p></div>`,
|
|
186
|
-
qualityDefault.updatedAt === null
|
|
187
|
-
? ""
|
|
188
|
-
: html `<p class="meta settings-changed">Changed ${whenTime(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${qualityDefault.updatedBy}`}</p>`,
|
|
189
|
-
], "\n");
|
|
190
|
-
// Quiet chat: this person's own choice. The installation's Telegram cadence only bundles Every step updates.
|
|
191
|
-
const chatCard = chatNotices === null || csrf === ""
|
|
192
|
-
? ""
|
|
193
|
-
: joinHtml([
|
|
194
|
-
html `<h3>Chat messages</h3>`,
|
|
195
|
-
postForm("/settings/notifications", joinHtml([
|
|
196
|
-
"",
|
|
197
|
-
html `<label><input type="radio" name="mode" value="quiet"${chatNotices.mode === "quiet" ? " checked" : ""}> Only when I'm needed <span class="meta">· one message per task, updated as it moves</span></label>`,
|
|
198
|
-
html `<label><input type="radio" name="mode" value="all"${chatNotices.mode === "all" ? " checked" : ""}> Every step <span class="meta">· a new message for each update</span></label>`,
|
|
199
|
-
html `<label>Evening digest<select name="digest">\
|
|
200
|
-
${digestTimes(chatNotices.digestAt).map(([value, label]) => html `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`)}\
|
|
201
|
-
</select></label>`,
|
|
202
|
-
html `<p class="meta">One message: what finished, what waits, what failed.</p>`,
|
|
203
|
-
html `<label>Screenshots with results<select name="screenshots">\
|
|
204
|
-
${RESULT_SHOT_CHOICES.map(([value, label]) => html `<option value="${value}"${value === (chatNotices.screenshots ?? "off") ? " selected" : ""}>${label}</option>`)}\
|
|
205
|
-
</select></label>`,
|
|
206
|
-
html `<button type="submit">Save</button>`,
|
|
207
|
-
"",
|
|
208
|
-
], "\n"), { attrs: { class: "card", "data-autosave": true } }),
|
|
209
|
-
...((chatNotices.projects ?? []).length === 0 ? [] : [
|
|
210
|
-
html `<h3>Projects</h3>`,
|
|
211
|
-
html `<ul class="card">${chatNotices.projects.map(one => html `<li>${postForm("/settings/notifications/mute", html `${one.name} <span class="meta">· ${one.muted ? "muted" : "pings on"}</span> <button type="submit">${one.muted ? "Unmute" : "Mute"}</button>`, { hidden: { repo: one.repo, pings: one.muted ? null : "on" } })}</li>`)}</ul>`,
|
|
212
|
-
html `<p class="meta">Muted projects still show in Tasks and the evening digest.</p>`,
|
|
213
|
-
]),
|
|
214
|
-
], "\n");
|
|
215
|
-
const digestCard = digest === null || csrf === "" || chatNotices?.mode === "quiet"
|
|
216
|
-
? ""
|
|
217
|
-
: joinHtml([
|
|
218
|
-
html `<h3>Telegram digest</h3>`,
|
|
219
|
-
html `<p class="meta">Bundle routine updates. Anything that needs you still arrives at once.</p>`,
|
|
220
|
-
postForm("/settings/telegram-digest", joinHtml([
|
|
221
|
-
"",
|
|
222
|
-
html `<label>Send a digest<select name="every">\
|
|
223
|
-
${[
|
|
224
|
-
["off", "Off: send each update"],
|
|
225
|
-
["30", "every 30 minutes"],
|
|
226
|
-
["60", "every hour"],
|
|
227
|
-
["240", "every 4 hours"],
|
|
228
|
-
["720", "every 12 hours"],
|
|
229
|
-
["1440", "once a day"],
|
|
230
|
-
]
|
|
231
|
-
.map(([value, label]) => {
|
|
232
|
-
const selected = value === "off" ? digest.everyMs === null : digest.everyMs === Number(value) * 60_000;
|
|
233
|
-
return html `<option value="${value}"${selected ? " selected" : ""}>${label}</option>`;
|
|
234
|
-
})}\
|
|
235
|
-
</select></label>`,
|
|
236
|
-
html `<p class="meta">${digest.everyMs === null
|
|
237
|
-
? ""
|
|
238
|
-
: `${digest.held} routine fact(s) held · next digest ${digest.lastSentAt === null ? "at the next bridge pass" : `no earlier than ${new Date(new Date(digest.lastSentAt).getTime() + digest.everyMs).toISOString()}`}`}</p>`,
|
|
239
|
-
html `<button type="submit">Save digest</button>`,
|
|
240
|
-
"",
|
|
241
|
-
], "\n"), { attrs: { class: "card", "data-autosave": true } }),
|
|
242
|
-
], "\n");
|
|
243
|
-
const keysCard = providerKeys === null || csrf === ""
|
|
244
|
-
? ""
|
|
245
|
-
: joinHtml([
|
|
246
|
-
html `<h2 id="providers">AI providers</h2><p><a href="/control">Set up this project</a></p>`,
|
|
247
|
-
html `<p class="meta">Keys stay on this computer and are never shown again.</p>`,
|
|
248
|
-
html `<details class="settings-more"><summary>How keys are used</summary><p class="meta">A key is used only when that provider’s sign-in is set to API key. With a subscription sign-in the key is kept out of the agent, so your membership never turns into API billing. Keys are private files beside the database, never stored in it.</p></details>`,
|
|
249
|
-
...providerKeys.map(one => {
|
|
250
|
-
// One status row per provider; the controls wait behind Manage.
|
|
251
|
-
const name = ASSISTANTS[one.provider]?.name ?? one.provider;
|
|
252
|
-
const status = one.connection !== undefined && one.connection.state === "connected"
|
|
253
|
-
? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
|
|
254
|
-
: one.mode === "subscription"
|
|
255
|
-
? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
|
|
256
|
-
: one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
|
|
257
|
-
: one.set ? { tone: "ok", words: "API key saved" } : one.ambient ? { tone: "ok", words: "Key from this computer’s environment" } : { tone: "off", words: "Not set up" };
|
|
258
|
-
return joinHtml([
|
|
259
|
-
html `<div class="provider-row" data-provider="${one.provider}">`,
|
|
260
|
-
html `<p class="provider-head"><strong>${name}</strong> <span class="provider-status provider-status--${status.tone}"><i aria-hidden="true"></i>${status.words}</span></p>`,
|
|
261
|
-
html `<details class="provider-manage"><summary>Manage</summary>`,
|
|
262
|
-
postForm("/settings/provider-key", joinHtml([
|
|
263
|
-
"",
|
|
264
|
-
one.connection === undefined ? "" : html `<p class="provider-connection"><strong>${connectionWords(one.connection)}</strong> ${[one.connection.email, one.connection.plan, one.connection.method].filter(Boolean).join(" · ")} · <button type="submit" class="secondary" formaction="/settings/connection/check" formnovalidate>Check again</button></p>`,
|
|
265
|
-
html `<p class="meta">${one.mode === "subscription" ? "Uses its own sign-in, so no API-key spend" : "Uses the API key"} · <span class="mono">${one.envName}</span> · ${one.set ? `key stored${one.updatedAt === null ? "" : ` ${one.updatedAt.slice(0, 10)}`}` : one.ambient ? "key in this server's environment" : "no key stored"}</p>`,
|
|
266
|
-
one.subscriptionCapable
|
|
267
|
-
? html `<label>Sign-in<select name="auth-mode">\
|
|
268
|
-
<option value="subscription"${one.mode === "subscription" ? " selected" : ""}>${name} subscription</option>\
|
|
269
|
-
<option value="api-key"${one.mode === "api-key" ? " selected" : ""}>API key</option>\
|
|
270
|
-
</select></label>`
|
|
271
|
-
: "",
|
|
272
|
-
html `<label>API key<input type="password" name="value" autocomplete="off" placeholder="${one.set ? "Paste to replace the stored key" : "Paste a key"}"></label>`,
|
|
273
|
-
html `<button type="submit">Save ${name}</button>`,
|
|
274
|
-
one.set
|
|
275
|
-
? html ` <details class="confirm-remove"><summary>Remove the stored key</summary><p class="meta">Runs that use this API key stop until you add one again.</p><button type="submit" formaction="/settings/provider-key-clear" class="danger">Remove key</button></details>`
|
|
276
|
-
: "",
|
|
277
|
-
"",
|
|
278
|
-
], "\n"), { attrs: { class: "card" }, hidden: { provider: one.provider } }),
|
|
279
|
-
html `</details></div>`,
|
|
280
|
-
], "\n");
|
|
281
|
-
}),
|
|
282
|
-
], "\n");
|
|
283
|
-
const pushCard = push === null || csrf === ""
|
|
284
|
-
? ""
|
|
285
|
-
: joinHtml([
|
|
286
|
-
html `<h3>This device</h3>`,
|
|
287
|
-
push.available
|
|
288
|
-
? joinHtml([
|
|
289
|
-
html `<p class="meta">A notification when something needs you. On iPhone, add this app to your Home Screen first.</p>`,
|
|
290
|
-
postForm("/push/subscribe", joinHtml([
|
|
291
|
-
"",
|
|
292
|
-
html `<input type="hidden" name="endpoint" value=""><input type="hidden" name="p256dh" value=""><input type="hidden" name="auth" value="">`,
|
|
293
|
-
html `${reauth(html `<label>Your password <input type="password" name="token" autocomplete="current-password"></label>`)}`,
|
|
294
|
-
html `<button type="submit" id="push-enable">Get alerts on this device</button>`,
|
|
295
|
-
html `<p class="meta" id="push-state"></p>`,
|
|
296
|
-
"",
|
|
297
|
-
], "\n"), { attrs: { id: "push-form", class: "card" } }),
|
|
298
|
-
], "\n")
|
|
299
|
-
: html `<p class="meta">Alerts need a secure (https) address for this app.</p>`,
|
|
300
|
-
...push.devices
|
|
301
|
-
.filter(one => one.retiredAt === null || one.retiredReason === "gone")
|
|
302
|
-
.map(one => html `<p class="row">${one.uaWords} · since ${whenTime(one.createdAt)}\
|
|
303
|
-
${one.retiredAt !== null ? html ` · <span class="meta">expired</span>` : one.consecutiveFailures >= 20 ? html ` · <span class="meta">failing</span>` : ""}\
|
|
304
|
-
${one.retiredAt === null
|
|
305
|
-
? html ` ${postForm("/push/remove", html `<button type="submit">Remove</button>`, { attrs: { class: "inline" }, hidden: { id: one.id } })}`
|
|
306
|
-
: ""}\
|
|
307
|
-
</p>`),
|
|
308
|
-
], "\n");
|
|
309
|
-
// The enrollment behavior rides the ONE composed script (arc 4, finding
|
|
310
|
-
// 18) — it fills the subscription fields the form posts; it never reads
|
|
311
|
-
// the password field beside them (the named functional exception).
|
|
312
|
-
const pushScript = push === null || !push.available || csrf === ""
|
|
313
|
-
? null
|
|
314
|
-
: `(function(){` +
|
|
315
|
-
`if(!("serviceWorker" in navigator)||!("PushManager" in window))return;` +
|
|
316
|
-
`var link=document.createElement("link");link.rel="manifest";link.href="/manifest.webmanifest";document.head.appendChild(link);` +
|
|
317
|
-
`navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){});` +
|
|
318
|
-
`var form=document.getElementById("push-form");if(!form)return;` +
|
|
319
|
-
`form.addEventListener("submit",function(event){` +
|
|
320
|
-
`if(form.dataset.ready==="1")return;` +
|
|
321
|
-
`event.preventDefault();var state=document.getElementById("push-state");` +
|
|
322
|
-
`Notification.requestPermission().then(function(granted){` +
|
|
323
|
-
`if(granted!=="granted"){if(state)state.textContent="notifications are blocked for this site in the browser settings";return;}` +
|
|
324
|
-
`return fetch("/push/key").then(function(r){return r.json();}).then(function(d){` +
|
|
325
|
-
`return navigator.serviceWorker.ready.then(function(reg){` +
|
|
326
|
-
`return reg.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:Uint8Array.from(atob(d.key.replace(/-/g,"+").replace(/_/g,"/")),function(c){return c.charCodeAt(0);})});});` +
|
|
327
|
-
`}).then(function(sub){var raw=sub.toJSON();` +
|
|
328
|
-
`form.querySelector("[name=endpoint]").value=sub.endpoint;` +
|
|
329
|
-
`form.querySelector("[name=p256dh]").value=(raw.keys.p256dh||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
|
|
330
|
-
`form.querySelector("[name=auth]").value=(raw.keys.auth||"").replace(/\\+/g,"-").replace(/\\//g,"_").replace(/=+$/,"");` +
|
|
331
|
-
`form.dataset.ready="1";form.submit();});` +
|
|
332
|
-
`}).catch(function(){if(state)state.textContent="could not subscribe — the browser said no";});});` +
|
|
333
|
-
`})();`;
|
|
334
|
-
const messagingCard = messaging === null || messaging.configured.length === 0
|
|
335
|
-
? ""
|
|
336
|
-
: messaging.configured.length === 1 && !messaging.implicit
|
|
337
|
-
? html `<h3>Alert service</h3><p class="provider-head"><strong>${messaging.configured[0]}</strong> <span class="provider-status provider-status--ok"><i aria-hidden="true"></i>Receiving alerts</span></p>`
|
|
338
|
-
: joinHtml([
|
|
339
|
-
html `<h3>Alert service</h3>`,
|
|
340
|
-
html `<p class="meta">Alerts go through one service, so you are never notified twice${messaging.implicit ? html ` · <strong>Several are connected and none was chosen. Pick one.</strong>` : ""}</p>`,
|
|
341
|
-
postForm("/settings/messaging", joinHtml([
|
|
342
|
-
"",
|
|
343
|
-
...messaging.configured.map(channel => html `<label style="display:flex;gap:.5rem;align-items:center"><input type="radio" name="primary" value="${channel}"${channel === messaging.channel ? " checked" : ""}> ${channel}${channel === messaging.channel ? html ` <span class="meta">— receiving alerts now${messaging.implicit ? " (by default, not by choice)" : ""}</span>` : ""}${channel === "telegram" ? html ` <span class="meta">· can carry answer buttons and reply-notes</span>` : html ` <span class="meta">· messages with console links; acting stays here</span>`}</label>`),
|
|
344
|
-
html `<button type="submit">Use this service</button>`,
|
|
345
|
-
"",
|
|
346
|
-
], "\n"), { attrs: { class: "card" } }),
|
|
347
|
-
html `<p class="meta">Telegram still accepts taps and replies when another service sends alerts.</p>`,
|
|
348
|
-
], "\n");
|
|
349
|
-
const current = hasEnv
|
|
350
|
-
? `set in the environment (${TOKEN_ENV}) — that takes precedence over anything saved here`
|
|
351
|
-
: existing === null
|
|
352
|
-
? "not set"
|
|
353
|
-
: `saved: ${redactToken(existing.token)} (bot ${existing.botId})`;
|
|
354
|
-
const theme = requestContext.getStore()?.theme ?? "system";
|
|
355
|
-
const view = {
|
|
356
|
-
kind: "settings",
|
|
357
|
-
said: problem,
|
|
358
|
-
groups: settingsGroups(messaging, telegramFailing),
|
|
359
|
-
...(messaging?.legacyWarning ? { legacyWebhookWarning: messaging.legacyWarning } : {}),
|
|
360
|
-
theme,
|
|
361
|
-
accent: requestContext.getStore()?.accent ?? DEFAULT_ACCENT,
|
|
362
|
-
accentPresets: ACCENT_PRESETS,
|
|
363
|
-
permission: permissionDefault === null ? null : { mode: permissionDefault.mode, canManage: permissionDefault.canManage && csrf !== "", changed: permissionDefault.updatedAt === null ? null : `Changed ${when(permissionDefault.updatedAt)}${permissionDefault.updatedBy === null ? "" : ` by ${permissionDefault.updatedBy}`}` },
|
|
364
|
-
quality: qualityDefault === null ? null : { mode: qualityDefault.mode, canManage: qualityDefault.canManage && csrf !== "", changed: qualityDefault.updatedAt === null ? null : `Changed ${when(qualityDefault.updatedAt)}${qualityDefault.updatedBy === null ? "" : ` by ${qualityDefault.updatedBy}`}` },
|
|
365
|
-
providers: providerKeys === null || csrf === "" ? null : providerKeys.map(one => {
|
|
366
|
-
const name = ASSISTANTS[one.provider]?.name ?? one.provider;
|
|
367
|
-
const status = one.connection !== undefined && one.connection.state === "connected"
|
|
368
|
-
? { tone: "ok", words: [connectionWords(one.connection), one.connection.plan].filter(Boolean).join(" · ") }
|
|
369
|
-
: one.mode === "subscription"
|
|
370
|
-
? { tone: one.connection === undefined ? "neutral" : "warn", words: one.connection === undefined ? "Uses its own sign-in" : connectionWords(one.connection) }
|
|
371
|
-
: one.connection?.state === "key-works" ? { tone: "ok", words: "API key works" } : one.connection?.state === "key-refused" ? { tone: "warn", words: "API key refused" }
|
|
372
|
-
: one.set ? { tone: "ok", words: "API key saved" } : one.ambient ? { tone: "ok", words: "Key from this computer’s environment" } : { tone: "off", words: "Not set up" };
|
|
373
|
-
return {
|
|
374
|
-
provider: one.provider, name, tone: status.tone, words: status.words,
|
|
375
|
-
connection: one.connection === undefined ? null : { words: connectionWords(one.connection), facts: [one.connection.email, one.connection.plan, one.connection.method].filter(Boolean).join(" · "), checkAction: "/settings/connection/check" },
|
|
376
|
-
usage: `${one.mode === "subscription" ? "Uses its own sign-in, so no API-key spend" : "Uses the API key"} · ${one.set ? `key stored${one.updatedAt === null ? "" : ` ${one.updatedAt.slice(0, 10)}`}` : one.ambient ? "key in this server's environment" : "no key stored"}`,
|
|
377
|
-
envName: one.envName, subscriptionCapable: one.subscriptionCapable, mode: one.mode, set: one.set,
|
|
378
|
-
};
|
|
379
|
-
}),
|
|
380
|
-
services: messaging === null || messaging.configured.length === 0 ? null : { configured: messaging.configured, channel: messaging.channel, implicit: messaging.implicit },
|
|
381
|
-
push: push === null || csrf === "" ? null : { available: push.available, devices: push.devices.filter(one => one.retiredAt === null || one.retiredReason === "gone").map(one => ({ id: one.id, words: `${one.uaWords} · since ${when(one.createdAt)}`, state: one.retiredAt !== null ? "expired" : one.consecutiveFailures >= 20 ? "failing" : "ok", removable: one.retiredAt === null })) },
|
|
382
|
-
chat: chatNotices === null || csrf === "" ? null : chatNotices,
|
|
383
|
-
digest: digest === null || csrf === "" || chatNotices?.mode === "quiet" ? null : { every: digest.everyMs === null ? "off" : String(Math.round(digest.everyMs / 60_000)), held: digest.everyMs === null ? null : `${digest.held} routine fact(s) held` },
|
|
384
|
-
telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
|
|
385
|
-
email,
|
|
386
|
-
workers,
|
|
387
|
-
updates,
|
|
388
|
-
firstResult,
|
|
389
|
-
...(phone === null ? {} : { phone }),
|
|
390
|
-
};
|
|
391
|
-
return screen("Settings", joinHtml([
|
|
392
|
-
html `<h1>Settings</h1>`,
|
|
393
|
-
messaging?.legacyWarning ? html `<p data-legacy-webhooks>Legacy webhooks are deprecated. Connect <a href="/settings/slack" style="display:inline-flex;min-height:44px;align-items:center">Slack</a> or <a href="/settings/discord" style="display:inline-flex;min-height:44px;align-items:center">Discord</a> in Chat settings.</p>` : "",
|
|
394
|
-
settingsTiles(view.groups),
|
|
395
|
-
phone === null ? "" : phoneSetupHtml(phone),
|
|
396
|
-
appearanceCard(csrf),
|
|
397
|
-
permissionCard,
|
|
398
|
-
qualityCard,
|
|
399
|
-
keysCard,
|
|
400
|
-
workersCard(workers),
|
|
401
|
-
updatesCard(updates),
|
|
402
|
-
firstResult === null ? "" : html `<p class="meta" data-first-result>${firstResult}</p>`,
|
|
403
|
-
pushCard === "" && messagingCard === "" && digestCard === "" && chatCard === "" ? "" : html `<h2>Notifications</h2>`,
|
|
404
|
-
chatCard,
|
|
405
|
-
messagingCard,
|
|
406
|
-
pushCard,
|
|
407
|
-
digestCard,
|
|
408
|
-
problem === null ? "" : html `<p class="problem" role="alert">${problem}</p>`,
|
|
409
|
-
html `<details class="settings-more" id="telegram-token"><summary>Telegram bot token <span class="meta">${hasEnv ? "from the environment" : existing === null ? "not set" : "saved"}</span></summary>`,
|
|
410
|
-
html `<p class="meta">Current: ${current}</p>`,
|
|
411
|
-
telegramDelivery === null ? "" : html `<p class="meta" data-telegram-delivery>${telegramDelivery}</p>`,
|
|
412
|
-
postForm("/settings/telegram-token", joinHtml([
|
|
413
|
-
"",
|
|
414
|
-
html `<label>Token from @BotFather<input type="password" name="token" autocomplete="off"></label>`,
|
|
415
|
-
html `<button type="submit">Save token</button>`,
|
|
416
|
-
"",
|
|
417
|
-
], "\n")),
|
|
418
|
-
html `<p class="meta">Stored privately on this computer. Then pair your phone under <a href="/settings/telegram">Telegram</a>. In Telegram, send <code>/status</code>, <code>/task <id></code> or <code>/help</code>; these use no AI model.</p>`,
|
|
419
|
-
html `</details>`,
|
|
420
|
-
], "\n"), { chrome, workspace: { view }, functional: { script: SETTINGS_AUTOSAVE_SCRIPT + (pushScript ?? ""), ...(pushScript === null ? {} : { fetches: true }) } });
|
|
421
|
-
}
|
|
422
|
-
/** The phone setup for the server-rendered Settings page: the same choices as the React card. */
|
|
423
|
-
export function phoneSetupHtml(phone) {
|
|
424
|
-
const [first, ...others] = phone.chatApps;
|
|
425
|
-
return html `<section class="card" id="phone" aria-labelledby="phone-card-title" data-phone-card><h2 id="phone-card-title">Use it from your phone</h2>\
|
|
426
|
-
<p>${first === undefined ? "" : html `<a href="${first.href}">Pair ${first.label}</a>`}${others.length === 0 ? "" : html ` · or ${joinHtml(others.map(one => html `<a href="${one.href}">${one.label}</a>`), ", ")}`}</p>\
|
|
427
|
-
<p class="meta" data-phone-tailnet>${phone.tailnet === null ? html `Or install <a href="https://tailscale.com/download">Tailscale</a> on this computer and your phone, then reload this page for the address.`
|
|
428
|
-
: phone.tailnet.restart === null ? html `Or on your phone open <code>${phone.tailnet.address}</code> and sign in.`
|
|
429
|
-
: html `Or start Toolroll with <code>${phone.tailnet.restart}</code>, then on your phone open <code>${phone.tailnet.address}</code> and sign in.`}</p></section>`;
|
|
430
|
-
}
|
|
431
152
|
/** Every worker that is not retired: its capacity, and the tasks it holds now. */
|
|
432
153
|
export function settingsWorkers(store, now) {
|
|
433
154
|
const claims = store.liveClaims(null, now);
|
|
@@ -449,39 +170,6 @@ export function settingsWorkers(store, now) {
|
|
|
449
170
|
};
|
|
450
171
|
});
|
|
451
172
|
}
|
|
452
|
-
/** The page's workers section, for browsers without the app script. */
|
|
453
|
-
export function workersCard(workers) {
|
|
454
|
-
if (workers === null)
|
|
455
|
-
return html ``;
|
|
456
|
-
return html `<section id="workers" aria-labelledby="workers-title"><h2 id="workers-title">Workers</h2>\
|
|
457
|
-
${workers.length === 0
|
|
458
|
-
? html `<p class="meta">No worker is connected. Run <code>toolroll up</code> on the computer with your projects.</p>`
|
|
459
|
-
: html `${workers.map(one => html `<div class="card" data-worker="${one.name}"><p class="row"><strong>${one.name}</strong> <span class="meta">${one.state}</span>\
|
|
460
|
-
<span class="right">${one.busy} of ${one.capacity} running</span></p>\
|
|
461
|
-
${one.running.length === 0
|
|
462
|
-
? html `<p class="meta">Nothing running.</p>`
|
|
463
|
-
: html `<ul>${one.running.map(task => html `<li><a href="${task.href}">${task.title}</a>${task.project === null ? "" : html ` <span class="meta">${task.project}</span>`}</li>`)}</ul>`}\
|
|
464
|
-
</div>`)}\
|
|
465
|
-
<p class="meta">To change how many a worker runs at once: <code>toolroll runner capacity <name> <n></code></p>`}\
|
|
466
|
-
</section>`;
|
|
467
|
-
}
|
|
468
|
-
/** Settings → Updates, for browsers without the app script. */
|
|
469
|
-
export function updatesCard(updates) {
|
|
470
|
-
if (updates === null)
|
|
471
|
-
return html ``;
|
|
472
|
-
const latest = updates.latest;
|
|
473
|
-
return html `<section id="updates" aria-labelledby="updates-title"><h2 id="updates-title">Updates</h2>\
|
|
474
|
-
<p class="row">This version <span class="mono">${updates.current}</span> · ${!updates.check.on ? "Update checks are off" : latest === null ? "Not checked yet" : latest.newer ? html `Latest <span class="mono">${latest.version}</span>` : "Up to date"}</p>\
|
|
475
|
-
${latest !== null && latest.newer
|
|
476
|
-
? html `<p>Update with <code>${updates.updateCommand}</code> · <a href="${latest.url}">Release notes</a></p>\
|
|
477
|
-
${latest.notes === "" ? "" : html `<details class="settings-more"><summary>What's new in ${latest.version}</summary><pre class="update-notes">${latest.notes}</pre></details>`}`
|
|
478
|
-
: ""}\
|
|
479
|
-
${updates.check.canManage && !updates.check.byEnv
|
|
480
|
-
? postForm("/settings/updates/checks", html `<button type="submit">${updates.check.on ? "Turn off daily check" : "Turn on daily check"}</button>`, { attrs: { class: "inline" }, hidden: { check: updates.check.on ? "off" : "on" } })
|
|
481
|
-
: updates.check.byEnv ? html `<p class="meta">Off by TOOLROLL_NO_UPDATE_CHECK.</p>` : ""}\
|
|
482
|
-
${updates.workers.length === 0 ? "" : html `<ul>${updates.workers.map(one => html `<li data-worker-version="${one.name}">${one.name} <span class="mono">${one.version ?? "unknown"}</span>${one.older ? " · older" : ""}</li>`)}</ul>`}\
|
|
483
|
-
</section>`;
|
|
484
|
-
}
|
|
485
173
|
/** Choices save the moment they change; without the script the Save
|
|
486
174
|
* button stays and the form works the same. */
|
|
487
175
|
export const SETTINGS_AUTOSAVE_SCRIPT = `(function(){document.querySelectorAll('form[data-autosave]').forEach(function(form){form.classList.add('js-autosave');form.addEventListener('change',function(ev){var t=ev.target;if(t&&(t.type==='radio'||t.tagName==='SELECT')){if(form.requestSubmit)form.requestSubmit();else form.submit();}});});})();`;
|
|
@@ -501,82 +189,3 @@ export async function latestReleaseFor(latest) {
|
|
|
501
189
|
latestSeen = { at: Date.now(), value };
|
|
502
190
|
return value;
|
|
503
191
|
}
|
|
504
|
-
/** Settings destinations under short headings: an icon and a name each, and a chat app's logo and state. */
|
|
505
|
-
export function settingsTiles(groups) {
|
|
506
|
-
return html `<nav class="settings-tiles" aria-label="Settings sections">${groups.map(group => html `<section aria-label="${group.title}"><h2>${group.title}</h2><div>${group.tiles.map(tile => html `<a href="${tile.href}">${tile.brand === undefined ? strokeIcon(SETTINGS_TILE_ICONS.get(tile.href) ?? html ``) : brandIconHtml(tile.brand)}<span>${tile.label}${tile.status === undefined ? "" :
|
|
507
|
-
html `<span class="provider-status provider-status--${tile.status.tone}"><i aria-hidden="true"></i>${tile.status.words}</span>`}</span></a>`)}</div></section>`)}</nav>`;
|
|
508
|
-
}
|
|
509
|
-
/** Each destination once, in order: a group's heading, then its [href, label, icon] tiles. */
|
|
510
|
-
export const SETTINGS_GROUPS = [
|
|
511
|
-
["Agents", [
|
|
512
|
-
["/settings/lead", "Lead", html `<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 9h8M8 13h5"/>`],
|
|
513
|
-
["/settings/models", "Models", html `<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>`],
|
|
514
|
-
["/settings/spend", "Spend", html `<circle cx="12" cy="12" r="9"/><path d="M15 9.5c-.6-.9-1.7-1.5-3-1.5-1.7 0-3 .9-3 2s1.3 1.7 3 2 3 .9 3 2-1.3 2-3 2c-1.3 0-2.4-.6-3-1.5M12 6v2M12 16v2"/>`],
|
|
515
|
-
["/settings/skills", "Skills", html `<path d="m12 3 1.9 5.8L20 10l-5 3.6L16.8 20 12 16.4 7.2 20 9 13.6 4 10l6.1-1.2z"/>`],
|
|
516
|
-
["/settings/tools", "Tools", html `<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>`],
|
|
517
|
-
["/settings/knowledge", "Knowledge", html `<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>`],
|
|
518
|
-
["/settings/learning", "Learning", html `<path d="M3 3v18h18"/><path d="m7 15 4-4 3 3 5-6"/>`],
|
|
519
|
-
]],
|
|
520
|
-
["Automation", [
|
|
521
|
-
["/settings/flows", "Flows", html `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><path d="M10 6.5h4a3 3 0 0 1 3 3V14"/>`],
|
|
522
|
-
["/settings/integrations", "Integrations", html `<path d="M9 2v6M15 2v6"/><path d="M6 8h12v4a6 6 0 0 1-12 0z"/><path d="M12 18v4"/>`],
|
|
523
|
-
]],
|
|
524
|
-
["Chat apps", [
|
|
525
|
-
["/settings/telegram", "Telegram", html ``],
|
|
526
|
-
["/settings/slack", "Slack", html ``],
|
|
527
|
-
["/settings/discord", "Discord", html ``],
|
|
528
|
-
["/settings/teams", "Teams", html ``],
|
|
529
|
-
]],
|
|
530
|
-
["Access and rules", [
|
|
531
|
-
["/settings/sign-in", "Sign-in", html `<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>`],
|
|
532
|
-
["/settings/passwordless", "Passwordless", html `<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>`],
|
|
533
|
-
["/settings/sessions", "Sessions & tokens", html `<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/>`],
|
|
534
|
-
["/settings/project", "Project", html `<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>`],
|
|
535
|
-
["/settings/policy", "Policy", html `<path d="M9 12l2 2 4-4"/><rect x="4" y="3" width="16" height="18" rx="2"/>`],
|
|
536
|
-
["/settings/approval", "Approval rules", html `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
|
|
537
|
-
]],
|
|
538
|
-
["System", [
|
|
539
|
-
["/settings/monitoring", "Monitoring", html `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
|
|
540
|
-
["/settings/retention", "Retention", html `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
|
|
541
|
-
["/settings/storage", "Storage", html `<path d="M22 12H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><path d="M6 16h.01M10 16h.01"/>`],
|
|
542
|
-
["/settings/updates", "Updates", html `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
|
|
543
|
-
["/settings/backups", "Backups", html `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
|
|
544
|
-
["/settings/data", "Data", html `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
|
|
545
|
-
]],
|
|
546
|
-
];
|
|
547
|
-
export const SETTINGS_TILE_ICONS = new Map(SETTINGS_GROUPS.flatMap(([, tiles]) => tiles.map(([href, , icon]) => [href, icon])));
|
|
548
|
-
export const CHAT_APP_TILES = { "/settings/telegram": "telegram", "/settings/slack": "slack", "/settings/discord": "discord", "/settings/teams": "teams" };
|
|
549
|
-
/**
|
|
550
|
-
* The settings groups, with each chat app's state from what the server already holds: not set up,
|
|
551
|
-
* connected, or getting alerts (only when that service was chosen, or is the only one).
|
|
552
|
-
* A Telegram bot whose saved delivery state reports a problem says so instead. With no config folder
|
|
553
|
-
* the state is unknown, so the tiles carry none.
|
|
554
|
-
*/
|
|
555
|
-
export function settingsGroups(messaging, telegramFailing = false) {
|
|
556
|
-
return SETTINGS_GROUPS.map(([title, tiles]) => ({
|
|
557
|
-
title,
|
|
558
|
-
tiles: tiles.map(([href, label]) => {
|
|
559
|
-
const app = CHAT_APP_TILES[href];
|
|
560
|
-
if (app === undefined)
|
|
561
|
-
return { href, label };
|
|
562
|
-
if (messaging === null)
|
|
563
|
-
return { href, label, brand: app };
|
|
564
|
-
if (!messaging.configured.includes(app))
|
|
565
|
-
return { href, label, brand: app, status: { tone: "off", words: "Not set up" } };
|
|
566
|
-
if (app === "telegram" && telegramFailing)
|
|
567
|
-
return { href, label, brand: app, status: { tone: "warn", words: "Has a problem" } };
|
|
568
|
-
return { href, label, brand: app, status: { tone: "ok", words: messaging.channel === app && !messaging.implicit ? "Gets alerts" : "Connected" } };
|
|
569
|
-
}),
|
|
570
|
-
}));
|
|
571
|
-
}
|
|
572
|
-
/** Light, dark or the device's choice, one tap each. Per browser (a cookie). */
|
|
573
|
-
export function appearanceCard(csrf) {
|
|
574
|
-
if (csrf === "")
|
|
575
|
-
return html ``;
|
|
576
|
-
const pinned = requestContext.getStore()?.theme ?? null;
|
|
577
|
-
const current = pinned ?? "system";
|
|
578
|
-
const option = (value, label) => html `<button type="submit" name="theme" value="${value}" class="theme-choice" aria-pressed="${String(current === value)}">${label}</button>`;
|
|
579
|
-
return html `<section class="appearance" aria-labelledby="appearance-title"><h2 id="appearance-title">Appearance</h2>\
|
|
580
|
-
${postForm("/settings/appearance", html `${option("system", "Match device")}${option("light", "Light")}${option("dark", "Dark")}`, { attrs: { class: "theme-switch" } })}\
|
|
581
|
-
<p class="meta">Saved in this browser.</p></section>`;
|
|
582
|
-
}
|