@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing what the author is looking at, so a live preview shows the canvas and not the disk.
|
|
3
|
+
*
|
|
4
|
+
* A live preview composes a page from the project's working TREE, and the tree is what has been
|
|
5
|
+
* saved. The document the author is actually editing lives in a tab's reactive memory and nowhere
|
|
6
|
+
* else, so without this the one thing that makes a live preview worth having — seeing an edit
|
|
7
|
+
* before it is written — would be exactly the thing it could not show.
|
|
8
|
+
*
|
|
9
|
+
* What travels is BYTES, and specifically {@link serializeDocument}'s: the same function `saveFile`
|
|
10
|
+
* writes through, grid controllers, format round-trips, frontmatter and all. That is what makes
|
|
11
|
+
* "what the reader sees" and "what saving would produce" one answer rather than two that drift. A
|
|
12
|
+
* document object would bypass the format layer, and a `.md` page's bytes are not
|
|
13
|
+
* `JSON.stringify(doc)`.
|
|
14
|
+
*
|
|
15
|
+
* **Why an effect and not `transactDoc`.** `transact.ts` has exactly one observer slot and the
|
|
16
|
+
* collab layer owns it. More importantly a transaction hook would MISS three of the most common
|
|
17
|
+
* ways a document changes: a Monaco source commit, a content-entry field edit, and every grid edit
|
|
18
|
+
* route around `transactDoc` entirely. "I edited it in Source and the preview did not change" is
|
|
19
|
+
* the bug this shape avoids.
|
|
20
|
+
*
|
|
21
|
+
* **Why one effect over the whole tab set rather than one per tab.** Reading the map tracks
|
|
22
|
+
* additions, so a tab opened later needs no registration and a tab closed needs no teardown. And
|
|
23
|
+
* the publish is a DIFF against what was last published, which means save, close, discard and undo
|
|
24
|
+
* back to clean all resolve through the same line instead of four hooks that can each be
|
|
25
|
+
* forgotten.
|
|
26
|
+
*
|
|
27
|
+
* **Armed, not always on.** Serializing dirty documents on every edit is not free, and before the
|
|
28
|
+
* author has ever opened a preview there is nothing to publish to. Nothing happens until a preview
|
|
29
|
+
* origin exists.
|
|
30
|
+
*
|
|
31
|
+
* @docs studio/interface
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { effect, effectScope } from "../reactivity";
|
|
35
|
+
import { getPlatform, hasPlatform } from "../platform";
|
|
36
|
+
import { serializeDocument } from "../files/serialize-document";
|
|
37
|
+
import { workspace } from "../workspace/workspace";
|
|
38
|
+
import type { Tab } from "../tabs/tab";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trailing debounce on a publish.
|
|
42
|
+
*
|
|
43
|
+
* No leading edge: a leading publish would send the first keystroke of a burst and the last, which
|
|
44
|
+
* is two reloads for one thought. The max wait is what keeps a continuous drag from starving the
|
|
45
|
+
* trailing timer forever.
|
|
46
|
+
*/
|
|
47
|
+
const PUBLISH_DEBOUNCE_MS = 200;
|
|
48
|
+
const PUBLISH_MAX_WAIT_MS = 500;
|
|
49
|
+
|
|
50
|
+
/** Bytes one document may publish before it is left showing its saved state instead. */
|
|
51
|
+
const MAX_ENTRY_BYTES = 2 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Path to the bytes last published for it, so identical bytes cost nothing.
|
|
55
|
+
*
|
|
56
|
+
* The TEXT rather than a digest of it. An edit that lands back on the same bytes — a character
|
|
57
|
+
* typed and deleted — must not cost the reader a reload, and comparing what was sent is exact where
|
|
58
|
+
* a hash would be a collision away from being wrong for no saving anyone can measure. Memory is
|
|
59
|
+
* bounded by the same per-entry cap the publish is.
|
|
60
|
+
*/
|
|
61
|
+
const published = new Map<string, string>();
|
|
62
|
+
|
|
63
|
+
let armed = false;
|
|
64
|
+
let scope: ReturnType<typeof effectScope> | null = null;
|
|
65
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
let burstStartedAt: number | null = null;
|
|
67
|
+
/** The publish in flight, so a flush awaits it rather than racing it. */
|
|
68
|
+
let inFlight: Promise<void> | null = null;
|
|
69
|
+
|
|
70
|
+
/** The documents whose unsaved bytes a preview needs, in the order they were opened. */
|
|
71
|
+
function dirtyTabs(): Tab[] {
|
|
72
|
+
const tabs: Tab[] = [];
|
|
73
|
+
for (const tab of workspace.tabs.values()) {
|
|
74
|
+
/* Dirty only. A clean tab's bytes are on disk by definition, and publishing them would create
|
|
75
|
+
a second source of truth for identical content. */
|
|
76
|
+
if (tab.doc.dirty && tab.documentPath) {
|
|
77
|
+
tabs.push(tab);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return tabs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Publish what changed and retract what no longer applies.
|
|
85
|
+
*
|
|
86
|
+
* A diff rather than a stream of events: whatever reason a document has for no longer being unsaved
|
|
87
|
+
* — saved, closed, discarded, undone back to its file — it is simply absent from this pass, and the
|
|
88
|
+
* retraction follows without anywhere else having to remember to ask for it.
|
|
89
|
+
*/
|
|
90
|
+
async function publish(): Promise<void> {
|
|
91
|
+
/* Not `armed` too: `flushPreviewOverlay` refuses when disarmed and `disarmPreviewOverlay` clears
|
|
92
|
+
the pending timer, so there is no path into here without it. A host with no platform at all is
|
|
93
|
+
real though — `getPlatform()` throws rather than answering null. */
|
|
94
|
+
if (!hasPlatform()) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const platform = getPlatform();
|
|
98
|
+
if (!platform.setPreviewOverlay || !platform.clearPreviewOverlay) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const live = new Set<string>();
|
|
102
|
+
for (const tab of dirtyTabs()) {
|
|
103
|
+
const path = tab.documentPath!.replace(/^\.\//, "");
|
|
104
|
+
let text: string;
|
|
105
|
+
try {
|
|
106
|
+
text = await serializeDocument(tab);
|
|
107
|
+
} catch {
|
|
108
|
+
/* A half-typed document that will not serialize is not a reason to stop previewing the layout
|
|
109
|
+
beside it. Skipped, and the next edit that does serialize publishes it. */
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (text.length > MAX_ENTRY_BYTES) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
live.add(path);
|
|
116
|
+
if (published.get(path) === text) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
published.set(path, text);
|
|
120
|
+
await platform.setPreviewOverlay(path, text);
|
|
121
|
+
}
|
|
122
|
+
for (const path of published.keys()) {
|
|
123
|
+
if (!live.has(path)) {
|
|
124
|
+
published.delete(path);
|
|
125
|
+
await platform.clearPreviewOverlay(path);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run a publish, coalescing with any already scheduled. */
|
|
131
|
+
function schedule(): void {
|
|
132
|
+
if (!armed) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
burstStartedAt ??= now;
|
|
137
|
+
if (timer) {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
const waited = now - burstStartedAt;
|
|
141
|
+
const delay = Math.max(0, Math.min(PUBLISH_DEBOUNCE_MS, PUBLISH_MAX_WAIT_MS - waited));
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
timer = null;
|
|
144
|
+
burstStartedAt = null;
|
|
145
|
+
inFlight = publish().finally(() => {
|
|
146
|
+
inFlight = null;
|
|
147
|
+
});
|
|
148
|
+
}, delay);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Note an edit the reactive effect cannot see.
|
|
153
|
+
*
|
|
154
|
+
* `transactDoc` replaces the document's ROOT REFERENCE on every transaction, and a Monaco source
|
|
155
|
+
* commit assigns a fresh one, so a shallow read catches both. A content-entry field edit writes a
|
|
156
|
+
* key in place on an already-dirty tab, which changes nothing the effect reads. This is the seam
|
|
157
|
+
* for exactly that: an explicit call from the few places that mutate without swapping.
|
|
158
|
+
*/
|
|
159
|
+
export function notePreviewOverlayEdit(): void {
|
|
160
|
+
schedule();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Start publishing, because a preview origin now exists to publish to.
|
|
165
|
+
*
|
|
166
|
+
* Idempotent: `View: Open in Browser` calls it on every invocation and only the first does work.
|
|
167
|
+
*/
|
|
168
|
+
export function armPreviewOverlay(): void {
|
|
169
|
+
if (armed) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
armed = true;
|
|
173
|
+
scope = effectScope();
|
|
174
|
+
scope.run(() => {
|
|
175
|
+
effect(() => {
|
|
176
|
+
for (const tab of workspace.tabs.values()) {
|
|
177
|
+
/* Two reads, both shallow. `document` is a fresh reference on every transaction — see
|
|
178
|
+
`transactDoc`, which assigns one specifically so effects re-run — so this costs a
|
|
179
|
+
property read rather than a walk of the document. */
|
|
180
|
+
void tab.doc.dirty;
|
|
181
|
+
void tab.doc.document;
|
|
182
|
+
}
|
|
183
|
+
schedule();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Stop publishing and forget what was published, without retracting it.
|
|
190
|
+
*
|
|
191
|
+
* Called when the window changes project. The published map is keyed by project-relative path, so
|
|
192
|
+
* carrying it across a switch would let one project's `pages/index.json` be read as the answer for
|
|
193
|
+
* another's — and the backend lets go of the old root's overlay on its own re-root anyway.
|
|
194
|
+
*/
|
|
195
|
+
export function disarmPreviewOverlay(): void {
|
|
196
|
+
armed = false;
|
|
197
|
+
if (timer) {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
timer = null;
|
|
200
|
+
}
|
|
201
|
+
burstStartedAt = null;
|
|
202
|
+
scope?.stop();
|
|
203
|
+
scope = null;
|
|
204
|
+
published.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Publish now and wait for it.
|
|
209
|
+
*
|
|
210
|
+
* Two callers, and both are moments where the debounce is exactly wrong: on the way to opening a
|
|
211
|
+
* browser tab, so the page the author is about to look at already carries their last keystroke; and
|
|
212
|
+
* after a save, so the retraction rides with the write instead of arriving a debounce later and
|
|
213
|
+
* costing a second reload.
|
|
214
|
+
*/
|
|
215
|
+
export async function flushPreviewOverlay(): Promise<void> {
|
|
216
|
+
if (!armed) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (timer) {
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
timer = null;
|
|
222
|
+
burstStartedAt = null;
|
|
223
|
+
}
|
|
224
|
+
await inFlight;
|
|
225
|
+
await publish();
|
|
226
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ai-ask.ts — the assistant's one way to stop and put a question to the author.
|
|
3
|
+
*
|
|
4
|
+
* The agent loop `await`s `toolRegistry.execute` (`services/tool-executor.ts`), so a tool that
|
|
5
|
+
* returns a pending promise suspends the turn. That is the whole mechanism: `ask_user` registers a
|
|
6
|
+
* question, hands back a promise, and the panel resolves it when the human answers. No new chat
|
|
7
|
+
* status, no pre-execution hook, and nothing in the published `@jxsuite/ai` changes —
|
|
8
|
+
* `finishStream` has already set the state to `"idle"` by the time tools run, so a suspended turn
|
|
9
|
+
* is idle by construction and this store is what "waiting" means.
|
|
10
|
+
*
|
|
11
|
+
* Two decisions are load-bearing and easy to get wrong on a rewrite:
|
|
12
|
+
*
|
|
13
|
+
* - **A skip is a SUCCESS.** "You decide" is a real answer to a question; only a stopped turn is a
|
|
14
|
+
* failure. Reporting a skip as an error would make the model apologise for having asked.
|
|
15
|
+
* - **The answer never becomes a user message.** `toMessagesArray` serialises the array verbatim
|
|
16
|
+
* and a provider requires a `tool` reply to follow its `tool_calls` request; a user turn spliced
|
|
17
|
+
* between them is a 400. The answer travels as the tool result, and the chat view renders it into
|
|
18
|
+
* the question's own chip.
|
|
19
|
+
*
|
|
20
|
+
* Exactly one question can be outstanding, because `runAgentLoop` executes a round's tool calls
|
|
21
|
+
* serially. The guard below is defensive, not load-bearing.
|
|
22
|
+
*
|
|
23
|
+
* @license MIT
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createToolDefinition, toolError, toolSuccess } from "@jxsuite/ai/tools";
|
|
27
|
+
import { reactive } from "../reactivity";
|
|
28
|
+
import { currentToolCallId, turnSignal } from "./ai-turn-signal";
|
|
29
|
+
|
|
30
|
+
import type { ToolRegistry } from "@jxsuite/ai/tools";
|
|
31
|
+
|
|
32
|
+
/** A question the assistant is waiting on. `id` is the tool-call id, so the chip and this agree. */
|
|
33
|
+
export interface PendingAsk {
|
|
34
|
+
id: string;
|
|
35
|
+
question: string;
|
|
36
|
+
/** Short choices rendered as buttons. Free text is always allowed alongside them. */
|
|
37
|
+
options: string[];
|
|
38
|
+
/** One line of why-I'm-asking, rendered under the question. */
|
|
39
|
+
context: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** How a question was settled. `answer: null` with `skipped` is the author saying "you decide". */
|
|
43
|
+
export interface AskAnswer {
|
|
44
|
+
answer: string | null;
|
|
45
|
+
skipped: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Max choices rendered as buttons; beyond this the question wants free text, not a menu. */
|
|
49
|
+
const MAX_OPTIONS = 6;
|
|
50
|
+
|
|
51
|
+
const state = reactive<{ pending: PendingAsk | null }>({ pending: null });
|
|
52
|
+
|
|
53
|
+
/** The resolver for {@link state.pending}, kept out of the reactive object (it is not render data). */
|
|
54
|
+
let settle: ((answer: AskAnswer) => void) | null = null;
|
|
55
|
+
|
|
56
|
+
/** Torn down with the question so a stopped turn does not leave a listener on a dead signal. */
|
|
57
|
+
let detachAbort: (() => void) | null = null;
|
|
58
|
+
|
|
59
|
+
function clear() {
|
|
60
|
+
state.pending = null;
|
|
61
|
+
settle = null;
|
|
62
|
+
detachAbort?.();
|
|
63
|
+
detachAbort = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The outstanding question, or null. Reactive — read it inside an `effect()` to repaint on it.
|
|
68
|
+
*
|
|
69
|
+
* @returns {PendingAsk | null}
|
|
70
|
+
*/
|
|
71
|
+
export function pendingAsk(): PendingAsk | null {
|
|
72
|
+
return state.pending;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a turn is suspended on the author. The composer's Send becomes Answer on this. */
|
|
76
|
+
export function isAwaitingAnswer(): boolean {
|
|
77
|
+
return state.pending !== null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register a question and suspend until it is settled.
|
|
82
|
+
*
|
|
83
|
+
* @param {PendingAsk} ask
|
|
84
|
+
* @returns {Promise<AskAnswer>} Resolves on an answer, a skip, or the turn being stopped (`{
|
|
85
|
+
* answer: null, skipped: false }` — the shape the tool reports as a failure).
|
|
86
|
+
*/
|
|
87
|
+
export function askUser(ask: PendingAsk): Promise<AskAnswer> {
|
|
88
|
+
return new Promise<AskAnswer>((resolve) => {
|
|
89
|
+
state.pending = ask;
|
|
90
|
+
settle = resolve;
|
|
91
|
+
|
|
92
|
+
/* The turn's signal, not a parameter: `ToolRegistry.execute` has nowhere to pass one. Without
|
|
93
|
+
this, Stop would leave the loop awaiting a promise nothing could ever resolve. */
|
|
94
|
+
const signal = turnSignal();
|
|
95
|
+
if (signal?.aborted) {
|
|
96
|
+
clear();
|
|
97
|
+
resolve({ answer: null, skipped: false });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (signal) {
|
|
101
|
+
const onAbort = () => {
|
|
102
|
+
cancelAsk();
|
|
103
|
+
};
|
|
104
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
detachAbort = () => signal.removeEventListener("abort", onAbort);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Answer the outstanding question.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {boolean} False when nothing was pending, so a caller can fall through to a normal send.
|
|
115
|
+
*/
|
|
116
|
+
export function answerAsk(text: string): boolean {
|
|
117
|
+
if (!settle) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const resolve = settle;
|
|
121
|
+
clear();
|
|
122
|
+
resolve({ answer: text, skipped: false });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decline to answer — "you decide". Settles as a success, so the turn carries on.
|
|
128
|
+
*
|
|
129
|
+
* @returns {boolean} False when nothing was pending.
|
|
130
|
+
*/
|
|
131
|
+
export function skipAsk(): boolean {
|
|
132
|
+
if (!settle) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const resolve = settle;
|
|
136
|
+
clear();
|
|
137
|
+
resolve({ answer: null, skipped: true });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Settle an outstanding question as unanswered. Called from `stop()` and from the turn's abort. */
|
|
142
|
+
export function cancelAsk(): void {
|
|
143
|
+
if (!settle) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const resolve = settle;
|
|
147
|
+
clear();
|
|
148
|
+
resolve({ answer: null, skipped: false });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Drop any outstanding question without settling a turn. For New Chat and for tests. */
|
|
152
|
+
export function resetAsk(): void {
|
|
153
|
+
cancelAsk();
|
|
154
|
+
clear();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Register `ask_user` into a tool registry.
|
|
159
|
+
*
|
|
160
|
+
* The description carries the discipline the tool needs to be worth having: a question the model
|
|
161
|
+
* could have answered itself is noise, and a question about a setting the pipeline never receives
|
|
162
|
+
* is worse than noise, because the answer cannot be honoured.
|
|
163
|
+
*
|
|
164
|
+
* @param {Pick<ToolRegistry, "register">} registry
|
|
165
|
+
*/
|
|
166
|
+
export function registerAskTool(registry: Pick<ToolRegistry, "register">): void {
|
|
167
|
+
registry.register(
|
|
168
|
+
createToolDefinition({
|
|
169
|
+
name: "ask_user",
|
|
170
|
+
description:
|
|
171
|
+
"Pause and put a question to the user; the turn waits for their reply and resumes with " +
|
|
172
|
+
"it. Use it ONLY for a judgement that is genuinely theirs — which pages matter, whether to " +
|
|
173
|
+
"keep the original design, whether a low-fidelity page is acceptable. Never ask something " +
|
|
174
|
+
"you can decide yourself or discover with another tool, and never ask about an option you " +
|
|
175
|
+
"cannot act on. One question at a time; offer options when the sensible answers are a " +
|
|
176
|
+
"short list, and the user may always reply in their own words instead.",
|
|
177
|
+
parameters: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {
|
|
180
|
+
question: {
|
|
181
|
+
type: "string",
|
|
182
|
+
description: "The question, in one sentence, addressed to the user.",
|
|
183
|
+
},
|
|
184
|
+
options: {
|
|
185
|
+
type: "array",
|
|
186
|
+
items: { type: "string" },
|
|
187
|
+
description:
|
|
188
|
+
`Up to ${MAX_OPTIONS} short answers, rendered as buttons. Omit when the answer is ` +
|
|
189
|
+
"open-ended.",
|
|
190
|
+
},
|
|
191
|
+
context: {
|
|
192
|
+
type: "string",
|
|
193
|
+
description:
|
|
194
|
+
"One line of why you are asking — what you found that made this a judgement call.",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
required: ["question"],
|
|
198
|
+
},
|
|
199
|
+
execute: async (args) => {
|
|
200
|
+
/* Everything `unknown`, and every field narrowed by `typeof` rather than by `?.`. The
|
|
201
|
+
registry's `validate()` is "a lightweight structural check, not a JSON Schema validator",
|
|
202
|
+
`strict: false` skips it entirely, and this tool may be registered into someone else's
|
|
203
|
+
registry — so an argument that is a number is a case, not an impossibility. Optional
|
|
204
|
+
chaining guards null, not type: `context?.trim()` threw on one. */
|
|
205
|
+
const { question, options, context } = args as {
|
|
206
|
+
question?: unknown;
|
|
207
|
+
options?: unknown;
|
|
208
|
+
context?: unknown;
|
|
209
|
+
};
|
|
210
|
+
if (typeof question !== "string" || !question.trim()) {
|
|
211
|
+
return toolError('Pass "question" — the question to put to the user.');
|
|
212
|
+
}
|
|
213
|
+
if (isAwaitingAnswer()) {
|
|
214
|
+
return toolError("A question is already waiting for the user.");
|
|
215
|
+
}
|
|
216
|
+
const choices = Array.isArray(options)
|
|
217
|
+
? options
|
|
218
|
+
.filter((o): o is string => typeof o === "string" && o.trim().length > 0)
|
|
219
|
+
.slice(0, MAX_OPTIONS)
|
|
220
|
+
: [];
|
|
221
|
+
|
|
222
|
+
const { answer, skipped } = await askUser({
|
|
223
|
+
context: typeof context === "string" ? context.trim() : "",
|
|
224
|
+
/* The tool-call id, so the chip the transcript draws and the question this registers are
|
|
225
|
+
the same thing. Without it a restored, permanently unanswered question would render
|
|
226
|
+
identically to the live one. */
|
|
227
|
+
id: currentToolCallId(),
|
|
228
|
+
options: choices,
|
|
229
|
+
question: question.trim(),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (skipped) {
|
|
233
|
+
/* A success: the model asked, and "you decide" is an answer. Rendering it as a failure
|
|
234
|
+
would have the model apologise for having asked a fair question. */
|
|
235
|
+
return toolSuccess(
|
|
236
|
+
{ answer: null, skipped: true },
|
|
237
|
+
"The user declined to answer; proceed with your best judgment.",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
if (answer === null) {
|
|
241
|
+
return toolError("The user did not answer — the turn was stopped.");
|
|
242
|
+
}
|
|
243
|
+
return toolSuccess({ answer, skipped: false }, `The user answered: "${answer}"`);
|
|
244
|
+
},
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
}
|