@jxsuite/studio 2.4.3 → 4.0.0
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/README.md +5 -5
- package/dist/chunks/Overlay.dev-723bv0df.js +23 -0
- package/dist/chunks/cf-settings-45fgyx8y.js +21 -0
- package/dist/chunks/{components-z6cnkyy1.js → components-zmx10t62.js} +6 -5
- package/dist/chunks/content-source-t88vb2dr.js +49 -0
- package/dist/chunks/{convert-to-repeater-k10dvcc7.js → convert-to-repeater-dhw1nrz9.js} +27 -18
- package/dist/chunks/entry-commands-6z6bhxg0.js +46 -0
- package/dist/chunks/{extension-sections-0ada0xcw.js → extension-sections-3ydgjbdy.js} +32 -23
- package/dist/chunks/file-ops-rskmhxyq.js +48 -0
- package/dist/chunks/files-0cratsr0.js +92 -0
- package/dist/chunks/{format-host-wewba4nq.js → format-host-tgcmfs4p.js} +3 -3
- package/dist/chunks/fs-events-hrrg328g.js +21 -0
- package/dist/chunks/{git-panel-2dh1aw50.js → git-panel-0yywyk76.js} +21 -15
- package/dist/chunks/{jx-validate-jj9rnt4n.js → jx-validate-8q2g05x1.js} +6 -4
- package/dist/chunks/layers-panel-z3tqe5cd.js +67 -0
- package/dist/chunks/layers-q75tj2mf.js +45 -0
- package/dist/chunks/lit-html-j055mkh8.js +22 -0
- package/dist/chunks/monaco-buffer-dqw0xxpx.js +25 -0
- package/dist/chunks/{monaco-setup-a5wkmbgh.js → monaco-setup-rws7k4br.js} +4 -4
- package/dist/chunks/notify-yjm79n4s.js +30 -0
- package/dist/chunks/parse-24d0gay3.js +19 -0
- package/dist/chunks/platform-ba1j1gbh.js +16 -0
- package/dist/chunks/publish-panel-54jvhskq.js +28 -0
- package/dist/chunks/quick-search-cgfmjwq5.js +65 -0
- package/dist/chunks/references-3qx8fkyp.js +29 -0
- package/dist/chunks/right-panel-cqp8bc4v.js +46 -0
- package/dist/chunks/sp-overlay.dev-yztmtrs1.js +18 -0
- package/dist/chunks/sp-popover.dev-n2etmqdp.js +9 -0
- package/dist/chunks/sp-progress-circle.dev-hryhj0es.js +15 -0
- package/dist/chunks/sp-tooltip.dev-xcvpnqr0.js +15 -0
- package/dist/chunks/{sp-tray.dev-64v4pxw8.js → sp-tray.dev-tkbscm06.js} +5 -5
- package/dist/chunks/{studio-f5yyvqxv.js → studio-1hbqnt19.js} +37 -31
- package/dist/chunks/studio-2zkz8evn.js +3163 -0
- package/dist/chunks/{studio-0b6fs05q.js → studio-4pqqxbrt.js} +23 -15
- package/dist/chunks/{studio-kvfx9z2c.js → studio-4rzyrtjz.js} +70 -31
- package/dist/chunks/{studio-ef8t53vc.js → studio-5q69gfag.js} +60 -56
- package/dist/chunks/{studio-v2c5pmjj.js → studio-5rywrkxz.js} +565 -133
- package/dist/chunks/{studio-6kv3apa9.js → studio-68n67094.js} +25 -9
- package/dist/chunks/{studio-0rqqhrdy.js → studio-7nte2hj4.js} +2 -2
- package/dist/chunks/{studio-hyn3vhwr.js → studio-8swrjbny.js} +31 -18
- package/dist/chunks/studio-92ab2jqe.js +282 -0
- package/dist/chunks/{studio-sas7mt3k.js → studio-9ab8gqsm.js} +2 -2
- package/dist/chunks/{studio-28xc8ws4.js → studio-9c4k4vhv.js} +14 -10
- package/dist/chunks/studio-9n4mrmnb.js +184 -0
- package/dist/chunks/{studio-tr20m8g8.js → studio-9r6j5ev1.js} +37 -2
- package/dist/chunks/{studio-aweyr4v1.js → studio-ahjfdy6e.js} +9 -7
- package/dist/chunks/{studio-s519zc99.js → studio-bgxt93ga.js} +3 -3
- package/dist/chunks/{studio-26sv44br.js → studio-cb12yz9w.js} +8543 -8784
- package/dist/chunks/studio-cft9r4r7.js +119 -0
- package/dist/chunks/{studio-rqemfhz6.js → studio-eenfkt4e.js} +6 -6
- package/dist/chunks/studio-eesa1myv.js +498 -0
- package/dist/chunks/{studio-ay8wcr2b.js → studio-eqq1w638.js} +7 -9
- package/dist/chunks/{studio-ke244pam.js → studio-ex8zyp98.js} +51 -25
- package/dist/chunks/studio-gv8vvshc.js +118 -0
- package/dist/chunks/studio-hbtp0eh1.js +1315 -0
- package/dist/chunks/{studio-cz2j1c0g.js → studio-htsf0w6m.js} +75 -123
- package/dist/chunks/{studio-pyrw8h0w.js → studio-kjjsjak4.js} +528 -212
- package/dist/chunks/{studio-5x5774ak.js → studio-kryvsw6j.js} +3 -3
- package/dist/chunks/studio-kyh9avvv.js +28 -0
- package/dist/chunks/{studio-jnakhg87.js → studio-kyk2v92a.js} +14 -14
- package/dist/chunks/{studio-twsafr1e.js → studio-m3hwn7y1.js} +3 -3
- package/dist/chunks/{studio-tzx3xmvx.js → studio-mmv72a27.js} +8 -8
- package/dist/chunks/{studio-vhk5fmfv.js → studio-n8b9wqwy.js} +5 -5
- package/dist/chunks/{studio-cjm7fdjf.js → studio-p4ngc046.js} +8 -104
- package/dist/chunks/{studio-sccge2wf.js → studio-p8j86wag.js} +2 -2
- package/dist/chunks/{studio-v1va0vhd.js → studio-pdfjntbk.js} +75 -139
- package/dist/chunks/studio-q43a5y9h.js +394 -0
- package/dist/chunks/studio-q7v10cg2.js +106 -0
- package/dist/chunks/{studio-2w11grav.js → studio-qq6jym04.js} +3 -3
- package/dist/chunks/studio-r1r7afxx.js +1066 -0
- package/dist/chunks/{studio-3fk1ac9z.js → studio-rdx8jcxy.js} +4 -4
- package/dist/chunks/{studio-4mqb9a0j.js → studio-rn9ta2mz.js} +4 -4
- package/dist/chunks/studio-sf7zjgv1.js +91 -0
- package/dist/chunks/studio-t7qb9db8.js +665 -0
- package/dist/chunks/studio-tmc5emrw.js +8 -0
- package/dist/chunks/{studio-wdt9y92a.js → studio-txwkm6p9.js} +2 -2
- package/dist/chunks/{studio-dgj7gq44.js → studio-v38hmvx1.js} +7 -7
- package/dist/chunks/{studio-7ejemhej.js → studio-vadzgkrz.js} +6 -6
- package/dist/chunks/studio-wcy8trfx.js +263 -0
- package/dist/chunks/{studio-hb7c46jy.js → studio-wx5609xd.js} +21 -15
- package/dist/chunks/studio-xrapxwwc.js +1539 -0
- package/dist/chunks/{studio-ws6ef6js.js → studio-yxrkyv8a.js} +41 -29
- package/dist/chunks/{studio-xm3dbxvn.js → studio-zczps70q.js} +2 -2
- package/dist/chunks/workspace-xpres40v.js +89 -0
- package/dist/iframe-entry.js +450 -48
- package/dist/studio.js +2455 -1761
- package/package.json +16 -15
- package/src/browse/library-layouts.ts +3 -3
- package/src/browse/library-pane.ts +59 -5
- package/src/browse/library-preview.ts +19 -3
- package/src/canvas/asset-refs.ts +119 -0
- package/src/canvas/asset-resolve.ts +256 -0
- package/src/canvas/canvas-live-render.ts +23 -15
- package/src/canvas/canvas-render.ts +23 -1
- package/src/canvas/iframe-entry.ts +1 -0
- package/src/canvas/iframe-host.ts +4 -0
- package/src/canvas/iframe-patch.ts +47 -21
- package/src/canvas/iframe-protocol.ts +9 -0
- package/src/canvas/iframe-render.ts +50 -16
- package/src/commands/app-commands.ts +4 -0
- package/src/commands/command-args.ts +29 -0
- package/src/commands/context.ts +15 -1
- package/src/commands/defaults.ts +29 -1
- package/src/commands/levels.ts +16 -0
- package/src/commands/live-context.ts +3 -0
- package/src/content/collection-match.ts +267 -0
- package/src/content/entry-commands.ts +38 -29
- package/src/content/entry-fields.ts +6 -0
- package/src/editor/file-drop-action.ts +2 -1
- package/src/editor/shortcuts.ts +5 -0
- package/src/files/file-ops.ts +10 -42
- package/src/files/files.ts +487 -61
- package/src/files/fs-events.ts +9 -0
- package/src/files/gitignore.ts +432 -0
- package/src/files/media-paths.ts +37 -14
- package/src/files/media-upload.ts +47 -6
- package/src/files/serialize-document.ts +60 -0
- package/src/format/convert-file.ts +511 -0
- package/src/format/format-choices.ts +199 -0
- package/src/grid/grid-idle.ts +45 -0
- package/src/grid/grid-view.ts +35 -0
- package/src/grid/schema-columns.ts +2 -1
- package/src/grid/sources/content-source.ts +24 -3
- package/src/media/media-open.ts +58 -0
- package/src/media/media-pane.ts +310 -0
- package/src/new-project/import-tab.ts +333 -160
- package/src/new-project/new-project-modal.ts +27 -34
- package/src/packages/ensure-deps.ts +14 -7
- package/src/packages/jxsuite-update.ts +11 -11
- package/src/page-params.ts +10 -46
- package/src/panels/activity-bar.ts +45 -16
- package/src/panels/ai-chat/attached-context.ts +6 -1
- package/src/panels/ai-chat/chat-view.ts +255 -13
- package/src/panels/ai-chat/composer.ts +21 -80
- package/src/panels/ai-panel.ts +116 -2
- package/src/panels/frontmatter-fields.ts +6 -2
- package/src/panels/navigator-panels.ts +7 -6
- package/src/panels/properties-panel.ts +8 -2
- package/src/panels/seo-modal.ts +5 -6
- package/src/panels/settings-menu.ts +799 -0
- package/src/panels/signals-panel.ts +2 -1
- package/src/panels/toolbar.ts +135 -15
- package/src/platforms/cloud.ts +34 -3
- package/src/platforms/devserver.ts +72 -34
- package/src/preview/preview-overlay.ts +226 -0
- package/src/services/ai-ask.ts +247 -0
- package/src/services/ai-import-tools.ts +494 -0
- package/src/services/ai-models.ts +101 -11
- package/src/services/ai-project-tools.ts +20 -26
- package/src/services/ai-settings.ts +69 -71
- package/src/services/ai-system-prompt.ts +88 -2
- package/src/services/ai-turn-signal.ts +70 -0
- package/src/services/cf-settings.ts +19 -37
- package/src/services/connection.ts +74 -0
- package/src/services/context-manager.ts +89 -0
- package/src/services/document-assistant.ts +49 -7
- package/src/services/idle.ts +6 -1
- package/src/services/import-client.ts +33 -6
- package/src/services/import-run.ts +158 -0
- package/src/services/import-seed.ts +120 -0
- package/src/services/jx-validate.ts +58 -6
- package/src/services/project-adoption.ts +71 -0
- package/src/services/references.ts +0 -0
- package/src/services/settings/definitions.ts +181 -0
- package/src/services/settings/kernel.ts +335 -0
- package/src/services/settings/write-queue.ts +116 -0
- package/src/services/tool-executor.ts +34 -2
- package/src/settings/contexts-section.ts +45 -3
- package/src/settings/dependencies-editor.ts +43 -23
- package/src/settings/general-settings.ts +30 -12
- package/src/settings/preferences-accounts.ts +6 -19
- package/src/settings/preferences-dialog.ts +18 -41
- package/src/settings/preferences-keymap.ts +8 -18
- package/src/settings/preferences-sections.ts +57 -0
- package/src/settings/schema-field-ui.ts +2 -2
- package/src/settings/settings-document.ts +65 -9
- package/src/shell.ts +6 -14
- package/src/studio.ts +65 -7
- package/src/tabs/transact.ts +42 -19
- package/src/types.ts +164 -8
- package/src/ui/ai-credentials-form.ts +52 -27
- package/src/ui/ai-model-picker.ts +162 -0
- package/src/ui/choice-field.ts +72 -0
- package/src/ui/layers.ts +83 -5
- package/src/ui/media-picker.ts +6 -11
- package/src/utils/studio-utils.ts +43 -31
- package/styles/forced-colors.css +7 -0
- package/styles/overlays.css +256 -0
- package/styles/panels.css +13 -1
- package/styles/shell.css +111 -0
- package/dist/chunks/Overlay.dev-d2ajy6sj.js +0 -23
- package/dist/chunks/cf-settings-g5fbf2zp.js +0 -17
- package/dist/chunks/content-source-k9724vcv.js +0 -39
- package/dist/chunks/files-yew27sn9.js +0 -69
- package/dist/chunks/layers-panel-qnsghwwd.js +0 -59
- package/dist/chunks/publish-panel-a0pdft5w.js +0 -21
- package/dist/chunks/quick-search-9avyc4pc.js +0 -54
- package/dist/chunks/right-panel-kcnhtc5j.js +0 -38
- package/dist/chunks/sp-overlay.dev-s18hymn9.js +0 -18
- package/dist/chunks/sp-popover.dev-dj9x7e7a.js +0 -9
- package/dist/chunks/sp-progress-circle.dev-0zxcyz3r.js +0 -15
- package/dist/chunks/sp-tooltip.dev-5c6d1kjz.js +0 -15
- package/dist/chunks/studio-a7pzveq1.js +0 -110
- package/dist/chunks/studio-c31yb4t6.js +0 -55
- package/dist/chunks/studio-ra7ar50f.js +0 -6682
- package/src/canvas/content-assets.ts +0 -263
- package/src/canvas/site-style-css.ts +0 -121
- package/src/services/settings-store.ts +0 -99
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The settings kernel — one owner for every application setting's value.
|
|
4
|
+
*
|
|
5
|
+
* **The model.** The backend store is authoritative, `localStorage` is a synchronous cache of it,
|
|
6
|
+
* and every write is a per-key patch applied by whoever owns the file. What this replaces collapsed
|
|
7
|
+
* all three roles: `localStorage` was the truth, and it was mirrored to disk by whole-map replaces
|
|
8
|
+
* computed from whichever window happened to write last. On the chromium launcher — where every
|
|
9
|
+
* window is its own process with its own browser profile, and so its own `localStorage` — a window
|
|
10
|
+
* holding no settings could therefore overwrite the shared file with an almost empty map. Nothing
|
|
11
|
+
* told the other window it had happened.
|
|
12
|
+
*
|
|
13
|
+
* **Why a Map and not just localStorage.** Reads must be synchronous: the chrome theme is read
|
|
14
|
+
* before the first paint (`shell.ts` says so explicitly — a mismatch flashes the shell), and every
|
|
15
|
+
* credential gate reads on the render path. But the authoritative copy arrives asynchronously. An
|
|
16
|
+
* in-memory Map seeded from the cache at module evaluation gives both: correct on the first frame,
|
|
17
|
+
* and reconciled by {@link hydrateSettings} the moment the backend answers.
|
|
18
|
+
*
|
|
19
|
+
* **The one deletion path.** {@link setSetting} with `""` STORES an empty string;
|
|
20
|
+
* {@link clearSetting} is the only thing that removes. Blank used to mean delete, which read as a
|
|
21
|
+
* convenience until a form that blanked its own drafts on save called the setters with them — and
|
|
22
|
+
* pressing Save a second time revoked the credentials the first press had stored. Making blank
|
|
23
|
+
* unrepresentable as a deletion is what stops that whole class, rather than fixing the one caller.
|
|
24
|
+
*
|
|
25
|
+
* @license MIT
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { getPlatform, hasPlatform } from "../../platform";
|
|
29
|
+
import { notify } from "../notify";
|
|
30
|
+
import { ALL_SETTINGS, USER_SETTINGS } from "./definitions";
|
|
31
|
+
import type { SettingDefinition } from "./definitions";
|
|
32
|
+
import { createWriteQueue } from "./write-queue";
|
|
33
|
+
import type { SettingsPatch } from "./write-queue";
|
|
34
|
+
|
|
35
|
+
/** The synchronous source of truth. Absent key means unset — which is not the same as `""`. */
|
|
36
|
+
const values = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
/** Mirror a value into the cache, so the next boot is correct before the backend answers. */
|
|
39
|
+
function writeCache(key: string, value: string | null): void {
|
|
40
|
+
try {
|
|
41
|
+
if (value === null) {
|
|
42
|
+
globalThis.localStorage?.removeItem(key);
|
|
43
|
+
} else {
|
|
44
|
+
globalThis.localStorage?.setItem(key, value);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
/* Storage unavailable (private mode) — the Map still serves this session. */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Seed from the cache, at module evaluation and before anything can read.
|
|
53
|
+
*
|
|
54
|
+
* This is what makes the first frame correct: the chrome theme is read before the first paint, and
|
|
55
|
+
* `shell.ts` records that a mismatch with the theme `index.html` hard-codes flashes the shell. The
|
|
56
|
+
* backend's copy arrives later and reconciles through {@link hydrateSettings}.
|
|
57
|
+
*
|
|
58
|
+
* One `try` around the whole loop rather than one per key — storage that throws throws for all of
|
|
59
|
+
* them, and a private-mode window simply starts empty.
|
|
60
|
+
*/
|
|
61
|
+
try {
|
|
62
|
+
for (const definition of ALL_SETTINGS) {
|
|
63
|
+
const cached = globalThis.localStorage?.getItem(definition.key);
|
|
64
|
+
if (cached !== null && cached !== undefined) {
|
|
65
|
+
values.set(definition.key, cached);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
/* Storage unavailable — the map starts empty, and hydration fills it. */
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Reading ──────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The value the user actually chose — the ONLY read. A default never stands in for silence here.
|
|
76
|
+
*
|
|
77
|
+
* There is deliberately no default-masking sibling. One existed, and every bug in this area came
|
|
78
|
+
* from a caller taking the masked value for a chosen one: a form prefilled with `"gpt-4o"` and
|
|
79
|
+
* saved it, so an install whose owner had configured a different provider was left holding
|
|
80
|
+
* `jx.ai.model: "gpt-4o"` and nothing else. A consumer that needs a fallback composes it where the
|
|
81
|
+
* fallbacks are known — see `ai-models.ts`'s `preferredModel`, which asks the backend what it
|
|
82
|
+
* prefers before reaching for a name invented here.
|
|
83
|
+
*
|
|
84
|
+
* @returns The stored value, or "" when none is stored.
|
|
85
|
+
*/
|
|
86
|
+
export function readStoredSetting(definition: SettingDefinition): string {
|
|
87
|
+
return values.get(definition.key) ?? "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether a value is stored — as distinct from stored-and-empty, or absent-with-a-default. */
|
|
91
|
+
export function hasSetting(definition: SettingDefinition): boolean {
|
|
92
|
+
return (values.get(definition.key) ?? "") !== "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Change notification ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const listeners = new Set<(keys: readonly string[]) => void>();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Subscribe to settings changes, local or remote. Returns the unsubscribe.
|
|
101
|
+
*
|
|
102
|
+
* This is the seam that lets a surface hold no copy of a setting: the composer's model picker, the
|
|
103
|
+
* Accounts list and the New Project gates all read through the kernel and repaint on this, rather
|
|
104
|
+
* than each caching a value that another window can invalidate.
|
|
105
|
+
*/
|
|
106
|
+
export function onSettingsChanged(listener: (keys: readonly string[]) => void): () => void {
|
|
107
|
+
listeners.add(listener);
|
|
108
|
+
return () => {
|
|
109
|
+
listeners.delete(listener);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Announce the keys that moved. Never called with an empty list. */
|
|
114
|
+
function announce(keys: readonly string[]): void {
|
|
115
|
+
for (const listener of listeners) {
|
|
116
|
+
listener(keys);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Writing ──────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const queue = createWriteQueue({
|
|
123
|
+
onError(error: unknown) {
|
|
124
|
+
notify("error", "Your settings could not be saved.", {
|
|
125
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
126
|
+
key: "settings-write",
|
|
127
|
+
source: "Settings",
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
async send(patch: SettingsPatch) {
|
|
131
|
+
if (!hasPlatform()) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const platform = getPlatform();
|
|
135
|
+
if (!platform.patchSettings) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
/*
|
|
139
|
+
* Only the keys that actually moved go on the wire. A key this window never touched is one the
|
|
140
|
+
* backend leaves exactly as it found it — which is what stops a second window's settings being
|
|
141
|
+
* cleared by a window that happens to hold none of them.
|
|
142
|
+
*
|
|
143
|
+
* An empty value is sent as a REMOVAL. The store is what a fresh install reads back at boot, and
|
|
144
|
+
* `"jx.ai.model": ""` there would be a model choice rather than the absence of one.
|
|
145
|
+
*/
|
|
146
|
+
const set: Record<string, string> = {};
|
|
147
|
+
const remove: string[] = [];
|
|
148
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
149
|
+
if (value) {
|
|
150
|
+
set[key] = value;
|
|
151
|
+
} else {
|
|
152
|
+
remove.push(key);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
await platform.patchSettings({ remove, set });
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** Resolves when every settings write made so far has been sent. */
|
|
160
|
+
export function settingsSettled(): Promise<void> {
|
|
161
|
+
return queue.settled();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Apply a patch: `null` deletes, a string stores. Keys named by neither are untouched.
|
|
166
|
+
*
|
|
167
|
+
* Returns the keys that actually moved, so a no-op write announces nothing and schedules nothing.
|
|
168
|
+
*/
|
|
169
|
+
function apply(patch: SettingsPatch): string[] {
|
|
170
|
+
const moved: string[] = [];
|
|
171
|
+
for (const [key, next] of Object.entries(patch)) {
|
|
172
|
+
const before = values.get(key) ?? null;
|
|
173
|
+
if (before === next) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (next === null) {
|
|
177
|
+
values.delete(key);
|
|
178
|
+
} else {
|
|
179
|
+
values.set(key, next);
|
|
180
|
+
}
|
|
181
|
+
writeCache(key, next);
|
|
182
|
+
moved.push(key);
|
|
183
|
+
}
|
|
184
|
+
return moved;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Store a value. An empty string is a VALUE; {@link clearSetting} is the only deletion. */
|
|
188
|
+
export function setSetting(definition: SettingDefinition, value: string): void {
|
|
189
|
+
const next = definition.normalize ? definition.normalize(value) : value;
|
|
190
|
+
const moved = apply({ [definition.key]: next });
|
|
191
|
+
if (moved.length > 0) {
|
|
192
|
+
queue.enqueue({ [definition.key]: next });
|
|
193
|
+
announce(moved);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Store several settings as one change — one announcement, one write.
|
|
199
|
+
*
|
|
200
|
+
* What a form's Save should call. Three separate `setSetting`s would coalesce into one write
|
|
201
|
+
* anyway, but they would announce three times, and each intermediate announcement describes a state
|
|
202
|
+
* the user never asked for.
|
|
203
|
+
*/
|
|
204
|
+
export function setSettings(entries: readonly [SettingDefinition, string][]): void {
|
|
205
|
+
const patch: SettingsPatch = {};
|
|
206
|
+
for (const [definition, value] of entries) {
|
|
207
|
+
patch[definition.key] = definition.normalize ? definition.normalize(value) : value;
|
|
208
|
+
}
|
|
209
|
+
const moved = apply(patch);
|
|
210
|
+
if (moved.length > 0) {
|
|
211
|
+
queue.enqueue(patch);
|
|
212
|
+
announce(moved);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forget several settings as one change — the shape a Disconnect wants. */
|
|
217
|
+
export function clearSettings(definitions: readonly SettingDefinition[]): void {
|
|
218
|
+
const patch: SettingsPatch = {};
|
|
219
|
+
for (const definition of definitions) {
|
|
220
|
+
patch[definition.key] = null;
|
|
221
|
+
}
|
|
222
|
+
const moved = apply(patch);
|
|
223
|
+
if (moved.length > 0) {
|
|
224
|
+
queue.enqueue(patch);
|
|
225
|
+
announce(moved);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Hydration ────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reconcile with the backend store. Call once, after the platform is registered; a no-op on
|
|
233
|
+
* platforms without one (dev server, cloud), where the cache is all there is.
|
|
234
|
+
*
|
|
235
|
+
* Deliberately not awaited by the boot path — hydration must not block the first paint — which is
|
|
236
|
+
* safe because the Map is already serving cached values by then. Anything the backend holds wins;
|
|
237
|
+
* anything only this machine holds is pushed up once, so a user upgrading from a cache-only build
|
|
238
|
+
* does not lose what they had configured.
|
|
239
|
+
*/
|
|
240
|
+
export async function hydrateSettings(): Promise<void> {
|
|
241
|
+
if (!hasPlatform()) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const platform = getPlatform();
|
|
245
|
+
if (!platform.getSettings) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
let stored: Record<string, string>;
|
|
249
|
+
try {
|
|
250
|
+
stored = await platform.getSettings();
|
|
251
|
+
} catch {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const incoming: SettingsPatch = {};
|
|
255
|
+
let needsMigration = false;
|
|
256
|
+
for (const definition of USER_SETTINGS) {
|
|
257
|
+
const backendValue = stored[definition.key];
|
|
258
|
+
if (backendValue) {
|
|
259
|
+
incoming[definition.key] = backendValue;
|
|
260
|
+
} else if (values.get(definition.key)) {
|
|
261
|
+
needsMigration = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const moved = apply(incoming);
|
|
265
|
+
if (moved.length > 0) {
|
|
266
|
+
announce(moved);
|
|
267
|
+
}
|
|
268
|
+
if (needsMigration) {
|
|
269
|
+
/* Push this machine's cache-only values up, through the same queue as any other write so it
|
|
270
|
+
cannot race one the user is making at the same moment. Only the keys the backend lacks — a
|
|
271
|
+
patch says nothing about the rest, so hydration cannot echo back what it just read. */
|
|
272
|
+
const push: SettingsPatch = {};
|
|
273
|
+
for (const definition of USER_SETTINGS) {
|
|
274
|
+
const value = values.get(definition.key);
|
|
275
|
+
if (value && !stored[definition.key]) {
|
|
276
|
+
push[definition.key] = value;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
queue.enqueue(push);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Adopt the backend store as another window (or a hand-edit) left it.
|
|
285
|
+
*
|
|
286
|
+
* Applied and announced, but never written back: this is news, not an edit, and echoing it would
|
|
287
|
+
* have two windows writing the same value at each other. A key the store no longer holds is
|
|
288
|
+
* forgotten here too, so a Disconnect in one window reaches the others.
|
|
289
|
+
*
|
|
290
|
+
* Self-echo needs no filter. Our own write reaches us through the same watch, and by then the
|
|
291
|
+
* values already match — {@link apply} reports nothing moved, so nothing is announced.
|
|
292
|
+
*/
|
|
293
|
+
export function adoptRemoteSettings(stored: Record<string, string>): void {
|
|
294
|
+
const patch: SettingsPatch = {};
|
|
295
|
+
for (const definition of USER_SETTINGS) {
|
|
296
|
+
patch[definition.key] = stored[definition.key] ?? null;
|
|
297
|
+
}
|
|
298
|
+
const moved = apply(patch);
|
|
299
|
+
if (moved.length > 0) {
|
|
300
|
+
announce(moved);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Subscribe to the backend store, if this platform can push. A no-op where it cannot.
|
|
306
|
+
*
|
|
307
|
+
* Called once at boot. The returned unsubscribe is not used — Studio boots this module once per
|
|
308
|
+
* window and never unwinds it — so it is deliberately dropped rather than stored somewhere nothing
|
|
309
|
+
* would read it.
|
|
310
|
+
*/
|
|
311
|
+
export function watchRemoteSettings(): void {
|
|
312
|
+
if (!hasPlatform()) {
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
getPlatform().subscribeSettings?.(adoptRemoteSettings);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Drop every value this kernel holds, cache included.
|
|
320
|
+
*
|
|
321
|
+
* The `?profile=fresh` startup profile's half of the story: clearing `localStorage` alone left the
|
|
322
|
+
* Map — and the backend store — untouched, so "fresh" was not fresh. This does not write to the
|
|
323
|
+
* backend, because a clean profile must not destroy credentials that belong to the user rather than
|
|
324
|
+
* to the profile.
|
|
325
|
+
*/
|
|
326
|
+
export function resetSettings(): void {
|
|
327
|
+
const moved = [...values.keys()];
|
|
328
|
+
values.clear();
|
|
329
|
+
for (const key of moved) {
|
|
330
|
+
writeCache(key, null);
|
|
331
|
+
}
|
|
332
|
+
if (moved.length > 0) {
|
|
333
|
+
announce(moved);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A coalescing, serializing write queue.
|
|
3
|
+
*
|
|
4
|
+
* The defect it exists to make unrepresentable: saving a provider mutates three settings through
|
|
5
|
+
* three setters, and each setter used to fire its own whole-map write. Three overlapping writes went
|
|
6
|
+
* out against one file, each reading storage at a different moment, with no ordering between them —
|
|
7
|
+
* so whichever landed last won. That was observed in the wild as a settings file holding
|
|
8
|
+
* `{"jx.ai.model": "gpt-4o"}`: the SECOND of three snapshots, landing last, a state storage itself
|
|
9
|
+
* never rested in.
|
|
10
|
+
*
|
|
11
|
+
* Two rules, and they are different guarantees:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Coalesce.** Every mutation in one synchronous burst merges into a single patch, sent once
|
|
14
|
+
* the burst has finished. N setters produce one write, not N.
|
|
15
|
+
* 2. **Serialize.** At most one send is in flight. A mutation made while one is out waits for it
|
|
16
|
+
* rather than racing it, so the order the app made changes in is the order the store sees them.
|
|
17
|
+
*
|
|
18
|
+
* Generic over the transport so the kernel can hand it a platform call and a test can hand it an
|
|
19
|
+
* array. `schedule` is injectable for the same reason.
|
|
20
|
+
*
|
|
21
|
+
* @license MIT
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A set of changes to apply. `null` DELETES the key; a key named by neither map is left alone.
|
|
26
|
+
*
|
|
27
|
+
* That is the whole difference from the whole-map replace this supersedes: a writer that says
|
|
28
|
+
* nothing about a key cannot destroy it, so a window with a stale or empty view of settings can no
|
|
29
|
+
* longer clear a sibling window's.
|
|
30
|
+
*/
|
|
31
|
+
export type SettingsPatch = Record<string, string | null>;
|
|
32
|
+
|
|
33
|
+
export interface WriteQueueOptions {
|
|
34
|
+
/** Deliver one merged patch. Rejection is reported through {@link WriteQueueOptions.onError}. */
|
|
35
|
+
send: (patch: SettingsPatch) => Promise<void>;
|
|
36
|
+
/** Called with the failure and the patch that did not land. */
|
|
37
|
+
onError?: (error: unknown, patch: SettingsPatch) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Defer to the end of the current burst. Defaults to `queueMicrotask`.
|
|
40
|
+
*
|
|
41
|
+
* A microtask rather than a timer: the burst is synchronous, so the patch goes out in the same
|
|
42
|
+
* turn the user acted in, and nothing can observe a half-applied save.
|
|
43
|
+
*/
|
|
44
|
+
schedule?: (run: () => void) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WriteQueue {
|
|
48
|
+
/** Merge `patch` into the pending one, scheduling a send if none is pending. */
|
|
49
|
+
enqueue: (patch: SettingsPatch) => void;
|
|
50
|
+
/** Resolves when everything enqueued so far has been sent (or has failed and been reported). */
|
|
51
|
+
settled: () => Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a queue over `send`.
|
|
56
|
+
*
|
|
57
|
+
* @param {WriteQueueOptions} opts
|
|
58
|
+
* @returns {WriteQueue}
|
|
59
|
+
*/
|
|
60
|
+
export function createWriteQueue(opts: WriteQueueOptions): WriteQueue {
|
|
61
|
+
const schedule = opts.schedule ?? queueMicrotask;
|
|
62
|
+
|
|
63
|
+
/** Merged changes not yet handed to `send`; null when nothing is waiting. */
|
|
64
|
+
let pending: SettingsPatch | null = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The chain every send is appended to.
|
|
68
|
+
*
|
|
69
|
+
* Always resolved — each link ends in a handler — so one failed write cannot poison the writes
|
|
70
|
+
* queued behind it.
|
|
71
|
+
*/
|
|
72
|
+
let tail: Promise<void> = Promise.resolve();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolves when the most recently scheduled burst has been sent.
|
|
76
|
+
*
|
|
77
|
+
* Tracked explicitly rather than inferred from {@link tail}: a caller that enqueues and then
|
|
78
|
+
* awaits would otherwise be awaiting the tail as it stood BEFORE its own patch joined it, and
|
|
79
|
+
* whether that happens to work depends on how many turns the scheduler takes.
|
|
80
|
+
*/
|
|
81
|
+
let idle: Promise<void> = Promise.resolve();
|
|
82
|
+
|
|
83
|
+
/** Hand the merged burst to `send`, appended to the chain. */
|
|
84
|
+
function flush(): Promise<void> {
|
|
85
|
+
const patch = pending;
|
|
86
|
+
pending = null;
|
|
87
|
+
if (!patch) {
|
|
88
|
+
return tail;
|
|
89
|
+
}
|
|
90
|
+
tail = tail
|
|
91
|
+
.then(() => opts.send(patch))
|
|
92
|
+
.catch((error: unknown) => {
|
|
93
|
+
opts.onError?.(error, patch);
|
|
94
|
+
});
|
|
95
|
+
return tail;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
enqueue(patch: SettingsPatch) {
|
|
100
|
+
if (pending) {
|
|
101
|
+
Object.assign(pending, patch);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
pending = { ...patch };
|
|
105
|
+
idle = new Promise<void>((resolve) => {
|
|
106
|
+
schedule(() => {
|
|
107
|
+
void flush().then(resolve);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
settled() {
|
|
113
|
+
return idle;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -28,9 +28,24 @@ import type { ToolRegistry } from "@jxsuite/ai/tools";
|
|
|
28
28
|
import type { Tab } from "../tabs/tab";
|
|
29
29
|
import { batchTab, beginBatch, endBatch } from "../tabs/transact";
|
|
30
30
|
import { beginTurn, endTurn } from "./ai-writes";
|
|
31
|
+
import { beginToolCall, beginTurnSignal, endTurnSignal } from "./ai-turn-signal";
|
|
31
32
|
|
|
32
33
|
const MAX_ROUNDS = 5;
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Hard ceiling on rounds of every kind, so the loop terminates whatever the model does.
|
|
37
|
+
*
|
|
38
|
+
* {@link MAX_ROUNDS} bounds AUTONOMOUS work, and a round that ends by blocking on a person is the
|
|
39
|
+
* opposite of runaway — it cannot advance without them, which is the property the cap was ever a
|
|
40
|
+
* proxy for. So an interactive round does not spend that budget. This one is the backstop that
|
|
41
|
+
* keeps `runAgentLoop` provably terminating anyway; a human answering ten questions will have
|
|
42
|
+
* stopped it long before.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_TOTAL_ROUNDS = 25;
|
|
45
|
+
|
|
46
|
+
/** Tools that suspend the turn on a human rather than doing work. */
|
|
47
|
+
const INTERACTIVE_TOOLS = new Set(["ask_user"]);
|
|
48
|
+
|
|
34
49
|
interface RunAgentLoopOptions {
|
|
35
50
|
chatState: ReturnType<typeof createChatState>;
|
|
36
51
|
streamingClient: StreamingClient;
|
|
@@ -59,6 +74,11 @@ export async function runAgentLoop({
|
|
|
59
74
|
const turnId = () => chatState.messages.at(-1)?.id ?? "";
|
|
60
75
|
beginTurn(`turn:${chatState.messages.length}`);
|
|
61
76
|
|
|
77
|
+
/* The signal, published for the tools rather than passed to them: `ToolRegistry.execute` takes
|
|
78
|
+
none. Without it a tool that waits for a human never learns the turn was stopped, and the
|
|
79
|
+
`await` below is the whole loop. See services/ai-turn-signal.ts. */
|
|
80
|
+
beginTurnSignal(signal);
|
|
81
|
+
|
|
62
82
|
// Batch all tool-call mutations into a single undo step, anchored on the tab being edited.
|
|
63
83
|
if (getTab) {
|
|
64
84
|
beginBatch(getTab());
|
|
@@ -83,7 +103,9 @@ export async function runAgentLoop({
|
|
|
83
103
|
};
|
|
84
104
|
|
|
85
105
|
try {
|
|
86
|
-
|
|
106
|
+
/* Rounds that DID something, which is what MAX_ROUNDS bounds — see MAX_TOTAL_ROUNDS. */
|
|
107
|
+
let workRounds = 0;
|
|
108
|
+
for (let round = 1; round <= MAX_TOTAL_ROUNDS && workRounds < MAX_ROUNDS; round++) {
|
|
87
109
|
const messages = chatState.toMessagesArray();
|
|
88
110
|
const tools = toolRegistry.listForLLM();
|
|
89
111
|
|
|
@@ -144,8 +166,11 @@ export async function runAgentLoop({
|
|
|
144
166
|
return;
|
|
145
167
|
}
|
|
146
168
|
|
|
169
|
+
let didWork = false;
|
|
147
170
|
for (const [id, call] of toolCalls) {
|
|
148
171
|
let result;
|
|
172
|
+
// Published, not passed: `ToolRegistry.execute` takes the args and nothing else.
|
|
173
|
+
beginToolCall(id);
|
|
149
174
|
try {
|
|
150
175
|
const args = call.arguments ? (JSON.parse(call.arguments) as object) : {};
|
|
151
176
|
result = await toolRegistry.execute(call.name, args);
|
|
@@ -155,6 +180,9 @@ export async function runAgentLoop({
|
|
|
155
180
|
error: `Failed to parse arguments: ${(error as Error).message}`,
|
|
156
181
|
};
|
|
157
182
|
}
|
|
183
|
+
if (!INTERACTIVE_TOOLS.has(call.name)) {
|
|
184
|
+
didWork = true;
|
|
185
|
+
}
|
|
158
186
|
reanchorBatch();
|
|
159
187
|
if (!result.success && result.error) {
|
|
160
188
|
allErrors.push(result.error);
|
|
@@ -166,7 +194,10 @@ export async function runAgentLoop({
|
|
|
166
194
|
chatState.pushToolResultMessage(id, JSON.stringify(result));
|
|
167
195
|
}
|
|
168
196
|
|
|
169
|
-
if (
|
|
197
|
+
if (didWork) {
|
|
198
|
+
workRounds += 1;
|
|
199
|
+
}
|
|
200
|
+
if (workRounds < MAX_ROUNDS && round < MAX_TOTAL_ROUNDS) {
|
|
170
201
|
chatState.beginAssistantTurn();
|
|
171
202
|
}
|
|
172
203
|
}
|
|
@@ -196,6 +227,7 @@ export async function runAgentLoop({
|
|
|
196
227
|
}
|
|
197
228
|
chatState.setError(tail);
|
|
198
229
|
} finally {
|
|
230
|
+
endTurnSignal();
|
|
199
231
|
endBatch();
|
|
200
232
|
endTurn(turnId());
|
|
201
233
|
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* alone), parks any rejection under the control that caused it, and re-renders. §7.1's third tier:
|
|
31
31
|
* a bad value belongs at its control, not in a toast that expires.
|
|
32
32
|
*
|
|
33
|
-
* @docs studio/projects/
|
|
33
|
+
* @docs studio/projects/settings
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { html, render as litRender, nothing } from "lit-html";
|
|
@@ -140,6 +140,15 @@ type ErrorTarget = string;
|
|
|
140
140
|
*/
|
|
141
141
|
const errors = new WeakMap<HTMLElement, { target: ErrorTarget; message: string }>();
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Schema errors the file already had when this section last wrote to it.
|
|
145
|
+
*
|
|
146
|
+
* Reported at section level rather than under a control, because they belong to no control — and
|
|
147
|
+
* kept per container for the same reason {@link errors} is. Populated by the first write, since
|
|
148
|
+
* validating on every render would compile a schema for a section nobody is editing.
|
|
149
|
+
*/
|
|
150
|
+
const inheritedErrors = new WeakMap<HTMLElement, string[]>();
|
|
151
|
+
|
|
143
152
|
/** Read the current failure for a container — exported for tests and for re-render helpers. */
|
|
144
153
|
export function contextsError(container: HTMLElement): { target: string; message: string } | null {
|
|
145
154
|
return errors.get(container) ?? null;
|
|
@@ -171,12 +180,26 @@ export function renderContextsSection(container: HTMLElement): void {
|
|
|
171
180
|
* shape, and the disk can refuse the write. The predecessor surfaces (`Properties › Media` and
|
|
172
181
|
* the CSS-variables "Enable dark scheme") did `void updateSiteConfig(...)` and dropped the
|
|
173
182
|
* rejection, so a failed write read as the field snapping back for no reason.
|
|
183
|
+
*
|
|
184
|
+
* **Only the errors THIS edit introduces block it.** The candidate is the whole `project.json`,
|
|
185
|
+
* so a violation anywhere else in the file used to refuse every context edit and park the reason
|
|
186
|
+
* under whichever control had been touched — typing `1280px` into Base width reported three
|
|
187
|
+
* unevaluated-property errors about `title`, `description` and `$style`, none of which is a
|
|
188
|
+
* width. The baseline is validated first and subtracted, the same way `services/ai-tools.ts`
|
|
189
|
+
* subtracts it before blaming the model for a document it inherited. A pre-existing problem is
|
|
190
|
+
* still worth saying, so it is reported once at section level, where a whole-file problem belongs
|
|
191
|
+
* — and it is reported rather than repaired, because `project.json` is the author's file and a
|
|
192
|
+
* settings screen that silently drops keys it did not recognise is worse than one that names
|
|
193
|
+
* them.
|
|
174
194
|
*/
|
|
175
195
|
const persistMedia = async (next: Record<string, string>, target: ErrorTarget) => {
|
|
176
196
|
const candidate = { ...config, $media: next } as unknown;
|
|
177
197
|
let schemaErrors: string[] = [];
|
|
198
|
+
let inherited: string[] = [];
|
|
178
199
|
try {
|
|
179
|
-
|
|
200
|
+
inherited = await validateProjectConfig(config);
|
|
201
|
+
const found = await validateProjectConfig(candidate);
|
|
202
|
+
schemaErrors = found.filter((error) => !inherited.includes(error));
|
|
180
203
|
} catch (error) {
|
|
181
204
|
/* A validator that cannot compile must not block the edit — but it must not be silent
|
|
182
205
|
either, so the message rides the same inline slot the schema errors would have used. */
|
|
@@ -187,6 +210,7 @@ export function renderContextsSection(container: HTMLElement): void {
|
|
|
187
210
|
reject(target, schemaErrors.join("; "));
|
|
188
211
|
return;
|
|
189
212
|
}
|
|
213
|
+
inheritedErrors.set(container, inherited);
|
|
190
214
|
try {
|
|
191
215
|
await updateSiteConfig({ $media: next });
|
|
192
216
|
errors.delete(container);
|
|
@@ -199,6 +223,24 @@ export function renderContextsSection(container: HTMLElement): void {
|
|
|
199
223
|
renderContextsSection(container);
|
|
200
224
|
};
|
|
201
225
|
|
|
226
|
+
/**
|
|
227
|
+
* What is already wrong with `project.json`, said once and not blamed on a control.
|
|
228
|
+
*
|
|
229
|
+
* It does not block anything — the edit that surfaced it went through. It is here because a file
|
|
230
|
+
* that fails its own schema will keep failing it, and until this notice existed the only place
|
|
231
|
+
* that fact appeared was as an unexplained refusal of an unrelated field.
|
|
232
|
+
*/
|
|
233
|
+
const inheritedNotice = () => {
|
|
234
|
+
const inherited = inheritedErrors.get(container) ?? [];
|
|
235
|
+
return inherited.length === 0
|
|
236
|
+
? nothing
|
|
237
|
+
: html`<p class="settings-section-notice" role="status">
|
|
238
|
+
project.json has ${inherited.length} pre-existing schema
|
|
239
|
+
${inherited.length === 1 ? "problem" : "problems"} that this section did not cause:
|
|
240
|
+
${inherited.join("; ")}
|
|
241
|
+
</p>`;
|
|
242
|
+
};
|
|
243
|
+
|
|
202
244
|
/** The error line for one control, when that is where the current failure belongs. */
|
|
203
245
|
const errorFor = (target: ErrorTarget) =>
|
|
204
246
|
shown?.target === target
|
|
@@ -370,7 +412,7 @@ export function renderContextsSection(container: HTMLElement): void {
|
|
|
370
412
|
The conditions this project's pages are rendered under. Define them once here; choose
|
|
371
413
|
between them on the pane's context control while you edit.
|
|
372
414
|
</p>
|
|
373
|
-
${errorFor("section")}
|
|
415
|
+
${errorFor("section")} ${inheritedNotice()}
|
|
374
416
|
|
|
375
417
|
<div class="settings-field">
|
|
376
418
|
<label class="settings-field-label">Base width</label>
|