@songsid/agend 2.3.1 → 2.3.2-beta.2
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/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +16 -10
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.d.ts +7 -1
- package/dist/authority/session-authorizations.js +6 -4
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +19 -6
- package/dist/authority/web-authority.js +22 -8
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +2 -0
- package/dist/channel/adapters/discord.js +33 -3
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -5
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/types.d.ts +11 -0
- package/dist/config-validator.js +40 -3
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +34 -3
- package/dist/daemon.js +227 -46
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +12 -1
- package/dist/delivery-outbox.js +67 -15
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +9 -0
- package/dist/doctor.js.map +1 -1
- package/dist/event-log.d.ts +5 -0
- package/dist/event-log.js +13 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-manager.d.ts +140 -0
- package/dist/fleet-manager.js +414 -29
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +1 -0
- package/dist/fleet-process-handlers.js +9 -1
- package/dist/fleet-process-handlers.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +4 -4
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +7 -7
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +113 -55
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +114 -56
- package/dist/guest-seen-people.d.ts +38 -0
- package/dist/guest-seen-people.js +78 -0
- package/dist/guest-seen-people.js.map +1 -0
- package/dist/instance-config-impact.js +4 -1
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +1 -0
- package/dist/instance-lifecycle.js +16 -1
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +38 -10
- package/dist/locale.js.map +1 -1
- package/dist/mac-auto-restore.d.ts +117 -0
- package/dist/mac-auto-restore.js +294 -0
- package/dist/mac-auto-restore.js.map +1 -0
- package/dist/outbound-schemas.d.ts +2 -0
- package/dist/outbound-schemas.js +2 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +9 -1
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/settings-api.d.ts +26 -0
- package/dist/settings-api.js +107 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +37 -3
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +5 -2
- package/dist/settings-effect.js.map +1 -1
- package/dist/start-attempt.d.ts +9 -5
- package/dist/start-attempt.js +27 -8
- package/dist/start-attempt.js.map +1 -1
- package/dist/start-failure-chat.d.ts +4 -0
- package/dist/start-failure-chat.js +23 -6
- package/dist/start-failure-chat.js.map +1 -1
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +8 -0
- package/dist/start-failure-notice.js.map +1 -1
- package/dist/start-failure.d.ts +25 -0
- package/dist/start-failure.js +75 -2
- package/dist/start-failure.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +25 -0
- package/dist/turn-reply-guard.js +111 -12
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +18 -1
- package/dist/ui/backend-signin.js +5 -2
- package/dist/ui/chat-ambient.js +1 -1
- package/dist/ui/chat-effects.js +4 -4
- package/dist/ui/chat-render.js +5 -2
- package/dist/ui/chat-strings.js +2 -2
- package/dist/ui/chat-thread.js +2 -2
- package/dist/ui/first-run.js +2 -2
- package/dist/ui/fleet-canvas-view.js +114 -0
- package/dist/ui/fleet-canvas.js +15 -77
- package/dist/ui/fleet-office-editor.js +80 -5
- package/dist/ui/fleet-office-layout.js +197 -0
- package/dist/ui/fleet-office-model.js +6 -3
- package/dist/ui/fleet-office-overview.js +167 -0
- package/dist/ui/fleet-office-pack.d.ts +39 -0
- package/dist/ui/fleet-office-pack.js +240 -29
- package/dist/ui/fleet-office-skin.js +140 -29
- package/dist/ui/fleet-office-store.js +84 -5
- package/dist/ui/fleet-office-view.js +154 -0
- package/dist/ui/fleet-office-zones.js +95 -33
- package/dist/ui/fleet-office.js +779 -140
- package/dist/ui/fleet-usage.js +7 -6
- package/dist/ui/panel-chat.js +1 -1
- package/dist/ui/panel-details.js +2 -2
- package/dist/ui/panel-fleet.js +11 -77
- package/dist/ui/panel-log.js +2 -2
- package/dist/ui/panel-settings.js +578 -163
- package/dist/ui/search-dialog.js +26 -7
- package/dist/ui/settings-appearance.js +82 -29
- package/dist/ui/settings-apply.js +42 -7
- package/dist/ui/settings-dialogs.js +68 -22
- package/dist/ui/settings-draft.js +51 -0
- package/dist/ui/settings-guests.js +244 -78
- package/dist/ui/settings-learn.js +2 -2
- package/dist/ui/settings-model.js +2 -0
- package/dist/ui/settings-registry.js +142 -0
- package/dist/ui/settings-strings.js +102 -44
- package/dist/ui/settings-web-authority.js +34 -23
- package/dist/ui/shared/app-i18n.js +14 -0
- package/dist/ui/shared/app-nav.js +10 -5
- package/dist/ui/shared/app-prefs.js +80 -2
- package/dist/ui/shared/app-route.js +39 -5
- package/dist/ui/shared/app-shell.js +59 -4
- package/dist/ui/shared/app-stream.js +2 -1
- package/dist/ui/shared/app.css +254 -17
- package/dist/ui/shared/app.js +3 -0
- package/dist/ui/shared/effect-stock-trust.js +1 -1
- package/dist/ui/shared/identity-color.js +4 -4
- package/dist/ui/shared/signin.js +2 -2
- package/dist/ui/shared/ui-dialog.js +6 -1
- package/dist/ui/shared/ui-icons.js +2 -0
- package/dist/ui/shared/web-authorize.js +13 -12
- package/dist/web-api.d.ts +8 -2
- package/dist/web-api.js +160 -5
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.js +7 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-experimental.d.ts +1 -1
- package/dist/web-experimental.js +1 -1
- package/dist/web-experimental.js.map +1 -1
- package/dist/web-mod-agent.js +2 -2
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-office-skins.d.ts +108 -0
- package/dist/web-office-skins.js +298 -0
- package/dist/web-office-skins.js.map +1 -0
- package/dist/web-office-store.d.ts +8 -2
- package/dist/web-office-store.js +37 -7
- package/dist/web-office-store.js.map +1 -1
- package/dist/web-settings-patch.d.ts +6 -0
- package/dist/web-settings-patch.js +61 -4
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +4 -2
- package/dist/web-shell-routes.js +17 -7
- package/dist/web-shell-routes.js.map +1 -1
- package/dist/working-recovery.d.ts +104 -0
- package/dist/working-recovery.js +263 -0
- package/dist/working-recovery.js.map +1 -0
- package/dist/working-state-journal.d.ts +3 -1
- package/dist/working-state-journal.js +15 -2
- package/dist/working-state-journal.js.map +1 -1
- package/package.json +1 -1
|
@@ -14,7 +14,9 @@ import { appStore, useStore } from "/assets/app-store.js";
|
|
|
14
14
|
import { useLease } from "/assets/app-ctx.js";
|
|
15
15
|
import { DevicePrefs, PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
|
|
16
16
|
import { navigate, navStore, setLeaveGuard } from "/assets/app-nav.js";
|
|
17
|
-
import {
|
|
17
|
+
import { discardHeldPrefs, heldPrefsCount, holdPrefs, onHeldPrefs, savePrefsHeld } from "/assets/app-prefs.js";
|
|
18
|
+
import { chatPath, LEGACY_SETTINGS, sectionPath, settingsPath, SETTINGS_GROUPS, SETTINGS_SECTIONS } from "/assets/app-route.js";
|
|
19
|
+
import { goToSetting, goToTarget, searchSettings, settingLabel, takePendingSetting } from "./settings-registry.js";
|
|
18
20
|
import { Dialog } from "/assets/ui-dialog.js";
|
|
19
21
|
import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
|
|
20
22
|
import { Icon } from "/assets/ui-icons.js";
|
|
@@ -25,14 +27,14 @@ import "./settings-strings.js";
|
|
|
25
27
|
import { api, attach, confirmedWrite, newKey } from "./settings-confirm.js";
|
|
26
28
|
import { dismissOperation, operationActive, restartFleet, startOperation, takeLeftover } from "./settings-apply.js";
|
|
27
29
|
import {
|
|
28
|
-
ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
|
|
30
|
+
ACCESS_MODES, AGENT_MODES, DASHBOARD_FEATURES, DEFAULT_SCHEMA, ownChoiceImpact, HANG_TIMEOUT_DEFAULT_MINUTES, LOG_LEVELS, TOOL_PROGRESS, TOOL_SETS, VISIBILITY_MODES, batchImpact,
|
|
29
31
|
changedFields, channelId, channelsOf, chLabel, connectionState, dashboardDefault, effectiveSummary, fleetModel, fromYaml, fullModelRequests, groupAgents,
|
|
30
|
-
hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, shortName, STATUS_ORDER, toYaml,
|
|
32
|
+
hasOwn, impactOf, localeOptions, defaultsImpact, requestImpact, nonNegative, positive, replyGuardSupported, sameValue, shortName, STATUS_ORDER, toYaml,
|
|
31
33
|
toolSetDefault, visibilityDefault, worstImpact,
|
|
32
34
|
} from "./settings-model.js";
|
|
33
35
|
import {
|
|
34
|
-
AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, Drawer, Feedback, Impact,
|
|
35
|
-
Select, ask, impactText,
|
|
36
|
+
AgentDialog, BackendSelect, BotDialog, ChipList, ClassicDialog, DefaultMark, Drawer, Feedback, Impact, InEffect, Inherited, Label, ModelSelect,
|
|
37
|
+
NewBotDialog, Override, Select, ask, impactText, inAgentDefaults, valueText,
|
|
36
38
|
} from "./settings-dialogs.js";
|
|
37
39
|
import { SetupWizard } from "./settings-wizard.js";
|
|
38
40
|
import { CreateInstanceDialog } from "./panel-fleet.js";
|
|
@@ -44,6 +46,7 @@ import { startTour } from "./chat-tour.js";
|
|
|
44
46
|
import "./chat-strings.js";
|
|
45
47
|
import { AppearanceSection } from "./settings-appearance.js";
|
|
46
48
|
import { GuestsCard } from "./settings-guests.js";
|
|
49
|
+
import { syncStage, useDraft } from "./settings-draft.js";
|
|
47
50
|
import { WebAuthorityCard } from "./settings-web-authority.js";
|
|
48
51
|
import { SignInList } from "./backend-signin.js";
|
|
49
52
|
|
|
@@ -52,7 +55,7 @@ const tn = (k, ...v) => t(`settings.${k}`, ...v);
|
|
|
52
55
|
const APPLY_ERRORS = new Set(["unsupported_sensitive_effect", "confirmation_diff_too_large", "session_required", "admin_required", "settings_changed",
|
|
53
56
|
"request_no_longer_current", "too_many_pending_changes", "fleet_unavailable", "invalid_settings_effect", "settings_confirmation_unavailable", "failed"]);
|
|
54
57
|
export const applyErrorText = (code) => (APPLY_ERRORS.has(code) ? tn(`applyError_${code}`) : tn("applyErrorOther", String(code)));
|
|
55
|
-
const ICONS = {
|
|
58
|
+
const ICONS = { preferences: "user", look: "palette", agents: "bot", "agent-defaults": "sliders", chat: "chat", "web-dashboard": "globe", access: "key", advanced: "code" };
|
|
56
59
|
const RELEASES = "https://github.com/songsid/AgEnD/releases";
|
|
57
60
|
|
|
58
61
|
/** GET under the panel's lease → { ok, status, body }. */
|
|
@@ -107,7 +110,9 @@ async function loadLive(lease) {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
export function SettingsPanel({ route, navKey }) {
|
|
110
|
-
|
|
113
|
+
// #2347: an old section name (a route made before the regrouping) is its new section.
|
|
114
|
+
const moved = Object.prototype.hasOwnProperty.call(LEGACY_SETTINGS, route.section) ? LEGACY_SETTINGS[route.section].split("#")[0] : null;
|
|
115
|
+
const section = SETTINGS_SECTIONS.includes(route.section) ? route.section : moved || "agents";
|
|
111
116
|
const mount = useLease("settings"); // the panel's life on screen: its data, its guard
|
|
112
117
|
const [data, setData] = useState(null);
|
|
113
118
|
const [staged, setStaged] = useState(() => new Map());
|
|
@@ -115,6 +120,33 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
115
120
|
stagedRef.current = staged;
|
|
116
121
|
const [dialog, setDialog] = useState(null); // { kind, ...props, key: navKey }
|
|
117
122
|
const [search, setSearch] = useState("");
|
|
123
|
+
// #2347: each section's draft while it is edited (settings-draft.js), and the epoch that remounts the sections when
|
|
124
|
+
// the drafts go: a Save that went through whole, or Discard. A Save that failed keeps them (what did not land is staged
|
|
125
|
+
// again, and the form still shows it).
|
|
126
|
+
const drafts = useRef(new Map());
|
|
127
|
+
const [draftEpoch, setDraftEpoch] = useState(0);
|
|
128
|
+
// A staged change's onResult hears how its Save settled: "applied", "waiting" (for a fleet admin's OK) or "failed".
|
|
129
|
+
const results = useRef(new Map());
|
|
130
|
+
// The drafts as they were when a Save began. #2367 review: the form stays editable while it runs, so a Save that went
|
|
131
|
+
// through drops only the drafts it saved — one edited since (in any section) stays, and stays staged.
|
|
132
|
+
const submitted = useRef(null);
|
|
133
|
+
// The staged changes as they were when that Save began (what comes back after a failure is them, whole: a section's
|
|
134
|
+
// draft and onResult included), and how a Save went for a change whose section was shown again since (#2367 review:
|
|
135
|
+
// a failed Save reloads, so the section that staged it is a new one by the time it could be told).
|
|
136
|
+
const submittedStaged = useRef(new Map());
|
|
137
|
+
const outcomes = useRef(new Map());
|
|
138
|
+
// #2367 r3: how the last Save went for each change it carried ("applied" / "failed"), until its configuration is read
|
|
139
|
+
// back: a change staged against both ends of a Save (`afterSave: { applied, failed }`) becomes the one that came true.
|
|
140
|
+
const settledAs = useRef(new Map());
|
|
141
|
+
const resolveAfterSave = (key, change) => {
|
|
142
|
+
if (!change || !change.afterSave) return change;
|
|
143
|
+
const v = change.afterSave[settledAs.current.get(key) === "applied" ? "applied" : "failed"];
|
|
144
|
+
return v ? { ...v, stageKey: key } : null;
|
|
145
|
+
};
|
|
146
|
+
const dropDrafts = (saved = null) => {
|
|
147
|
+
drafts.current = saved ? new Map([...drafts.current].filter(([k, v]) => !saved.has(k) || !sameValue(v, saved.get(k)))) : new Map();
|
|
148
|
+
setDraftEpoch((n) => n + 1);
|
|
149
|
+
};
|
|
118
150
|
const { settingsOp: op, pendingChanges } = useStore(appStore);
|
|
119
151
|
const gen = useRef(0);
|
|
120
152
|
|
|
@@ -133,6 +165,12 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
133
165
|
setData((d) => (d && !d.error ? { ...d, ...live } : d));
|
|
134
166
|
};
|
|
135
167
|
|
|
168
|
+
// #2347: the "You" group — a preference chosen here (Preferences, Look) is shown at once and saved by the bar's
|
|
169
|
+
// Save with everything else: held in app-prefs while Settings is open, and staged here as one change.
|
|
170
|
+
const syncHeld = (n) => (n ? stage(YOU_PREFS, { label: tn("youPrefs"), impact: ownChoiceImpact(data && data.schema), count: n,
|
|
171
|
+
local: { commit: savePrefsHeld, revert: discardHeldPrefs } }, true) : unstage(YOU_PREFS));
|
|
172
|
+
useEffect(() => { const release = holdPrefs(); syncHeld(heldPrefsCount()); const off = onHeldPrefs(syncHeld); return () => { off(); release(); }; }, []);
|
|
173
|
+
|
|
136
174
|
// Mount: the data; requests still waiting for an admin from before a reload; the leave guard; the browser's prompt.
|
|
137
175
|
useEffect(() => {
|
|
138
176
|
reload();
|
|
@@ -148,6 +186,7 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
148
186
|
asking = false;
|
|
149
187
|
if (!yes || !mount.current()) return;
|
|
150
188
|
dropSecrets(stagedRef.current);
|
|
189
|
+
revertLocal(stagedRef.current);
|
|
151
190
|
stagedRef.current = new Map();
|
|
152
191
|
navigate(path, { force: true });
|
|
153
192
|
});
|
|
@@ -155,6 +194,14 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
155
194
|
return false;
|
|
156
195
|
}));
|
|
157
196
|
mount.on(window, "beforeunload", (e) => { if (stagedRef.current.size) { e.preventDefault(); e.returnValue = ""; } });
|
|
197
|
+
// #2380 review: a reload or a closed tab confirmed past that question keeps nothing unsaved — this person's and this
|
|
198
|
+
// device's own choices are put back before the page goes (a device switch is in this browser's storage at once).
|
|
199
|
+
mount.on(window, "pagehide", () => {
|
|
200
|
+
if (![...stagedRef.current.values()].some((c) => c.local)) return;
|
|
201
|
+
revertLocal(stagedRef.current);
|
|
202
|
+
stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
|
|
203
|
+
setStaged((s) => new Map([...s].filter(([, c]) => !c.local)));
|
|
204
|
+
});
|
|
158
205
|
}, [mount]);
|
|
159
206
|
|
|
160
207
|
// Changes a failed Apply did not land come back to be staged again (secrets excepted: they are entered again).
|
|
@@ -162,20 +209,64 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
162
209
|
if (!op || !op.leftover || !op.leftover.length) return;
|
|
163
210
|
const back = takeLeftover();
|
|
164
211
|
if (!back.length) return;
|
|
165
|
-
setStaged((s) => {
|
|
212
|
+
setStaged((s) => {
|
|
213
|
+
const m = new Map(s);
|
|
214
|
+
// #2367 review: a change staged for the same thing since that Save began is the newer word (its draft and
|
|
215
|
+
// request carry everything still unsaved): it stays, and the failed one is not put back over it.
|
|
216
|
+
for (const c of back) {
|
|
217
|
+
const k = c.stageKey || newKey("left");
|
|
218
|
+
if (!m.has(k)) m.set(k, { ...(submittedStaged.current.get(k) || {}), ...c, stageKey: k });
|
|
219
|
+
}
|
|
220
|
+
return m;
|
|
221
|
+
});
|
|
166
222
|
}, [op && op.leftover]);
|
|
167
223
|
|
|
168
224
|
// An Apply that finished, or a confirmed request that applied, changed the configuration: read it again.
|
|
169
225
|
const lastPhase = useRef(op ? op.phase : null);
|
|
170
226
|
useEffect(() => {
|
|
171
227
|
const phase = op ? op.phase : null;
|
|
172
|
-
if (lastPhase.current !== phase && (phase === "done" || phase === "failed"))
|
|
228
|
+
if (lastPhase.current !== phase && (phase === "done" || phase === "failed")) {
|
|
229
|
+
settle(op);
|
|
230
|
+
settledAs.current = new Map(op.steps.filter((s) => s.stageKey).map((s) => [s.stageKey, s.status === "done" ? "applied" : "failed"]));
|
|
231
|
+
setStaged((s) => {
|
|
232
|
+
if (![...s.values()].some((c) => c.afterSave)) return s;
|
|
233
|
+
const m = new Map();
|
|
234
|
+
for (const [k, c] of s) { const r = resolveAfterSave(k, c); if (r) m.set(k, r); }
|
|
235
|
+
return m;
|
|
236
|
+
});
|
|
237
|
+
if (phase !== "done") settledAs.current = new Map();
|
|
238
|
+
// After a Save that went through, its drafts go once the configuration it wrote is read back — not before: a
|
|
239
|
+
// section shown on the old configuration would take an edit back to the old value for no edit at all.
|
|
240
|
+
const saved = phase === "done" ? submitted.current : null;
|
|
241
|
+
if (!saved) submitted.current = null;
|
|
242
|
+
reload().finally(() => { if (saved && submitted.current === saved) { dropDrafts(saved); submitted.current = null; settledAs.current = new Map(); } });
|
|
243
|
+
}
|
|
173
244
|
lastPhase.current = phase;
|
|
174
245
|
}, [op && op.phase]);
|
|
246
|
+
// A saved change that waits for a fleet admin's OK is told so while it waits (its section says it is not saved yet).
|
|
247
|
+
const waitingNow = op ? op.steps.filter((s) => s.status === "waiting" && s.stageKey).map((s) => s.stageKey).join(",") : "";
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
for (const k of waitingNow ? waitingNow.split(",") : []) {
|
|
250
|
+
const fn = results.current.get(k);
|
|
251
|
+
try { if (fn) fn({ status: "waiting", error: null }); } catch { /* a section's own handler: never the Save's */ }
|
|
252
|
+
}
|
|
253
|
+
}, [waitingNow]);
|
|
175
254
|
const applied = (pendingChanges || []).filter((p) => p.state === "applied").map((p) => p.id).join(",");
|
|
176
255
|
useEffect(() => { if (applied) reload(); }, [applied]);
|
|
177
256
|
|
|
178
257
|
const stage = (key, change, quiet = false) => {
|
|
258
|
+
// A change staged against both ends of a Save that has settled since is the end that came true (none: nothing left).
|
|
259
|
+
if (change && change.afterSave && settledAs.current.size) {
|
|
260
|
+
change = resolveAfterSave(key, change);
|
|
261
|
+
if (!change) { unstage(key); return false; }
|
|
262
|
+
}
|
|
263
|
+
// #2347: the editor's edit and a form's change to the same settings never wait together (the controls of each are
|
|
264
|
+
// off while the other is unsaved; this is the last word for a way in that is not).
|
|
265
|
+
const now = stagedRef.current;
|
|
266
|
+
if (isYamlKey(key) ? formsStaged(now) : writesModel(change) && yamlStaged(now)) {
|
|
267
|
+
toast(tn(isYamlKey(key) ? "devFormsUnsaved" : "yamlUnsaved"), false);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
179
270
|
setStaged((s) => {
|
|
180
271
|
const m = new Map(s);
|
|
181
272
|
const prev = m.get(key);
|
|
@@ -187,12 +278,32 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
187
278
|
return m;
|
|
188
279
|
});
|
|
189
280
|
if (!quiet) toast(tn("queued", change.label));
|
|
281
|
+
// A section shown again after its Save failed (or still waits) hears how it went as it stages its change again.
|
|
282
|
+
if (typeof change.onResult === "function" && outcomes.current.has(key)) {
|
|
283
|
+
const outcome = outcomes.current.get(key);
|
|
284
|
+
outcomes.current.delete(key);
|
|
285
|
+
try { change.onResult(outcome); } catch { /* a section's own handler: never the Save's */ }
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
190
288
|
};
|
|
191
289
|
const unstage = (key) => setStaged((s) => { if (!s.has(key)) return s; const m = new Map(s); m.delete(key); return m; });
|
|
192
|
-
const discard = () => { dropSecrets(staged); setStaged(new Map()); reload(); };
|
|
290
|
+
const discard = () => { dropSecrets(staged); revertLocal(staged); setStaged(new Map()); outcomes.current = new Map(); dropDrafts(); reload(); };
|
|
291
|
+
/** Tell each saved change's section how its Save settled (#2347: the onResult in the change it staged). */
|
|
292
|
+
const settle = (o) => {
|
|
293
|
+
for (const step of (o && o.steps) || []) {
|
|
294
|
+
const fn = step.stageKey && results.current.get(step.stageKey);
|
|
295
|
+
if (!fn) continue;
|
|
296
|
+
const status = step.status === "done" ? "applied" : step.status === "waiting" ? "waiting" : "failed";
|
|
297
|
+
if (status !== "applied") outcomes.current.set(step.stageKey, { status, error: step.error || null });
|
|
298
|
+
try { fn({ status, error: step.error || null }); } catch { /* a section's own handler: never the Save's */ }
|
|
299
|
+
}
|
|
300
|
+
results.current = new Map();
|
|
301
|
+
};
|
|
302
|
+
|
|
193
303
|
const applying = useRef(false);
|
|
194
304
|
const apply = async () => {
|
|
195
305
|
if (applying.current || operationActive() || !staged.size) return;
|
|
306
|
+
if ([...stagedRef.current.values()].some((c) => c.invalid)) return; // #2347: Save waits for every field to be valid
|
|
196
307
|
// Every confirmation before the first write: a cancelled one must not leave earlier changes applied and later
|
|
197
308
|
// ones pending (a surprising partial apply).
|
|
198
309
|
applying.current = true;
|
|
@@ -207,19 +318,42 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
207
318
|
}
|
|
208
319
|
} finally { applying.current = false; }
|
|
209
320
|
if (!mount.current() || operationActive()) return;
|
|
321
|
+
// #2347: this person's and this device's own choices are saved here, first (no apply job: nothing in fleet.yaml).
|
|
322
|
+
const locals = [...stagedRef.current.values()].filter((c) => c.local);
|
|
323
|
+
if (locals.length) {
|
|
324
|
+
let failed = false;
|
|
325
|
+
for (const c of locals) { try { await c.local.commit(); } catch { failed = true; } }
|
|
326
|
+
if (!mount.current()) return;
|
|
327
|
+
setStaged((s) => { const m = new Map(s); for (const c of locals) m.delete(c.stageKey); return m; });
|
|
328
|
+
stagedRef.current = new Map([...stagedRef.current].filter(([, c]) => !c.local));
|
|
329
|
+
// #2380 review: a refusal is said whatever else this Save carries (the host's values are back on the page).
|
|
330
|
+
if (failed) toast(tn("youSaveFailed"), false);
|
|
331
|
+
if (!stagedRef.current.size) { if (!failed) toast(tn("youSaved")); setDraftEpoch((n) => n + 1); return; }
|
|
332
|
+
}
|
|
210
333
|
const list = [...stagedRef.current.values()].map(({ label, impact, request, connectionSecret, stageKey, key }) => ({ label, impact, request, connectionSecret, stageKey, key }));
|
|
211
|
-
|
|
334
|
+
const handlers = new Map([...stagedRef.current.values()].filter((c) => typeof c.onResult === "function").map((c) => [c.stageKey, c.onResult]));
|
|
335
|
+
if (startOperation(list)) {
|
|
336
|
+
results.current = handlers; submitted.current = new Map(drafts.current); submittedStaged.current = new Map(stagedRef.current);
|
|
337
|
+
outcomes.current = new Map(); setStaged(new Map());
|
|
338
|
+
}
|
|
212
339
|
};
|
|
213
340
|
|
|
214
|
-
|
|
341
|
+
// #2347: sections read `staged` (each change as staged, its extra fields kept: Guests carries its whole draft) and
|
|
342
|
+
// `drafts` to come back to their edits; `draftEpoch` moves on Discard and after a Save to start them from what is saved.
|
|
343
|
+
const ctx = data && !data.error ? makeCtx(data, setData, stage, unstage, reload, reloadLive, () => mount.current(),
|
|
344
|
+
{ drafts: drafts.current, draftEpoch, staged, savingDrafts: () => submitted.current,
|
|
345
|
+
savingStaged: () => (submitted.current || operationActive() ? submittedStaged.current : null) }) : null;
|
|
215
346
|
// #1519 P7: "Connect a chat app" on the first-run card elsewhere came here for the wizard: it opens once the data is in.
|
|
216
|
-
|
|
347
|
+
// #2347: it writes a connection and an agent at once — not while a fleet.yaml edit waits to be saved.
|
|
348
|
+
const openWizard = () => { if (yamlStaged(stagedRef.current)) toast(tn("yamlUnsaved"), false); else setDialog({ kind: "wizard", key: navKey }); };
|
|
349
|
+
useEffect(() => { if (ctx && takeWizardRequest()) openWizard(); }, [!!ctx, navKey]);
|
|
217
350
|
// #1523 N2: "Edit in Settings" on an instance's Details: that agent's dialog, once the data is in — only an agent this
|
|
218
351
|
// configuration has (own key), with Settings' own staged Apply and confirmations.
|
|
219
352
|
useEffect(() => {
|
|
220
353
|
if (!ctx) return;
|
|
221
354
|
const want = takeAgentRequest();
|
|
222
355
|
if (!want) return;
|
|
356
|
+
if (yamlStaged(stagedRef.current)) { toast(tn("yamlUnsaved"), false); return; } // #2347: the editor's edit waits
|
|
223
357
|
const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
|
|
224
358
|
if (want.kind === "classic") {
|
|
225
359
|
// A ClassicBot room: its own dialog (ClassicDialog), found by its instance among this config's rooms.
|
|
@@ -232,37 +366,112 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
232
366
|
if (own(insts, want.name)) setDialog({ kind: "agent", name: want.name, inst: insts[want.name], key: navKey });
|
|
233
367
|
}, [!!ctx, navKey]);
|
|
234
368
|
// #1604: This device needs no configuration: shown at once, whatever the configuration read does.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
369
|
+
// #2347: drawn again from what is in force after a Discard or a Save (draftEpoch), like every section.
|
|
370
|
+
const toDialog = (d) => setDialog({ ...d, key: navKey });
|
|
371
|
+
const you = { key: `you:${draftEpoch}`, stage, unstage, staged, schema: data && data.schema };
|
|
372
|
+
const formKey = data && !data.error ? `${data.loadedAt}:${draftEpoch}` : "";
|
|
373
|
+
// #2347 regrouping: each card where the person looks for it (the IA in #2347 §3); a form split across sections (the
|
|
374
|
+
// defaults, Web) is one form with one draft, shown a part at a time.
|
|
375
|
+
const sectionBody = section === "preferences" ? html`<${DeviceSection} key=${you.key} /><${AppearanceSection} ...${you} part="device" />`
|
|
376
|
+
: section === "look" ? html`<${AppearanceSection} ...${you} part="look" />`
|
|
238
377
|
: !data ? html`<${Skeleton} lines=${6} />`
|
|
239
378
|
: data.error ? html`<${ErrorState} message=${tn("configLoadFailed")} onRetry=${reload} />`
|
|
240
|
-
: section === "agents" ? html`<${Agents} ctx=${ctx} search=${search} openDialog=${
|
|
241
|
-
: section === "
|
|
242
|
-
: section === "
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
379
|
+
: section === "agents" ? html`<${YamlLock} staged=${staged}><${Agents} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`
|
|
380
|
+
: section === "agent-defaults" ? html`<${YamlLock} staged=${staged}><${General} key=${formKey} ctx=${ctx} part="fleet" /></${YamlLock}>`
|
|
381
|
+
: section === "chat" ? html`<${YamlLock} staged=${staged}><${Bots} ctx=${ctx} search=${search} openDialog=${toDialog} /></${YamlLock}>
|
|
382
|
+
<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="chat" />
|
|
383
|
+
<div id="classicbot" class="s-anchor"><h2 class="s-ap-group">ClassicBot</h2><${Classic} ctx=${ctx} search=${search} openDialog=${toDialog} />
|
|
384
|
+
<${General} key=${formKey} ctx=${ctx} part="classic" /></div>`
|
|
385
|
+
: section === "web-dashboard" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="dashboard" /><${AppearanceSection} ...${you} part="dashboard" />`
|
|
386
|
+
: section === "access" ? html`<${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="access" />`
|
|
387
|
+
: html`<${Developer} key=${`dev:${draftEpoch}`} ctx=${ctx} /><${WebSection} key=${formKey} ctx=${ctx} staged=${staged} part="readonly" />`;
|
|
388
|
+
// #2347 §6: the setting a search result opened — brought into view once its section is drawn (its data may still be
|
|
389
|
+
// on its way), focused, and flashed for 2 s.
|
|
390
|
+
const wanted = useRef(null);
|
|
391
|
+
// Each navigation's own: one made since (without a setting to find) cancels it (#2383 review).
|
|
392
|
+
useEffect(() => { wanted.current = takePendingSetting(); }, [navKey]);
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (!wanted.current) return undefined;
|
|
395
|
+
let tries = 0, timer = null, live = true;
|
|
396
|
+
const find = () => {
|
|
397
|
+
if (!live || !wanted.current) return;
|
|
398
|
+
const el = document.querySelector(wanted.current);
|
|
399
|
+
if (!el) {
|
|
400
|
+
// A section that draws from the configuration waits for it however long its read takes (#2383 review: a slow
|
|
401
|
+
// link); once it is drawn, the setting gets ~2 s to appear (a card loading its own data), then is given up.
|
|
402
|
+
if (section !== "preferences" && section !== "look" && !data) return; // run again when the data is in
|
|
403
|
+
if (++tries < 40) timer = setTimeout(find, 50); else wanted.current = null;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
wanted.current = null;
|
|
407
|
+
// #2347 §10: a setting in a closed drawer (Advanced) is shown — every drawer around it opens first.
|
|
408
|
+
for (let d = el.closest && el.closest("details"); d; d = d.parentElement && d.parentElement.closest("details")) d.open = true;
|
|
409
|
+
if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", inline: "nearest" });
|
|
410
|
+
const field = el.matches && el.matches("input, select, textarea, button") ? el : el.querySelector && el.querySelector("input, select, textarea, button");
|
|
411
|
+
if (field && typeof field.focus === "function") field.focus({ preventScroll: true });
|
|
412
|
+
const row = (el.closest && el.closest(".field, label.check, .card")) || el;
|
|
413
|
+
row.classList.add("s-flash");
|
|
414
|
+
setTimeout(() => row.classList.remove("s-flash"), 2000);
|
|
415
|
+
};
|
|
416
|
+
find();
|
|
417
|
+
return () => { live = false; clearTimeout(timer); };
|
|
418
|
+
}, [section, navKey, !!data, draftEpoch]);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const onKey = (e) => {
|
|
421
|
+
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return;
|
|
422
|
+
const tgt = e.target;
|
|
423
|
+
if (tgt && (tgt.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(tgt.tagName || ""))) return;
|
|
424
|
+
const box = document.getElementById("settingsSearch");
|
|
425
|
+
if (box) { e.preventDefault(); box.focus(); }
|
|
426
|
+
};
|
|
427
|
+
document.addEventListener("keydown", onKey);
|
|
428
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
429
|
+
}, []);
|
|
430
|
+
// #2347: on a phone the sections are one row that scrolls sideways: the one shown is brought into it.
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
const item = typeof document !== "undefined" ? document.querySelector(".s-nav .seg-item.active") : null;
|
|
433
|
+
const row = item && item.closest ? item.closest(".s-nav-groups") : null;
|
|
434
|
+
// That row only (scrollIntoView would also scroll the page sideways, and the nav column on a wide screen).
|
|
435
|
+
if (row && row.scrollWidth > row.clientWidth) {
|
|
436
|
+
const ir = item.getBoundingClientRect(), rr = row.getBoundingClientRect();
|
|
437
|
+
if (ir.left < rr.left || ir.right > rr.right) row.scrollLeft += ir.left - rr.left - (rr.width - ir.width) / 2;
|
|
438
|
+
}
|
|
439
|
+
}, [section]);
|
|
440
|
+
// #2347: a place in a section (`/settings/chat#classicbot`, an old /settings/classic link): there, once it is drawn.
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
const id = (typeof location !== "undefined" && location.hash ? location.hash.slice(1) : "");
|
|
443
|
+
if (!id) return;
|
|
444
|
+
let el = null;
|
|
445
|
+
try { el = document.getElementById(decodeURIComponent(id)); } catch { el = null; }
|
|
446
|
+
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "start" });
|
|
447
|
+
}, [section, navKey, !!data]);
|
|
246
448
|
// A dialog belongs to the navigation that opened it (its draft names one object, never another).
|
|
247
449
|
const open = dialog && dialog.key === navKey ? dialog : null;
|
|
248
450
|
const close = () => setDialog(null);
|
|
249
|
-
const searchable = section === "agents" || section === "
|
|
250
|
-
|
|
451
|
+
const searchable = section === "agents" || section === "chat";
|
|
452
|
+
// #2347 §7: on a phone, Settings itself (/settings) is the list of sections with search on top; a section opens on its
|
|
453
|
+
// own with "‹ Settings" back to it. A wider screen always shows the list beside the section (the CSS decides).
|
|
454
|
+
// Which one is shown is the root's data-view: its class stays exactly the effects producer's ("panel p-settings", #1629).
|
|
455
|
+
const atList = typeof location !== "undefined" && location.pathname === "/settings";
|
|
456
|
+
return html`<div data-effect-panel="settings" class="panel p-settings" data-view=${atList ? "index" : "section"}>
|
|
251
457
|
<${PanelHeader} title=${tn("title")} sub=${data && data.version ? html`<a class="pill ver" href=${data.version.includes("-") ? `${RELEASES}/latest` : `${RELEASES}/tag/v${data.version}`} target="_blank" rel="noopener">v${data.version}</a>` : null}>
|
|
252
|
-
<button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx}
|
|
458
|
+
<button type="button" class="btn btn-ghost btn-sm" disabled=${!ctx || yamlStaged(staged)} title=${ctx && yamlStaged(staged) ? tn("yamlUnsaved") : undefined} onClick=${openWizard}><${Icon} name="wand" size=${16} /><span class="hide-narrow">${tn("wizardButton")}</span></button>
|
|
253
459
|
<button type="button" class="icon-btn" aria-label=${tn("helpButton")} title=${tn("helpButton")} onClick=${() => setDialog({ kind: "help", key: navKey })}><${Icon} name="info" /></button>
|
|
254
460
|
</${PanelHeader}>
|
|
255
|
-
<nav data-trust="panel-navigation" class="seg" aria-label=${tn("title")}>${
|
|
256
|
-
|
|
461
|
+
<nav data-trust="panel-navigation" class="seg s-nav" aria-label=${tn("title")}><${SettingsSearch} /><div class="s-nav-groups">${SETTINGS_GROUPS.map((g) => html`<div key=${g.id} class="s-nav-group" role="group" aria-labelledby=${`s-grp-${g.id}`}>
|
|
462
|
+
<span class="s-nav-head" id=${`s-grp-${g.id}`}>${tn(`grp_${g.id}`)}</span>
|
|
463
|
+
${g.sections.map((k) => html`<a key=${k} href=${sectionPath(k)} class=${`seg-item${k === section ? " active" : ""}`}
|
|
464
|
+
aria-current=${k === section ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${tn(`sec_${k}`)}</span></a>`)}</div>`)}</div></nav>
|
|
257
465
|
<div data-trust="panel-content" class="panel-body"><div class="col">
|
|
466
|
+
<a class="s-back" href=${settingsPath()}><${Icon} name="back" size=${16} /><span>${tn("title")}</span><span class="s-back-here">${tn(`sec_${section}`)}</span></a>
|
|
258
467
|
${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
|
|
259
468
|
<input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
|
|
260
469
|
${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
|
|
261
470
|
<${PendingChanges} inline=${true} />
|
|
262
|
-
${ctx && (section === "agents" || section === "
|
|
471
|
+
${ctx && (section === "agents" || section === "chat") ? html`<${FirstRunCard} onConnect=${openWizard} />` : null}
|
|
263
472
|
${sectionBody}
|
|
264
473
|
</div></div>
|
|
265
|
-
${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
|
|
474
|
+
${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} section=${section} />` : null}
|
|
266
475
|
${open && ctx ? renderDialog(open, ctx, close) : null}
|
|
267
476
|
${open && open.kind === "help" ? html`<${HelpDialog} onClose=${close} />` : null}
|
|
268
477
|
</div>`;
|
|
@@ -274,6 +483,87 @@ export function SettingsPanel({ route, navKey }) {
|
|
|
274
483
|
* read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
|
|
275
484
|
* chat it points at), and the layout choices named there (their controls are where they always were).
|
|
276
485
|
*/
|
|
486
|
+
/** The words of `text` that `query` names, marked (for a result's name). */
|
|
487
|
+
function marked(text, query) {
|
|
488
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
489
|
+
if (!terms.length) return text;
|
|
490
|
+
const lower = text.toLowerCase(), out = [];
|
|
491
|
+
let i = 0;
|
|
492
|
+
while (i < text.length) {
|
|
493
|
+
let best = -1, len = 0;
|
|
494
|
+
for (const term of terms) { const at = lower.indexOf(term, i); if (at !== -1 && (best === -1 || at < best)) { best = at; len = term.length; } }
|
|
495
|
+
if (best === -1) { out.push(text.slice(i)); break; }
|
|
496
|
+
if (best > i) out.push(text.slice(i, best));
|
|
497
|
+
out.push(html`<mark>${text.slice(best, best + len)}</mark>`);
|
|
498
|
+
i = best + len;
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* #2347 §6: search every setting — by its name, its config key, or the words people use (both languages: "token",
|
|
505
|
+
* "語言", "port") — results grouped by section with where they are; Enter or a click goes there. "/" focuses it.
|
|
506
|
+
*/
|
|
507
|
+
function SettingsSearch() {
|
|
508
|
+
const [q, setQ] = useState("");
|
|
509
|
+
const [active, setActive] = useState(0);
|
|
510
|
+
const hits = useMemo(() => searchSettings(q).slice(0, 40), [q]);
|
|
511
|
+
// Grouped by section, the group of the best match first (its order is the ranking's): Enter takes the best.
|
|
512
|
+
const groups = useMemo(() => [...new Set(hits.map((e) => e.section))].map((s) => ({ section: s, items: hits.filter((e) => e.section === s) })), [hits]);
|
|
513
|
+
const flat = groups.flatMap((g) => g.items);
|
|
514
|
+
const groupOf = (s) => SETTINGS_GROUPS.find((g) => g.sections.includes(s));
|
|
515
|
+
const go = (entry) => { if (!entry) return; setQ(""); setActive(0); goToSetting(entry); };
|
|
516
|
+
const onKey = (e) => {
|
|
517
|
+
if (e.isComposing) return;
|
|
518
|
+
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => (flat.length ? (i + 1) % flat.length : 0)); }
|
|
519
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => (flat.length ? (i - 1 + flat.length) % flat.length : 0)); }
|
|
520
|
+
else if (e.key === "Enter") { e.preventDefault(); go(flat[Math.min(active, flat.length - 1)]); }
|
|
521
|
+
else if (e.key === "Escape") { if (q) { e.preventDefault(); e.stopPropagation(); setQ(""); } else e.target.blur(); }
|
|
522
|
+
};
|
|
523
|
+
const open = q.trim() !== "";
|
|
524
|
+
const at = Math.min(active, Math.max(flat.length - 1, 0));
|
|
525
|
+
let n = -1;
|
|
526
|
+
return html`<div class="s-find" role="search">
|
|
527
|
+
<label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchSettings")}</span>
|
|
528
|
+
<input id="settingsSearch" type="search" value=${q} placeholder=${tn("searchSettings")} autocomplete="off" spellcheck=${false}
|
|
529
|
+
role="combobox" aria-expanded=${open ? "true" : "false"} aria-controls="s-find-results" aria-autocomplete="list"
|
|
530
|
+
aria-activedescendant=${open && flat.length ? `s-find-${at}` : undefined}
|
|
531
|
+
onInput=${(e) => { setQ(e.target.value); setActive(0); }} onKeyDown=${onKey} /></label>
|
|
532
|
+
${open ? html`<div id="s-find-results" class="s-find-results" role="listbox" aria-label=${tn("searchSettings")}>
|
|
533
|
+
${flat.length ? groups.map((g) => html`<div key=${g.section} role="group" class="s-find-group" aria-label=${tn(`sec_${g.section}`)}>
|
|
534
|
+
<div class="s-find-head" aria-hidden="true">${tn(`grp_${groupOf(g.section).id}`)} › ${tn(`sec_${g.section}`)}</div>
|
|
535
|
+
${g.items.map((entry) => { n++; const i = n; return html`<div key=${entry.id} id=${`s-find-${i}`} role="option" aria-selected=${i === at ? "true" : "false"}
|
|
536
|
+
class=${`s-find-item${i === at ? " on" : ""}`} onMouseDown=${(e) => e.preventDefault()} onMouseMove=${i === at ? undefined : () => setActive(i)} onClick=${() => go(entry)}>
|
|
537
|
+
<span class="s-find-label">${marked(settingLabel(entry), q)}</span>${entry.key ? html`<code class="s-find-key">${entry.key}</code>` : null}</div>`; })}
|
|
538
|
+
</div>`) : html`<p class="s-find-none" role="status">${tn("searchNone")}</p>`}
|
|
539
|
+
<p class="s-find-foot" aria-hidden="true">${tn("searchFoot")}</p>
|
|
540
|
+
</div>` : null}
|
|
541
|
+
</div>`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** #2347: the staged change that stands for every held "You" preference (app-prefs.js holdPrefs). */
|
|
545
|
+
const YOU_PREFS = "you:prefs";
|
|
546
|
+
/** #2347: the fleet.yaml editor's staged writes (Advanced → Developer), one per write in order: `yaml:0`, `yaml:1`, … */
|
|
547
|
+
const YAML_KEY = "yaml:";
|
|
548
|
+
const isYamlKey = (k) => typeof k === "string" && k.startsWith(YAML_KEY);
|
|
549
|
+
// What the editor writes (fleet.yaml's defaults, channels and agents), and the setup wizard's commit of both.
|
|
550
|
+
const MODEL_WRITE = /^\/api\/settings\/(fleet\/(defaults|channels|instances)(\/|$)|quickstart\/)/;
|
|
551
|
+
const writesModel = (c) => !!(c && c.request && MODEL_WRITE.test(String(c.request.url || "")));
|
|
552
|
+
/** #2347: the editor and the forms that write the same settings refuse to mix: one waits for the other's Save or Discard. */
|
|
553
|
+
const yamlStaged = (staged) => [...staged.keys()].some(isYamlKey);
|
|
554
|
+
const formsStaged = (staged) => [...staged].some(([k, c]) => !isYamlKey(k) && writesModel(c));
|
|
555
|
+
/** A form that writes what a staged fleet.yaml edit writes: shown, not edited, until that edit is saved or discarded. */
|
|
556
|
+
function YamlLock({ staged, children }) {
|
|
557
|
+
const locked = yamlStaged(staged);
|
|
558
|
+
return html`<fieldset class="s-yaml-lock" disabled=${locked}>
|
|
559
|
+
${locked ? html`<p class="note s-yaml-lock-note" role="status">${tn("yamlUnsaved")} <a href=${settingsPath("advanced")}>${tn("yamlOpen")}</a></p>` : null}
|
|
560
|
+
${children}</fieldset>`;
|
|
561
|
+
}
|
|
562
|
+
/** Put back this person's and this device's own choices among `changes` (a Discard, or leaving Settings with them). */
|
|
563
|
+
function revertLocal(changes) {
|
|
564
|
+
for (const c of [...changes.values()].reverse()) if (c.local) { try { c.local.revert(); } catch { /* the rest still go back */ } }
|
|
565
|
+
}
|
|
566
|
+
|
|
277
567
|
export function DeviceSection() {
|
|
278
568
|
const P = () => globalThis.AgendPreview;
|
|
279
569
|
const [on, setOn] = useState(P() ? P().optedIn() : false);
|
|
@@ -312,7 +602,7 @@ export function DeviceSection() {
|
|
|
312
602
|
<section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
|
|
313
603
|
<p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
|
|
314
604
|
<${DevicePrefs} labels=${true} />
|
|
315
|
-
${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
605
|
+
${P() ? html`<label class="check"><input id="pref-previews" type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
|
|
316
606
|
<span>${t("chat.previews")}</span></label>` : null}
|
|
317
607
|
</section>
|
|
318
608
|
<section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
|
|
@@ -382,12 +672,12 @@ function renderDialog(d, ctx, close) {
|
|
|
382
672
|
}
|
|
383
673
|
|
|
384
674
|
/** What the sections and dialogs work with: the data, and the ways to change it (staging, or a direct write). */
|
|
385
|
-
function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
|
|
675
|
+
function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive, extra = {}) {
|
|
386
676
|
const { fleet, classic, schema } = data;
|
|
387
677
|
const chs = channelsOf(fleet);
|
|
388
678
|
const edit = (fn) => setData((d) => { const next = structuredClone(d); fn(next); return next; });
|
|
389
679
|
return {
|
|
390
|
-
...data, reload, reloadLive, stage, unstage, shortName,
|
|
680
|
+
...data, ...extra, reload, reloadLive, stage, unstage, shortName,
|
|
391
681
|
channelType: (id) => { const ch = chs.find((c, i) => channelId(c, i) === id); return ch ? ch.type : undefined; },
|
|
392
682
|
classicRuntime: (instanceName) => {
|
|
393
683
|
const live = data.classicLive[instanceName] || {};
|
|
@@ -475,7 +765,7 @@ function Agents({ ctx, search, openDialog }) {
|
|
|
475
765
|
try { await work(); } finally { setBusy((b) => { const n = new Map(b); n.delete(name); return n; }); }
|
|
476
766
|
};
|
|
477
767
|
const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
|
|
478
|
-
<button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
|
|
768
|
+
<button type="button" id="newAgentBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
|
|
479
769
|
// #2233: after the agents, each login on this machine with Sign in (backend-signin.js).
|
|
480
770
|
const signIns = html`<${SignInList} />`;
|
|
481
771
|
if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />${signIns}`;
|
|
@@ -542,7 +832,7 @@ function Bots({ ctx, search, openDialog }) {
|
|
|
542
832
|
const needle = search.trim().toLowerCase();
|
|
543
833
|
const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
|
|
544
834
|
const head = html`<div class="list-head"><span>${all.length === 1 ? tn("countBot") : tn("countBots", all.length)}</span>
|
|
545
|
-
<button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
|
|
835
|
+
<button type="button" id="newBotBtn" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
|
|
546
836
|
return html`${head}
|
|
547
837
|
${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
|
|
548
838
|
const users = (ch.access && ch.access.allowed_users) || [];
|
|
@@ -569,8 +859,7 @@ function Bots({ ctx, search, openDialog }) {
|
|
|
569
859
|
${!overPublicLink && ctx.fleetUp && meta.status !== "connected" ? html`<${ConnectionRetry} id=${channelId(ch, i)} ctx=${ctx} />` : null}
|
|
570
860
|
<button type="button" class="btn btn-sm" onClick=${() => openDialog({ kind: "bot", id: channelId(ch, i) })}>${tn("settingsButton")}</button></span>
|
|
571
861
|
</div>`;
|
|
572
|
-
})}</div>`}
|
|
573
|
-
${ctx.providerOk ? html`<${ProviderKeys} ctx=${ctx} />` : null}`;
|
|
862
|
+
})}</div>`}`;
|
|
574
863
|
}
|
|
575
864
|
|
|
576
865
|
/**
|
|
@@ -620,7 +909,7 @@ function ProviderKeys({ ctx }) {
|
|
|
620
909
|
} catch { say(spec.id, { error: tn("failed") }); }
|
|
621
910
|
finally { inFlight.current.delete(spec.id); if (lease.current()) setBusy(new Set(inFlight.current)); }
|
|
622
911
|
};
|
|
623
|
-
return html`<section class="card"><h3>${tn("providerKeys")}</h3>
|
|
912
|
+
return html`<section class="card s-provider-keys"><h3>${tn("providerKeys")}</h3>
|
|
624
913
|
${!ctx.providerSecrets.length ? html`<p class="note">${tn("noProviderVerifiers")}</p>` : ctx.providerSecrets.map((spec) => html`<div key=${spec.id} class="s-key">
|
|
625
914
|
<div class="s-key-row"><strong>${spec.display_name}</strong><span class="tag">${spec.token_present ? tn("keyConfigured") : tn("keyNotConfigured")}</span>
|
|
626
915
|
${spec.verifier === "available" ? html`<input type="password" autocomplete="new-password" aria-label=${tn("providerKeyInput", spec.display_name)}
|
|
@@ -663,56 +952,78 @@ function Classic({ ctx, search, openDialog }) {
|
|
|
663
952
|
})}</div>`;
|
|
664
953
|
}
|
|
665
954
|
|
|
666
|
-
/**
|
|
667
|
-
|
|
668
|
-
* ClassicBot defaults, web) — each only with what differs from what this form showed, so changing one hot default
|
|
669
|
-
* never also stages untouched settings as a fleet restart.
|
|
670
|
-
*/
|
|
671
|
-
function General({ ctx }) {
|
|
672
|
-
const { schema } = ctx;
|
|
673
|
-
const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
|
|
955
|
+
/** General's form, from the saved defaults (`d`) and ClassicBot's (`c`). */
|
|
956
|
+
function generalForm(d, c, fleet = {}) {
|
|
674
957
|
const hang = d.hang_detector || {}, startup = d.startup || {};
|
|
675
|
-
|
|
676
|
-
const original = useMemo(() => ({ c: structuredClone(c) }), []);
|
|
677
|
-
const [f, setF] = useState(() => ({
|
|
958
|
+
return {
|
|
678
959
|
backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
|
|
679
960
|
agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : "", tool_progress: d.tool_progress ?? "off",
|
|
680
961
|
visibility: visibilityDefault(d), reply: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
|
|
681
|
-
|
|
962
|
+
// #2495 review: one field per dashboard feature, so a draft keeps only the features edited (a whole-object field
|
|
963
|
+
// would bring back a feature another admin changed meanwhile).
|
|
964
|
+
...Object.fromEntries(DASHBOARD_FEATURES.map((k) => [`dash_${k}`, dashboardDefault(d, k)])),
|
|
682
965
|
hangOn: hang.enabled !== false, hang: String(hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES),
|
|
683
|
-
|
|
966
|
+
// #2347 §10 review: unset startup concurrency is automatic (deriveSpawnConcurrency: 2–10 by free memory and cores),
|
|
967
|
+
// not 10 — an empty field, and a save that leaves it unset (the defaults merge replaces `startup` whole).
|
|
968
|
+
concurrency: startup.concurrency == null ? "" : String(startup.concurrency), stagger: String(startup.stagger_delay_ms ?? 500),
|
|
684
969
|
classicBackend: c.backend || d.backend || "claude-code",
|
|
685
970
|
classicToolProgress: c.tool_progress ?? d.tool_progress ?? "off", classicToolProgressInherit: !hasOwn(c, "tool_progress"),
|
|
686
971
|
classicReply: c.reply_completion_guard ?? d.reply_completion_guard ?? true, classicReplyInherit: !hasOwn(c, "reply_completion_guard"),
|
|
687
972
|
admins: [...(c.admin_users || [])], guilds: [...(c.allowed_guilds || [])],
|
|
688
|
-
|
|
689
|
-
|
|
973
|
+
roots: [...(fleet.project_roots || [])],
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* General: the fleet's defaults and ClassicBot's. #2347: an edit is unsaved at once — staged (up to two changes:
|
|
979
|
+
* defaults, ClassicBot defaults), each only with what differs from what is saved, so changing one hot default never also
|
|
980
|
+
* stages untouched settings as a fleet restart; the sticky bar saves them. A changed field carries the straight bar.
|
|
981
|
+
*/
|
|
982
|
+
function General({ ctx, part = "all" }) {
|
|
983
|
+
const { schema } = ctx;
|
|
984
|
+
const d = ctx.fleet.defaults || {}, c = (ctx.classic && ctx.classic.defaults) || {};
|
|
985
|
+
const hang = d.hang_detector || {}, startup = d.startup || {};
|
|
986
|
+
const primary = channelsOf(ctx.fleet)[0];
|
|
987
|
+
const original = useMemo(() => ({ c: structuredClone(c) }), []);
|
|
988
|
+
const base = useMemo(() => generalForm(d, c, ctx.fleet), []);
|
|
989
|
+
const [f, setF] = useDraft(ctx, "general", () => base);
|
|
690
990
|
const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
|
|
991
|
+
/** The class of a field's row: `field`, and `s-dirty` while one of its keys differs from what is saved. */
|
|
992
|
+
const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], base[k])) ? " s-dirty" : ""}`;
|
|
691
993
|
const num = (v) => Number(v);
|
|
692
994
|
const errors = {
|
|
693
995
|
auto: nonNegative(f.auto), hang: positive(f.hang),
|
|
694
|
-
concurrency: !Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20 ? "concurrencyRange" : "",
|
|
996
|
+
concurrency: f.concurrency.trim() !== "" && (!Number.isInteger(num(f.concurrency)) || num(f.concurrency) < 1 || num(f.concurrency) > 20) ? "concurrencyRange" : "",
|
|
695
997
|
stagger: !Number.isFinite(num(f.stagger)) || f.stagger.trim() === "" || num(f.stagger) < 0 || num(f.stagger) > 30000 ? "staggerRange" : "",
|
|
696
998
|
};
|
|
697
|
-
const warnings = { concurrency: num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
|
|
999
|
+
const warnings = { concurrency: f.concurrency.trim() !== "" && num(f.concurrency) > 4 ? "concurrencyWarning" : "", stagger: num(f.stagger) < 100 ? "staggerWarning" : "" };
|
|
698
1000
|
const ready = !Object.values(errors).some(Boolean);
|
|
699
|
-
const
|
|
700
|
-
|
|
1001
|
+
const firstError = Object.values(errors).find(Boolean);
|
|
1002
|
+
// #2347: every edit, staged as it is made (the first render included: a draft kept from before is staged again).
|
|
1003
|
+
useEffect(() => {
|
|
1004
|
+
if (!ready) {
|
|
1005
|
+
// Where it is (#2382 review): the bar in another section names it and goes there.
|
|
1006
|
+
const at = { auto: "#g-auto", hang: "#g-hang", concurrency: "#g-conc", stagger: "#g-stag" }[Object.keys(errors).find((k) => errors[k])];
|
|
1007
|
+
syncStage(ctx, "defaults:invalid", { label: tn("updateDefaults"), invalid: tn(firstError), section: "agent-defaults", target: at });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
ctx.unstage("defaults:invalid");
|
|
701
1011
|
const defaultsPatch = changedFields({
|
|
702
1012
|
backend: f.backend, model: f.model || undefined, locale: f.locale || null, auto_pause_after: num(f.auto),
|
|
703
1013
|
agent_mode: f.agent_mode, tool_set: f.tool_set || null, tool_progress: f.tool_progress, cross_instance_visibility: f.visibility,
|
|
704
1014
|
reply_completion_guard: f.reply, log_level: f.log_level,
|
|
705
|
-
// The dashboard features every agent may use unless its own setting says otherwise
|
|
706
|
-
dashboard:
|
|
1015
|
+
// The dashboard features every agent may use unless its own setting says otherwise. All three go, because the write
|
|
1016
|
+
// to the fleet's defaults merges top-level keys only (`dashboard` is replaced whole): the untouched ones as saved.
|
|
1017
|
+
dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, f[`dash_${k}`]])),
|
|
707
1018
|
hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
|
|
708
|
-
startup: { concurrency: num(f.concurrency), stagger_delay_ms: num(f.stagger) },
|
|
1019
|
+
startup: { ...(f.concurrency.trim() === "" ? {} : { concurrency: num(f.concurrency) }), stagger_delay_ms: num(f.stagger) },
|
|
709
1020
|
}, {
|
|
710
1021
|
backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
|
|
711
1022
|
agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
|
|
712
1023
|
cross_instance_visibility: visibilityDefault(d), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
|
|
713
1024
|
dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, dashboardDefault(d, k)])),
|
|
714
1025
|
hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
|
|
715
|
-
startup: { concurrency: startup.concurrency
|
|
1026
|
+
startup: { ...(startup.concurrency == null ? {} : { concurrency: startup.concurrency }), stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
|
|
716
1027
|
});
|
|
717
1028
|
const oc = original.c;
|
|
718
1029
|
const classicPatch = changedFields({
|
|
@@ -726,78 +1037,97 @@ function General({ ctx }) {
|
|
|
726
1037
|
reply_completion_guard: hasOwn(oc, "reply_completion_guard") ? oc.reply_completion_guard : null,
|
|
727
1038
|
admin_users: oc.admin_users || [], allowed_guilds: oc.allowed_guilds || [],
|
|
728
1039
|
});
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
1040
|
+
// #2347 §10: where new agents may work (fleet.project_roots, from Fleet → Config): its own write, the one route
|
|
1041
|
+
// that sets it (POST /ui/config; a change to it waits for a fleet admin's OK, as it did there).
|
|
1042
|
+
const roots = f.roots.map((r) => String(r).trim()).filter(Boolean); // ChipList keeps "2024" as a number; paths are text
|
|
1043
|
+
syncStage(ctx, "fleet:project_roots", sameValue(roots, base.roots) ? null : { label: tn("projectRootsTitle"), count: 1,
|
|
1044
|
+
impact: impactOf(schema, "fleet.project_roots"), request: { method: "POST", url: "/ui/config", body: { project_roots: roots } } });
|
|
1045
|
+
const dKeys = Object.keys(defaultsPatch), cKeys = Object.keys(classicPatch);
|
|
1046
|
+
syncStage(ctx, "defaults:fleet", dKeys.length ? { label: tn("updateDefaults"), count: dKeys.length,
|
|
1047
|
+
impact: batchImpact(schema, dKeys, (k) => defaultsImpact(schema, k)),
|
|
1048
|
+
request: { method: "PUT", url: "/api/settings/fleet/defaults", body: defaultsPatch } } : null);
|
|
1049
|
+
syncStage(ctx, "defaults:classic", cKeys.length ? { label: tn("updateClassicDefaults"), count: cKeys.length,
|
|
1050
|
+
impact: cKeys.every((k) => impactOf(schema, `classic_defaults.${k}`) === "now") ? "now" : "fleet",
|
|
1051
|
+
request: { method: "PUT", url: "/api/settings/classic/defaults", body: classicPatch } } : null);
|
|
1052
|
+
}, [f, ready]);
|
|
1053
|
+
// #2347 §10: a default nobody set in fleet.yaml shows the built-in value it holds, marked "default" until it is edited;
|
|
1054
|
+
// ClassicBot's inherited ones say what they inherit from Agent defaults.
|
|
1055
|
+
const unset = {
|
|
1056
|
+
locale: !d.locale, backend: !d.backend, model: !d.model, auto: !hasOwn(d, "auto_pause_after"), tool_progress: !hasOwn(d, "tool_progress"),
|
|
1057
|
+
visibility: !VISIBILITY_MODES.includes(d.cross_instance_visibility), reply: !hasOwn(d, "reply_completion_guard"), tool_set: !d.tool_set,
|
|
1058
|
+
log_level: !d.log_level, agent_mode: !d.agent_mode, hangOn: !hasOwn(hang, "enabled"), hang: !hasOwn(hang, "timeout_minutes"),
|
|
1059
|
+
concurrency: !hasOwn(startup, "concurrency"), stagger: !hasOwn(startup, "stagger_delay_ms"),
|
|
739
1060
|
};
|
|
740
|
-
const
|
|
1061
|
+
const dm = (k) => (unset[k] && sameValue(f[k], base[k]) ? html`<${DefaultMark} />` : null);
|
|
1062
|
+
const fromDefaults = (k, value, target) => ({ text: valueText(value), builtin: !hasOwn(d, k), from: inAgentDefaults(target) });
|
|
1063
|
+
const L = (text, field, id, k) => html`<${Label} text=${text} schema=${schema} field=${field} htmlFor=${id} mark=${k ? dm(k) : null} />`;
|
|
741
1064
|
const fleetReply = replyGuardSupported(f.backend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.backend, f.agent_mode);
|
|
742
1065
|
const classicReply = replyGuardSupported(f.classicBackend, f.agent_mode, d.kiro_ui) ? tn("replyGuardSupported") : tn("replyGuardUnsupported", f.classicBackend, f.agent_mode);
|
|
743
|
-
|
|
744
|
-
|
|
1066
|
+
// #2347: Agent defaults show the fleet's part; Chat & connections ClassicBot's. One form, one draft either way.
|
|
1067
|
+
const fleetPart = part !== "classic", classicPart = part !== "fleet";
|
|
1068
|
+
return html`<div class=${`form s-general s-part-${part}`}>
|
|
1069
|
+
${fleetPart ? html`<section class="card"><h3>${tn("defaultsTitle")}</h3>
|
|
745
1070
|
<div class="grid2">
|
|
746
|
-
<div class
|
|
1071
|
+
<div class=${fc("field", "locale")}>${L(tn("language"), "defaults.locale", "g-locale", "locale")}<${Select} id="g-locale" value=${f.locale} onChange=${set("locale")}
|
|
747
1072
|
options=${localeOptions(f.locale).map((o) => (o.auto ? { value: "", label: tn("localeAuto") } : o))} /></div>
|
|
748
|
-
<div class
|
|
749
|
-
<div class
|
|
1073
|
+
<div class=${fc("field", "backend")}>${L(tn("defaultBackend"), "defaults.backend", "g-be", "backend")}<${BackendSelect} id="g-be" value=${f.backend} onChange=${set("backend")} /></div>
|
|
1074
|
+
<div class=${fc("field", "model")}>${L(tn("defaultModel"), "defaults.model", "g-model", "model")}<${ModelSelect} id="g-model" value=${f.model} onChange=${set("model")}
|
|
750
1075
|
backend=${f.backend || "claude-code"} inherit=${tn("modelCliDefault")} /></div>
|
|
751
|
-
<div class
|
|
752
|
-
<div class
|
|
753
|
-
<div class
|
|
1076
|
+
<div class=${fc("field", "auto")}>${L(`${tn("autoPause")} (${tn("minutes")})`, "defaults.auto_pause_after", "g-auto", "auto")}<input id="g-auto" type="number" min="0" step="1" class=${errors.auto ? "invalid" : ""} value=${f.auto} onInput=${(e) => set("auto")(e.target.value)} /><${Feedback} error=${errors.auto} /></div>
|
|
1077
|
+
<div class=${fc("field", "tool_progress")}>${L(tn("toolProgress"), "defaults.tool_progress", "g-tp", "tool_progress")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
|
|
1078
|
+
<div class=${fc("field", "visibility")}>${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis", "visibility")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
|
|
754
1079
|
<p class="note">${tn("crossInstanceVisibilityHint")}</p></div>
|
|
755
|
-
<div class
|
|
1080
|
+
<div class=${fc("field", "reply")}><label class="check"><input id="g-reply" type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span>${dm("reply")}<${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
|
|
756
1081
|
<p class="note">${fleetReply}</p></div>
|
|
757
1082
|
</div>
|
|
758
|
-
<fieldset
|
|
1083
|
+
<fieldset id="g-dash" class=${fc("field dash-perms", ...DASHBOARD_FEATURES.map((k) => `dash_${k}`))}><legend>${L(tn("dashboardAccess"), "defaults.dashboard")}</legend>
|
|
759
1084
|
<p class="note">${tn("dashboardDefaultsHint")}</p>
|
|
760
|
-
${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f
|
|
761
|
-
onChange=${(e) =>
|
|
1085
|
+
${DASHBOARD_FEATURES.map((k) => html`<label key=${k} class="check"><input type="checkbox" checked=${f[`dash_${k}`]}
|
|
1086
|
+
onChange=${(e) => set(`dash_${k}`)(e.target.checked)} /><span>${tn(`dashboard_${k}`)}</span></label>`)}
|
|
762
1087
|
</fieldset>
|
|
763
|
-
</section
|
|
764
|
-
|
|
1088
|
+
</section>` : null}
|
|
1089
|
+
${classicPart ? html`<section class="card"><h3>${tn("classicDefaultsTitle")}</h3>
|
|
765
1090
|
<div class="grid2">
|
|
766
|
-
<div class
|
|
767
|
-
|
|
768
|
-
|
|
1091
|
+
<div class=${fc("field", "classicBackend")}>${L(tn("classicBackend"), "classic_defaults.backend", "g-cbe")}<${BackendSelect} id="g-cbe" value=${f.classicBackend} onChange=${set("classicBackend")} />
|
|
1092
|
+
${!c.backend && f.classicBackend === base.classicBackend ? html`<${Inherited} text=${valueText(f.classicBackend, "backend")} builtin=${!d.backend} from=${inAgentDefaults("#g-be")} />` : null}</div>
|
|
1093
|
+
<div class=${fc("field", "classicToolProgress", "classicToolProgressInherit")}>${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
|
|
1094
|
+
<${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")} inherited=${fromDefaults("tool_progress", d.tool_progress ?? "off", "#g-tp")}>
|
|
769
1095
|
<${Select} id="g-ctp" disabled=${f.classicToolProgressInherit} value=${f.classicToolProgressInherit ? (d.tool_progress ?? "off") : f.classicToolProgress} onChange=${set("classicToolProgress")} options=${TOOL_PROGRESS} /></${Override}></div>
|
|
770
|
-
<div class
|
|
771
|
-
<${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")}>
|
|
772
|
-
<label class="check"><input type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
|
|
1096
|
+
<div class=${fc("field", "classicReply", "classicReplyInherit")}>${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
|
|
1097
|
+
<${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")} inherited=${fromDefaults("reply_completion_guard", d.reply_completion_guard ?? true, "#g-reply")}>
|
|
1098
|
+
<label class="check"><input id="g-creply" type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
|
|
773
1099
|
<p class="note">${classicReply}</p></div>
|
|
774
1100
|
</div>
|
|
775
1101
|
<h4>${tn("whoUsesClassic")}</h4>
|
|
776
1102
|
<div class="grid2">
|
|
777
|
-
<div
|
|
778
|
-
<div
|
|
1103
|
+
<div id="g-admins" class=${fc("field", "admins")}>${L(tn("classicAdmins"), "classic.admin_users")}<${ChipList} items=${f.admins} onChange=${set("admins")} placeholder=${tn("userIdPlaceholder")} label=${tn("classicAdmins")} /></div>
|
|
1104
|
+
<div id="g-guilds" class=${fc("field", "guilds")}>${L(tn("allowedGuilds"), "classic.allowed_guilds")}<${ChipList} items=${f.guilds} onChange=${set("guilds")} placeholder=${tn("guildIdPlaceholder")} label=${tn("allowedGuilds")} /></div>
|
|
779
1105
|
</div>
|
|
780
|
-
</section
|
|
781
|
-
|
|
1106
|
+
</section>` : null}
|
|
1107
|
+
${fleetPart ? html`<section class="card" id="project-roots"><h3>${tn("projectRootsTitle")}</h3>
|
|
1108
|
+
<p class="note">${tn("projectRootsHint")}</p>
|
|
1109
|
+
<div id="g-roots" class=${fc("field", "roots")}>${L(tn("projectRootsTitle"), "fleet.project_roots")}<${ChipList} items=${f.roots} onChange=${set("roots")} placeholder="/path/to/projects" label=${tn("projectRootsTitle")} />
|
|
1110
|
+
${f.roots.length ? null : html`<${InEffect} text=${tn("projectRootsAnywhere")} />`}</div>
|
|
1111
|
+
</section>` : null}
|
|
1112
|
+
${fleetPart ? html`<${Drawer} title=${tn("advancedSection")}>
|
|
782
1113
|
<section class="card"><h3>${tn("runtimeResources")}</h3><div class="grid2">
|
|
783
|
-
<div class
|
|
1114
|
+
<div class=${fc("field", "tool_set")}>${L(tn("toolSet"), "defaults.tool_set", "g-ts", "tool_set")}<${Select} id="g-ts" value=${f.tool_set} onChange=${set("tool_set")}
|
|
784
1115
|
options=${[{ value: "", label: tn("toolSetUnset", toolSetDefault({}, {}), toolSetDefault({}, { general_topic: true })) }, ...TOOL_SETS]} /></div>
|
|
785
|
-
<div class
|
|
786
|
-
<div class
|
|
1116
|
+
<div class=${fc("field", "log_level")}>${L(tn("logLevel"), "defaults.log_level", "g-ll", "log_level")}<${Select} id="g-ll" value=${f.log_level} onChange=${set("log_level")} options=${LOG_LEVELS} /></div>
|
|
1117
|
+
<div class=${fc("field", "agent_mode")}>${L(tn("agentMode"), "defaults.agent_mode", "g-am", "agent_mode")}<${Select} id="g-am" value=${f.agent_mode} onChange=${set("agent_mode")} options=${AGENT_MODES} /></div>
|
|
787
1118
|
</div></section>
|
|
788
1119
|
<section class="card"><h3>${tn("detection")}</h3><div class="grid2">
|
|
789
|
-
<label class
|
|
790
|
-
<div class
|
|
1120
|
+
<label class=${fc("check", "hangOn")}><input id="g-hang-on" type="checkbox" checked=${f.hangOn} onChange=${(e) => set("hangOn")(e.target.checked)} /><span>${tn("hangEnabled")}</span>${dm("hangOn")}<${Impact} schema=${schema} field="defaults.hang_detector" /></label>
|
|
1121
|
+
<div class=${fc("field", "hang")}>${L(`${tn("hangTimeout")} (${tn("minutes")})`, "defaults.hang_detector", "g-hang", "hang")}<input id="g-hang" type="number" min="0.1" step="0.5" class=${errors.hang ? "invalid" : ""} value=${f.hang} onInput=${(e) => set("hang")(e.target.value)} /><${Feedback} error=${errors.hang} /></div>
|
|
791
1122
|
</div></section>
|
|
792
1123
|
<section class="card"><h3>${tn("startup")}</h3><div class="grid2">
|
|
793
|
-
<div class
|
|
794
|
-
|
|
1124
|
+
<div class=${fc("field", "concurrency")}>${L(tn("concurrency"), "fleet.spawn_concurrency", "g-conc")}<input id="g-conc" type="number" min="1" max="20" step="1" class=${errors.concurrency ? "invalid" : ""} value=${f.concurrency} onInput=${(e) => set("concurrency")(e.target.value)} /><${Feedback} error=${errors.concurrency} warning=${warnings.concurrency} />
|
|
1125
|
+
${f.concurrency.trim() === "" ? html`<${InEffect} text=${tn("concurrencyAuto")} />` : null}</div>
|
|
1126
|
+
<div class=${fc("field", "stagger")}>${L(`${tn("staggerDelay")} (ms)`, "fleet.spawn_stagger_ms", "g-stag", "stagger")}<input id="g-stag" type="number" min="0" max="30000" step="100" class=${errors.stagger ? "invalid" : ""} value=${f.stagger} onInput=${(e) => set("stagger")(e.target.value)} /><${Feedback} error=${errors.stagger} warning=${warnings.stagger} /></div>
|
|
795
1127
|
</div></section>
|
|
796
1128
|
<section class="card"><h3>${tn("accessTitle")}</h3>
|
|
797
1129
|
<p class="note">${primary ? tn("accessMovedHint", primary.id || chLabel(0)) : tn("noBots")}</p></section>
|
|
798
|
-
</${Drawer}
|
|
799
|
-
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${review}>${tn("reviewChanges")}</button>
|
|
800
|
-
${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
|
|
1130
|
+
</${Drawer}>` : null}
|
|
801
1131
|
</div>`;
|
|
802
1132
|
}
|
|
803
1133
|
|
|
@@ -813,7 +1143,30 @@ export const WEB_READ_ONLY = [
|
|
|
813
1143
|
"web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
|
|
814
1144
|
"web.usage_panel", "web.search", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
|
|
815
1145
|
];
|
|
816
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* #2347 §10: what each of them is when fleet.yaml leaves it out, as the host applies it (null: nothing — none is used).
|
|
1148
|
+
* web.preview_port: the web port + 1 (previewSettings); the uploads: UPLOAD_LIMITS (#1937); the terminal: 127.0.0.1.
|
|
1149
|
+
*/
|
|
1150
|
+
export function webReadOnlyDefault(path, fleet) {
|
|
1151
|
+
// previewSettings: a web port of 0 (tests, harnesses) gets an ephemeral one; past 65535 there is no preview listener.
|
|
1152
|
+
const hp = fleet.health_port ?? 19280;
|
|
1153
|
+
return {
|
|
1154
|
+
"web.allowed_hosts": [], "web.view_access": "open", "web.notify_login": true, "web.preview": true, "web.preview_port": hp === 0 ? 0 : hp + 1 <= 65535 ? hp + 1 : null,
|
|
1155
|
+
// #2518: web.search unset is no age limit (the 300,000-row cap still applies); its line says so itself.
|
|
1156
|
+
"web.preview_origin": null, "web.preview_on_public_link": true, "web.usage_panel": true, "web.search": null, "web.provider_secrets": false,
|
|
1157
|
+
"web.upload_max_file_mb": uploadMbInEffect(fleet.web).file, "web.upload_max_total_mb": 100, "web_terminal.bind": "127.0.0.1", "web_terminal.tunnel": null,
|
|
1158
|
+
}[path];
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* #2347 §10 review: the upload limits in force, in MB, as uploadLimits (#1937) resolves them: a value that is not a whole
|
|
1162
|
+
* 1–500 is the default (50 / 100), and one file is never allowed more than a whole message.
|
|
1163
|
+
*/
|
|
1164
|
+
export function uploadMbInEffect(web) {
|
|
1165
|
+
const mb = (v, dflt) => (Number.isInteger(v) && v >= 1 && v <= 500 ? v : dflt);
|
|
1166
|
+
const total = mb(web && web.upload_max_total_mb, 100);
|
|
1167
|
+
return { file: Math.min(mb(web && web.upload_max_file_mb, 50), total), total };
|
|
1168
|
+
}
|
|
1169
|
+
function WebSection({ ctx, staged, part = "all" }) {
|
|
817
1170
|
const { schema } = ctx;
|
|
818
1171
|
const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
|
|
819
1172
|
// Over the public link the fields read only; the Guests and Web authority cards are not offered (the server never
|
|
@@ -828,7 +1181,12 @@ function WebSection({ ctx, staged }) {
|
|
|
828
1181
|
terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
|
|
829
1182
|
}), []);
|
|
830
1183
|
const text = (v) => (v === null || v === undefined ? "" : String(v));
|
|
831
|
-
|
|
1184
|
+
/** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
|
|
1185
|
+
const savedForm = useMemo(() => ({ echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
|
|
1186
|
+
ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
|
|
1187
|
+
abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
|
|
1188
|
+
termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
|
|
1189
|
+
const [f, setF] = useDraft(ctx, "web", () => savedForm, () => {
|
|
832
1190
|
// Settings sections share one draft. Reopening Web must show it as well as preserve it in the Apply bar, so a
|
|
833
1191
|
// subsequent field edit keeps earlier edits. The original disk values remain the diff/confirmation baseline.
|
|
834
1192
|
const request = staged.get("web")?.request;
|
|
@@ -843,12 +1201,12 @@ function WebSection({ ctx, staged }) {
|
|
|
843
1201
|
termOn: pick(terminal, "enabled", original.terminal.enabled) !== false, termTtl: text(pick(terminal, "ttl_minutes", original.terminal.ttl_minutes)),
|
|
844
1202
|
};
|
|
845
1203
|
});
|
|
846
|
-
const [msg, setMsg] = useState(null);
|
|
847
1204
|
const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
|
|
1205
|
+
const fc = (cls, ...keys) => `${cls}${keys.some((k) => !sameValue(f[k], savedForm[k])) ? " s-dirty" : ""}`;
|
|
848
1206
|
// Empty = the default (null in the write: the key is removed from fleet.yaml).
|
|
849
1207
|
const value = (v) => (v.trim() === "" ? null : Number(v));
|
|
850
1208
|
const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
|
|
851
|
-
// The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
|
|
1209
|
+
// The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY; the terminal's 10 min); an idle time left blank follows a shorter session, as
|
|
852
1210
|
// the store and the validator apply it — so only an idle time that is typed can exceed the session length.
|
|
853
1211
|
const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
|
|
854
1212
|
// Named mode: a hostname is typed (the server judges the merged result the same way).
|
|
@@ -866,8 +1224,14 @@ function WebSection({ ctx, staged }) {
|
|
|
866
1224
|
};
|
|
867
1225
|
const ready = !overPublicLink && !Object.values(errors).some(Boolean);
|
|
868
1226
|
const review = (quiet = false) => {
|
|
869
|
-
//
|
|
870
|
-
if (
|
|
1227
|
+
// #2347: invalid text keeps the draft unsaved and Save waiting ("Fix 1 field to save"); no older valid draft is sent.
|
|
1228
|
+
if (overPublicLink) return;
|
|
1229
|
+
if (!ready) {
|
|
1230
|
+
const at = { hostname: "#publicWebHostname", originPort: "#publicWebOriginPort", ttl: "#publicWebTtl", abs: "#webSessionAbsolute", idle: "#webSessionIdle",
|
|
1231
|
+
termTtl: "#webTerminalTtl" }[Object.keys(errors).find((k) => errors[k])];
|
|
1232
|
+
syncStage(ctx, "web", { label: tn("webSettingsLabel"), invalid: tn(Object.values(errors).find(Boolean)), section: "access", target: at });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
871
1235
|
// Keep an existing secret reference, including a custom one. Only a newly supplied hostname without any
|
|
872
1236
|
// reference adopts the standard env key; mounting or editing an unrelated field never rewrites credentials.
|
|
873
1237
|
const tokenEnv = f.hostname === original.pub.hostname ? original.pub.tunnel_token_env
|
|
@@ -894,10 +1258,9 @@ function WebSection({ ctx, staged }) {
|
|
|
894
1258
|
...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
|
|
895
1259
|
];
|
|
896
1260
|
if (fields.length) {
|
|
897
|
-
ctx.stage("web", { label: tn("
|
|
1261
|
+
ctx.stage("web", { label: tn("webSettingsLabel"), count: fields.length, impact: batchImpact(schema, fields, (k) => impactOf(schema, k)),
|
|
898
1262
|
request: { method: "PUT", url: "/api/settings/fleet/web", body } }, quiet);
|
|
899
1263
|
} else if (staged.has("web")) ctx.unstage("web");
|
|
900
|
-
setMsg(fields.length ? tn("readyApply") : null);
|
|
901
1264
|
};
|
|
902
1265
|
// This form already has validation and a single shared Web draft. Valid edits join that draft immediately;
|
|
903
1266
|
// Apply, its confirmations and the leave guard still own all HTTP writes. Quiet staging avoids a toast per key.
|
|
@@ -905,6 +1268,9 @@ function WebSection({ ctx, staged }) {
|
|
|
905
1268
|
// parent's already-staged Web draft. Only an actual field edit replaces or removes that draft.
|
|
906
1269
|
const formMounted = useRef(false);
|
|
907
1270
|
useEffect(() => { if (!formMounted.current) { formMounted.current = true; return; } review(true); }, [f]);
|
|
1271
|
+
const roText = (v) => (v === null ? tn("valueNone") : typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
1272
|
+
// #2347 §10: a key fleet.yaml leaves out shows the value the host uses, marked "default".
|
|
1273
|
+
const uploads = uploadMbInEffect(web);
|
|
908
1274
|
const shown = (path) => {
|
|
909
1275
|
const [top, key] = path.split(".");
|
|
910
1276
|
if (top === "web" && key === "search") {
|
|
@@ -914,72 +1280,103 @@ function WebSection({ ctx, staged }) {
|
|
|
914
1280
|
return String(days);
|
|
915
1281
|
}
|
|
916
1282
|
const v = (top === "web" ? web : term)[key];
|
|
917
|
-
|
|
1283
|
+
// An upload limit that is not what applies (out of range, or a file above the whole message's) says what does.
|
|
1284
|
+
const upload = top === "web" ? { upload_max_file_mb: uploads.file, upload_max_total_mb: uploads.total }[key] : undefined;
|
|
1285
|
+
const applies = upload === undefined ? v : upload;
|
|
1286
|
+
const capped = top === "web" && key === "upload_max_file_mb" && uploads.file < 50; // left out, only the whole message's limit lowers it
|
|
1287
|
+
if (v === undefined) return html`${roText(webReadOnlyDefault(path, ctx.fleet))} <${DefaultMark} />${capped ? html` <span class="tag">${tn("cappedByTotal")}</span>` : null}`;
|
|
1288
|
+
return applies === v ? roText(v) : html`${roText(v)} <span class="tag">${tn("inEffectShort", roText(applies))}</span>`;
|
|
918
1289
|
};
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1290
|
+
// #2347: one form, one draft, shown where each part belongs — Access & security (sessions, the browser terminal, the
|
|
1291
|
+
// public link and its fixed address, guests, web authority), Chat & connections (web echo), Web dashboard (the Log tab,
|
|
1292
|
+
// usage over the public link), Advanced (what only fleet.yaml sets).
|
|
1293
|
+
const has = (p) => part === "all" || part === p;
|
|
1294
|
+
const lock = (body) => html`<fieldset class="s-web-fields" disabled=${overPublicLink}>${body}</fieldset>`;
|
|
1295
|
+
return html`<div class=${`form s-web s-part-${part}`}>
|
|
1296
|
+
${has("access") ? html`<p class="sub">${tn("webLead")}</p>` : null}
|
|
1297
|
+
${overPublicLink && part !== "readonly" ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
|
|
1298
|
+
${has("access") ? lock(html`<section class="card"><h3>${tn("webSessionTitle")}</h3>
|
|
924
1299
|
<p class="note">${tn("webSessionHint")}</p>
|
|
925
1300
|
<div class="grid2">
|
|
926
|
-
<div class
|
|
927
|
-
class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs}
|
|
928
|
-
|
|
929
|
-
|
|
1301
|
+
<div class=${fc("field", "abs")}><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any"
|
|
1302
|
+
class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} />
|
|
1303
|
+
${value(f.abs) === null ? html`<${InEffect} text=${tn("hoursValue", 48)} />` : null}</div>
|
|
1304
|
+
<div class=${fc("field", "idle")}><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any"
|
|
1305
|
+
class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} />
|
|
1306
|
+
${value(f.idle) === null && !errors.abs ? html`<${InEffect} text=${tn("hoursValue", idle)} why=${idle < 48 ? tn("cappedBySession") : null} />` : null}</div>
|
|
930
1307
|
</div>
|
|
931
1308
|
</section>
|
|
932
1309
|
<section class="card"><h3>${tn("webTerminalTitle")}</h3>
|
|
933
1310
|
<p class="note">${tn("webTerminalHint")}</p>
|
|
934
|
-
<label class
|
|
1311
|
+
<label class=${fc("check", "termOn")}><input id="webTerminalEnabled" type="checkbox" checked=${f.termOn} onChange=${(e) => set("termOn")(e.target.checked)} /><span>${tn("webTerminalEnabled")}</span><${Impact} schema=${schema} field="web_terminal.enabled" /></label>
|
|
935
1312
|
<div class="grid2">
|
|
936
|
-
<div class
|
|
937
|
-
class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl}
|
|
1313
|
+
<div class=${fc("field", "termTtl")}><label for="webTerminalTtl">${tn("webTerminalTtl")}</label><input id="webTerminalTtl" type="number" min="1" max="20" step="1"
|
|
1314
|
+
class=${errors.termTtl ? "invalid" : ""} value=${f.termTtl} onInput=${(e) => set("termTtl")(e.target.value)} /><${Feedback} error=${errors.termTtl} />
|
|
1315
|
+
${value(f.termTtl) === null ? html`<${InEffect} text=${tn("minutesValue", 10)} />` : null}</div>
|
|
938
1316
|
</div>
|
|
939
1317
|
</section>
|
|
940
|
-
<section class="card"><h3>${tn("
|
|
941
|
-
<label class
|
|
942
|
-
<p class="note">${tn("webEchoHint")}</p>
|
|
943
|
-
<label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
|
|
944
|
-
<p class="note">${tn("webLogTabHint")}</p>
|
|
945
|
-
<label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
|
|
946
|
-
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
947
|
-
<label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
1318
|
+
<section class="card"><h3>${tn("publicLinkTitle")}</h3>
|
|
1319
|
+
<label class=${fc("check", "pub")}><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
948
1320
|
<p class="note">${tn("publicLinkHint")}</p>
|
|
949
1321
|
<div class="grid2">
|
|
950
|
-
<div class
|
|
951
|
-
<div class
|
|
1322
|
+
<div class=${fc("field", "hostname")}><label for="publicWebHostname">${tn("namedHostname")}</label><input id="publicWebHostname" type="text" value=${f.hostname} placeholder="agend.example.com" class=${errors.hostname ? "invalid" : ""} onInput=${(e) => set("hostname")(e.target.value)} /><${Feedback} error=${errors.hostname} /></div>
|
|
1323
|
+
<div class=${fc("field", "originPort")}><label for="publicWebOriginPort">${tn("namedOriginPort")}</label><input id="publicWebOriginPort" type="number" min="1024" max="65535" value=${f.originPort} class=${errors.originPort ? "invalid" : ""} onInput=${(e) => set("originPort")(e.target.value)} /><${Feedback} error=${errors.originPort} /></div>
|
|
952
1324
|
</div>
|
|
953
1325
|
<p class="note">${tn("namedTunnelHint")} <a href="https://github.com/songsid/AgEnD/blob/main/docs/named-tunnel.md" target="_blank" rel="noopener noreferrer">${tn("namedTunnelSetup")}</a></p>
|
|
954
1326
|
<div class="grid2">
|
|
955
|
-
<div class
|
|
956
|
-
<div class
|
|
1327
|
+
<div class=${fc("field", "ttl")}><label for="publicWebTtl">${tn(named ? "publicTtlNamed" : "publicTtl")}</label><input id="publicWebTtl" type="number" min=${named ? "0" : "1"} max=${named ? "10080" : "480"} step="1" class=${errors.ttl ? "invalid" : ""} value=${f.ttl} onInput=${(e) => set("ttl")(e.target.value)} /><${Feedback} error=${errors.ttl} /></div>
|
|
1328
|
+
<div class=${fc("field", "protocol")}><label for="publicWebProtocol">${tn("publicProtocol")}</label><${Select} id="publicWebProtocol" value=${f.protocol} onChange=${set("protocol")} options=${["http2", "quic", "auto"]} /></div>
|
|
957
1329
|
</div>
|
|
958
1330
|
${named ? html`<p class="note" id="publicTtlNamedHint">${tn("publicTtlNamedHint")}</p>` : null}
|
|
959
|
-
${named || f.autoOpen ? html`<label class
|
|
1331
|
+
${named || f.autoOpen ? html`<label class=${fc("check", "autoOpen")}><input id="publicAutoOpen" type="checkbox" checked=${f.autoOpen} onChange=${(e) => set("autoOpen")(e.target.checked)} /><span>${tn("publicAutoOpen")}</span><${Impact} schema=${schema} field="web.public_link.auto_open" /></label>
|
|
960
1332
|
<p class="note">${tn("publicAutoOpenHint")}</p><${Feedback} error=${errors.autoOpen} />` : null}
|
|
961
|
-
</section
|
|
962
|
-
|
|
963
|
-
${
|
|
964
|
-
|
|
1333
|
+
</section>`) : null}
|
|
1334
|
+
${has("access") && !overPublicLink ? html`<${PublicTunnelToken} /><${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />` : null}
|
|
1335
|
+
${has("chat") ? lock(html`<section class="card"><h3>${tn("webChat")}</h3>
|
|
1336
|
+
<label class=${fc("check", "echo")}><input id="webEchoToChannel" type="checkbox" checked=${f.echo} onChange=${(e) => set("echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="web.echo_to_channel" /></label>
|
|
1337
|
+
<p class="note">${tn("webEchoHint")}</p>
|
|
1338
|
+
</section>`) : null}
|
|
1339
|
+
${has("dashboard") ? lock(html`<section class="card"><h3>${tn("webDashboardTitle")}</h3>
|
|
1340
|
+
<label class=${fc("check", "log")}><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
|
|
1341
|
+
<p class="note">${tn("webLogTabHint")}</p>
|
|
1342
|
+
<label class=${fc("check", "usagePub")}><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
|
|
1343
|
+
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
1344
|
+
</section>`) : null}
|
|
1345
|
+
${has("readonly") ? html`<section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
|
|
965
1346
|
<p class="note">${tn("webReadOnlyLead")}</p>
|
|
966
1347
|
<dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
|
|
967
1348
|
<p class="note">${tn("webSearchHint")}</p>
|
|
968
|
-
</section
|
|
969
|
-
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${!ready} onClick=${() => review()}>${tn("reviewChanges")}</button>
|
|
970
|
-
${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>
|
|
1349
|
+
</section>` : null}
|
|
971
1350
|
</div>`;
|
|
972
1351
|
}
|
|
973
1352
|
|
|
974
|
-
/**
|
|
1353
|
+
/**
|
|
1354
|
+
* Developer: the fleet model as YAML or JSON, to read, copy, download — or edit. #2347: an edit's Apply stages its
|
|
1355
|
+
* writes in the Save bar like every form's change (Save writes them, in order, then one apply; Discard drops them),
|
|
1356
|
+
* and the editor keeps the edit while they wait — or comes back to it after a Save that did not land.
|
|
1357
|
+
*/
|
|
975
1358
|
function Developer({ ctx }) {
|
|
976
|
-
const [
|
|
977
|
-
const [
|
|
1359
|
+
const mine = [...ctx.staged].find(([k]) => isYamlKey(k));
|
|
1360
|
+
const held = mine && mine[1].yaml ? mine[1].yaml : null;
|
|
1361
|
+
const heldCount = [...ctx.staged.keys()].filter(isYamlKey).length;
|
|
1362
|
+
const [fmt, setFmt] = useState(held ? held.fmt : "yaml");
|
|
1363
|
+
const [editing, setEditing] = useState(!!held);
|
|
978
1364
|
const model = fleetModel(ctx.fleet);
|
|
979
1365
|
const text = fmt === "yaml" ? toYaml(model) : JSON.stringify(model, null, 2);
|
|
980
|
-
const [draft, setDraft] = useState(text);
|
|
1366
|
+
const [draft, setDraft] = useState(held ? held.text : text);
|
|
981
1367
|
const [msg, setMsg] = useState(null);
|
|
982
1368
|
useEffect(() => { if (!editing) setDraft(text); }, [text, editing]);
|
|
1369
|
+
// The edit as staged: one that goes (its Save began, or Discard) leaves the editor on what is in force; one that comes
|
|
1370
|
+
// back (a Save that did not land all of it) is the editor's text again.
|
|
1371
|
+
const heldText = held ? held.text : null;
|
|
1372
|
+
const was = useRef(heldText);
|
|
1373
|
+
useEffect(() => {
|
|
1374
|
+
if (heldText !== null && heldText !== was.current) { setFmt(held.fmt); setDraft(heldText); setEditing(true); }
|
|
1375
|
+
else if (heldText === null && was.current !== null) { setEditing(false); setMsg(null); }
|
|
1376
|
+
was.current = heldText;
|
|
1377
|
+
}, [heldText]);
|
|
1378
|
+
// The forms' own unsaved changes to the same settings come first: the editor is for reading until they are saved.
|
|
1379
|
+
const blocked = formsStaged(ctx.staged);
|
|
983
1380
|
const copy = () => { try { navigator.clipboard.writeText(text).then(() => setMsg({ text: tn("copied") }), () => setMsg({ error: tn("failed") })); } catch { setMsg({ error: tn("failed") }); } };
|
|
984
1381
|
const download = () => {
|
|
985
1382
|
const a = document.createElement("a");
|
|
@@ -990,38 +1387,56 @@ function Developer({ ctx }) {
|
|
|
990
1387
|
let parsed;
|
|
991
1388
|
try { parsed = fmt === "yaml" ? fromYaml(draft) : JSON.parse(draft || "{}"); } catch (e) { setMsg({ error: e.message }); return; }
|
|
992
1389
|
if (!parsed || typeof parsed !== "object") { setMsg({ error: tn("expectedMapping") }); return; }
|
|
1390
|
+
if (formsStaged(ctx.staged)) { setMsg({ error: tn("devFormsUnsaved") }); return; }
|
|
993
1391
|
const requests = fullModelRequests(parsed, ctx.fleet);
|
|
1392
|
+
// An Apply again replaces the edit staged before: what it writes is this text's difference from what is in force.
|
|
1393
|
+
for (const k of ctx.staged.keys()) if (isYamlKey(k)) ctx.unstage(k);
|
|
1394
|
+
was.current = null; // its own Apply: not an edit that went (the editor stays on it)
|
|
994
1395
|
if (!requests.length) { setMsg({ text: tn("noChanges") }); return; }
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1396
|
+
const yaml = { fmt, text: draft };
|
|
1397
|
+
let staged = 0;
|
|
1398
|
+
for (const [i, r] of requests.entries()) {
|
|
1399
|
+
if (ctx.stage(`${YAML_KEY}${i}`, { label: `${r.method} ${decodeURIComponent(r.url.replace("/api/settings/", ""))}`, impact: requestImpact(ctx.schema, r), request: r, yaml }, true)) staged++;
|
|
999
1400
|
}
|
|
1401
|
+
setMsg(staged ? { text: tn("devStaged", staged) } : null);
|
|
1000
1402
|
};
|
|
1001
1403
|
return html`<section class="card s-dev">
|
|
1002
1404
|
<div class="s-dev-bar">
|
|
1003
1405
|
<div class="seg-inline" role="group" aria-label=${tn("format")}>
|
|
1004
1406
|
${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
|
|
1005
|
-
<button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
|
|
1407
|
+
<button type="button" class="btn btn-sm" disabled=${blocked && !editing} title=${blocked && !editing ? tn("devFormsUnsaved") : undefined} onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
|
|
1006
1408
|
<button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
|
|
1007
1409
|
<button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
|
|
1008
1410
|
</div>
|
|
1009
1411
|
${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
|
|
1010
|
-
<div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
|
|
1412
|
+
<div class="save-row"><button type="button" class="btn btn-primary" disabled=${blocked} onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
|
|
1011
1413
|
: html`<pre class="s-yaml">${text}</pre>`}
|
|
1414
|
+
${blocked ? html`<p class="note s-dev-blocked" role="status">${tn("devFormsUnsaved")}</p>`
|
|
1415
|
+
: held && !msg ? html`<p class="note s-dev-held" role="status">${tn("devStaged", heldCount)}</p>` : null}
|
|
1012
1416
|
${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
|
|
1013
1417
|
</section>`;
|
|
1014
1418
|
}
|
|
1015
1419
|
|
|
1016
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1420
|
+
/**
|
|
1421
|
+
* #2347: the section's Save. Shown while anything is unsaved: how many changes, what they will cost, Discard and Save —
|
|
1422
|
+
* Save waits while a field is invalid ("Fix 1 field to save").
|
|
1423
|
+
*/
|
|
1424
|
+
function PendingBar({ staged, busy, onApply, onDiscard, section }) {
|
|
1425
|
+
const changes = [...staged.values()];
|
|
1426
|
+
const kinds = [...new Set(changes.filter((c) => !c.invalid).map((c) => c.impact))];
|
|
1427
|
+
const invalid = changes.filter((c) => c.invalid).length;
|
|
1428
|
+
// #2382 review: a field to fix in another section is named, and the message goes there (its field focused).
|
|
1429
|
+
const elsewhere = changes.find((c) => c.invalid && c.section && c.section !== section);
|
|
1430
|
+
const count = changes.reduce((n, c) => n + (Number.isInteger(c.count) && c.count > 0 ? c.count : 1), 0);
|
|
1019
1431
|
return html`<div data-trust="panel-controls" class="s-pending" role="region" aria-label=${tn("pendingRegion")}>
|
|
1020
|
-
<strong>${tn("changesPending",
|
|
1432
|
+
<strong>${tn("changesPending", count)}</strong>
|
|
1021
1433
|
<span class="note">${kinds.map(impactText).join(" · ")}</span>
|
|
1022
|
-
<span class="
|
|
1023
|
-
|
|
1024
|
-
|
|
1434
|
+
<span class="s-pending-actions">
|
|
1435
|
+
${!invalid ? null : elsewhere ? html`<button type="button" class="link-btn feedback error s-fix" role="status" onClick=${() => goToTarget(elsewhere.section, elsewhere.target)}>${tn("fixToSaveIn", invalid, tn(`sec_${elsewhere.section}`))}</button>`
|
|
1436
|
+
: html`<span class="feedback error s-fix" role="status">${tn("fixToSave", invalid)}</span>`}
|
|
1437
|
+
<button type="button" class="btn" onClick=${onDiscard} disabled=${busy}>${tn("discard")}</button>
|
|
1438
|
+
<button type="button" class="btn btn-primary" onClick=${onApply} disabled=${busy || invalid > 0} title=${busy ? tn("applyBusyLocal") : undefined}>${tn("applyChanges")}</button>
|
|
1439
|
+
</span>
|
|
1025
1440
|
</div>`;
|
|
1026
1441
|
}
|
|
1027
1442
|
|