@mosetta/editor 0.0.0-stage → 0.0.28

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.
Files changed (115) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +10 -2
  4. package/mosetta.json +6 -0
  5. package/package.json +35 -3
  6. package/prefabs/workbench/prefab.json +11 -0
  7. package/prefabs/workbench/versions/1.json +120 -0
  8. package/prefabs/workbench/versions/2.json +13 -0
  9. package/prefabs/workbench/versions/3.json +19 -0
  10. package/prefabs/workbench/versions/4.json +29 -0
  11. package/prefabs/workbench/versions/5.json +29 -0
  12. package/tiles/canvas/canvas.component.tsx +2038 -0
  13. package/tiles/canvas/canvas.css +973 -0
  14. package/tiles/canvas/canvasTools.ts +66 -0
  15. package/tiles/canvas/chromeNodes.ts +35 -0
  16. package/tiles/canvas/ghosts.ts +33 -0
  17. package/tiles/canvas/insertPopover.tsx +56 -0
  18. package/tiles/canvas/popoverPlacement.ts +104 -0
  19. package/tiles/canvas/popoverSize.ts +87 -0
  20. package/tiles/canvas/providerChrome.tsx +191 -0
  21. package/tiles/canvas/scrollWatch.ts +43 -0
  22. package/tiles/canvas/settingsPopover.tsx +257 -0
  23. package/tiles/canvas/settingsTabs.ts +78 -0
  24. package/tiles/canvas/toolPanel.tsx +86 -0
  25. package/tiles/content/autoBind.ts +61 -0
  26. package/tiles/content/blockPage.css +390 -0
  27. package/tiles/content/blockPage.tsx +379 -0
  28. package/tiles/content/blockPicker.css +215 -0
  29. package/tiles/content/blockPicker.tsx +501 -0
  30. package/tiles/content/blockUpgrades.ts +145 -0
  31. package/tiles/content/blockVariants.ts +185 -0
  32. package/tiles/content/blockVocabulary.ts +77 -0
  33. package/tiles/content/catalogVersions.ts +106 -0
  34. package/tiles/content/componentPreview.css +82 -0
  35. package/tiles/content/componentPreview.tsx +249 -0
  36. package/tiles/content/content.server.ts +593 -0
  37. package/tiles/content/contentCommon.ts +133 -0
  38. package/tiles/content/disconnected.ts +56 -0
  39. package/tiles/content/distributions.css +256 -0
  40. package/tiles/content/distributions.tsx +271 -0
  41. package/tiles/content/editingAutosave.ts +125 -0
  42. package/tiles/content/editingClipboard.ts +185 -0
  43. package/tiles/content/editingConflicts.ts +223 -0
  44. package/tiles/content/editingHotkeys.ts +59 -0
  45. package/tiles/content/editingNotices.ts +105 -0
  46. package/tiles/content/formRoot.ts +18 -0
  47. package/tiles/content/fuzzySearch.ts +121 -0
  48. package/tiles/content/mediaUpload.ts +55 -0
  49. package/tiles/content/moduleSiblings.ts +9 -0
  50. package/tiles/content/mountedTree.ts +136 -0
  51. package/tiles/content/nodeSettings.css +483 -0
  52. package/tiles/content/nodeSettings.tsx +963 -0
  53. package/tiles/content/pasteText.ts +131 -0
  54. package/tiles/content/prefabFields.tsx +158 -0
  55. package/tiles/content/prefabParts.ts +105 -0
  56. package/tiles/content/previewEnvironment.ts +46 -0
  57. package/tiles/content/projectConfig.ts +46 -0
  58. package/tiles/content/projectPrefabs.ts +57 -0
  59. package/tiles/content/publishModal.css +109 -0
  60. package/tiles/content/publishModal.tsx +92 -0
  61. package/tiles/content/refsEditing.ts +412 -0
  62. package/tiles/content/scaledStage.ts +63 -0
  63. package/tiles/content/searchField.css +32 -0
  64. package/tiles/content/searchField.tsx +32 -0
  65. package/tiles/content/settingsDraft.ts +178 -0
  66. package/tiles/content/settingsIssues.tsx +124 -0
  67. package/tiles/content/themeScope.ts +72 -0
  68. package/tiles/content/themeVariables.ts +47 -0
  69. package/tiles/content/tooltipPlacement.ts +17 -0
  70. package/tiles/content/tooltips.tsx +131 -0
  71. package/tiles/content/treeMounter.ts +33 -0
  72. package/tiles/content/updatesModal.css +124 -0
  73. package/tiles/content/updatesModal.tsx +110 -0
  74. package/tiles/content/useCtrlHeld.ts +24 -0
  75. package/tiles/content/useEditingSession.ts +115 -0
  76. package/tiles/content/useMountedTree.ts +47 -0
  77. package/tiles/content/workbenchScale.css +27 -0
  78. package/tiles/hostFallback/hostFallback.component.tsx +32 -0
  79. package/tiles/hostFallback/hostFallback.css +12 -0
  80. package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
  81. package/tiles/page/page.component.tsx +7 -0
  82. package/tiles/page/page.script.tsx +5 -0
  83. package/tiles/pageModules/pageModules.component.tsx +740 -0
  84. package/tiles/pageModules/pageModules.css +383 -0
  85. package/tiles/pageModules/pageModules.icon.svg +1 -0
  86. package/tiles/pageModules/servedPage.ts +465 -0
  87. package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
  88. package/tiles/pagesPanel/pagesPanel.css +713 -0
  89. package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
  90. package/tiles/pagesPanel/pagesSearch.ts +194 -0
  91. package/tiles/pagesPanel/routeTree.ts +98 -0
  92. package/tiles/previewBridge/landing.ts +66 -0
  93. package/tiles/previewBridge/previewAgent.ts +457 -0
  94. package/tiles/previewBridge/previewBridge.component.tsx +137 -0
  95. package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
  96. package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
  97. package/tiles/previewBridge/standIn.ts +22 -0
  98. package/tiles/treeView/treeView.component.tsx +514 -0
  99. package/tiles/treeView/treeView.css +303 -0
  100. package/tiles/treeView/treeView.editor.tsx +12 -0
  101. package/tiles/workbench/assistant.css +401 -0
  102. package/tiles/workbench/assistant.tsx +516 -0
  103. package/tiles/workbench/assistantChat.ts +165 -0
  104. package/tiles/workbench/dictation.ts +354 -0
  105. package/tiles/workbench/projectInfo.ts +72 -0
  106. package/tiles/workbench/recent.ts +67 -0
  107. package/tiles/workbench/workbench.component.tsx +476 -0
  108. package/tiles/workbench/workbench.css +376 -0
  109. package/tiles/workbench/workbenchAddress.ts +202 -0
  110. package/tiles/workbench/workspace.ts +775 -0
  111. package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
  112. package/tiles/workbenchEditor/workbenchEditor.css +370 -0
  113. package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
  114. package/tiles/workbenchHome/workbenchHome.css +271 -0
  115. package/tsconfig.json +3 -0
@@ -0,0 +1,516 @@
1
+ // The ASSISTANT of the workbench: an island over it where the person says what they want, and
2
+ // the platform's assistant does it with the editor's own content tools — as this person, on this
3
+ // site. Where it floats depends on the page: at the bottom over Home and the lists, over the tree
4
+ // in the editor — never a column taking the room. The workbench follows it: a block it is about to
5
+ // change is selected in the canvas, a draft it changed is read again into every canvas holding it
6
+ // (unless the person has unsaved work there) with the block it touched selected, a draft it opens,
7
+ // makes or changes elsewhere is opened, the lists are read anew. While it works the workbench is
8
+ // frozen: edits made under it would only collide with its own. What the person sees — the page,
9
+ // the draft, the block selected — goes with every message.
10
+ import { useComponent, useDi } from '@mosetta/react';
11
+ import { DI_CONTAINER_TOKEN } from '@mosetta/di';
12
+ import { editingSessionHubOf, ensureEditingSession } from '@mosetta/content';
13
+ import type { ContentKind, EditingSession, ILoadedDraft, IPageDoc } from '@mosetta/content';
14
+ import { fullServerTileId } from '@mosetta/server-action/core';
15
+ import { ensureServerActionClient, SERVER_DATA_MANAGER } from '@mosetta/server-action/client';
16
+ import { useDiOptional, useRoute } from '@mosetta/react';
17
+ import { ROUTER_TOKEN } from '@mosetta/runtime/client';
18
+ import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
19
+ import { WORKSPACE_TOKEN } from './workspace';
20
+ import { basePathOf, pathOfView } from './workbenchAddress';
21
+ import type { IOpenDoc } from './workbenchAddress';
22
+ import {
23
+ ASSISTANT_PATH,
24
+ changesContent,
25
+ chatEvents,
26
+ chatReduce,
27
+ draftTouched,
28
+ EMPTY_CHAT,
29
+ IChatState,
30
+ IDraftTouched,
31
+ nodeOfCall,
32
+ TChatItem,
33
+ touchedNodes,
34
+ withUserMessage,
35
+ } from './assistantChat';
36
+ import { useDictation } from './dictation';
37
+ import './assistant.css';
38
+
39
+ /** the conversation, kept for this tab of this site — a reload keeps it, another tab starts its own */
40
+ const storageKey = (): string => `mosetta-assistant:${window.location.host}`;
41
+
42
+ function readChat(): IChatState {
43
+ try {
44
+ const raw = window.sessionStorage.getItem(storageKey());
45
+ const held = raw ? (JSON.parse(raw) as Partial<IChatState>) : null;
46
+ if (held && Array.isArray(held.items) && Array.isArray(held.history)) return { items: held.items, history: held.history, cost: Number(held.cost) || 0 };
47
+ } catch { /* storage denied, or not ours */ }
48
+ return EMPTY_CHAT;
49
+ }
50
+
51
+ function writeChat(state: IChatState): void {
52
+ try {
53
+ window.sessionStorage.setItem(storageKey(), JSON.stringify(state));
54
+ } catch {
55
+ // too large for the tab's storage, or denied: the conversation lives for the page only
56
+ }
57
+ }
58
+
59
+ // the little of markdown a model writes in a chat: [words](link), a bare link, **bold**, `code`
60
+ const MARK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s)<>]+)|\*\*([^*\n]+)\*\*|`([^`\n]+)`/g;
61
+
62
+ /** a link as words: the host and the path, cut short — a signed preview link is long */
63
+ function shortLink(href: string): string {
64
+ try {
65
+ const url = new URL(href);
66
+ const shown = `${url.host}${url.pathname === '/' ? '' : url.pathname}`;
67
+ return `${shown.length > 48 ? `${shown.slice(0, 47)}…` : shown}${url.search ? ' ↗' : ''}`;
68
+ } catch {
69
+ return href;
70
+ }
71
+ }
72
+
73
+ /** the assistant's words: paragraphs as written, links short, a little markdown understood */
74
+ function Words({ text }: { text: string }) {
75
+ const parts: ReactNode[] = [];
76
+ let at = 0;
77
+ for (const match of text.matchAll(MARK_RE)) {
78
+ if (match.index > at) parts.push(text.slice(at, match.index));
79
+ const [whole, label, labelled, bare, bold, code] = match;
80
+ const key = match.index;
81
+ if (labelled) {
82
+ parts.push(<a key={key}
83
+ href={labelled}
84
+ target="_blank"
85
+ rel="noreferrer">{label}</a>);
86
+ } else if (bare) {
87
+ parts.push(<a key={key}
88
+ href={bare}
89
+ target="_blank"
90
+ rel="noreferrer"
91
+ title={bare}>{shortLink(bare)}</a>);
92
+ } else if (bold) parts.push(<strong key={key}>{bold}</strong>);
93
+ else if (code) parts.push(<code key={key}>{code}</code>);
94
+ else parts.push(whole);
95
+ at = match.index + whole.length;
96
+ }
97
+ if (at < text.length) parts.push(text.slice(at));
98
+ return <>{parts}</>;
99
+ }
100
+
101
+ const ToolMark: Record<'running' | 'ok' | 'failed', ReactNode> = {
102
+ running: <span className="assistant__spinner"
103
+ aria-label="working" />,
104
+ ok: <span aria-label="done">✓</span>,
105
+ failed: <span aria-label="failed">!</span>,
106
+ };
107
+
108
+ function Item({ item }: { item: TChatItem }) {
109
+ switch (item.kind) {
110
+ case 'user':
111
+ return <div className="assistant__user"
112
+ data-test-id="assistant-user">{item.text}</div>;
113
+ case 'assistant':
114
+ return <div className="assistant__reply"
115
+ data-test-id="assistant-reply"><Words text={item.text} /></div>;
116
+ case 'tool':
117
+ return (
118
+ <>
119
+ <details className={`assistant__tool assistant__tool--${item.status}`}
120
+ data-test-id="assistant-tool"
121
+ data-tool={item.name}
122
+ data-status={item.status}>
123
+ <summary>
124
+ <span className="assistant__tool-mark">{ToolMark[item.status]}</span>
125
+ <span className="assistant__tool-summary">{item.summary}</span>
126
+ </summary>
127
+ {item.result ? <pre className="assistant__tool-result">{item.result}</pre> : null}
128
+ </details>
129
+ {/* what the assistant looked at, as it saw it */}
130
+ {item.pictures?.length ? (
131
+ <div className="assistant__pictures"
132
+ data-test-id="assistant-pictures">
133
+ {item.pictures.map((picture) => (
134
+ <a key={picture.url}
135
+ href={picture.url}
136
+ target="_blank"
137
+ rel="noreferrer"
138
+ data-tooltip={picture.caption}>
139
+ <img src={picture.url}
140
+ alt={picture.caption}
141
+ loading="lazy" />
142
+ </a>
143
+ ))}
144
+ </div>
145
+ ) : null}
146
+ </>
147
+ );
148
+ case 'error':
149
+ return <div className="assistant__error"
150
+ data-test-id="assistant-error">{item.text}</div>;
151
+ default:
152
+ return null;
153
+ }
154
+ }
155
+
156
+ const SUGGESTIONS = [
157
+ 'What pages does this site have?',
158
+ 'Make a new page /about with a heading and a short text about us',
159
+ 'Copy the post at https://… into a new blog page',
160
+ ];
161
+
162
+ const MicIcon = (
163
+ <svg viewBox="0 0 16 16"
164
+ aria-hidden="true">
165
+ <rect x="5.5"
166
+ y="1.8"
167
+ width="5"
168
+ height="8.4"
169
+ rx="2.5"
170
+ fill="none"
171
+ stroke="currentColor"
172
+ strokeWidth="1.4" />
173
+ <path d="M3.2 7.6a4.8 4.8 0 0 0 9.6 0M8 12.4v2"
174
+ fill="none"
175
+ stroke="currentColor"
176
+ strokeWidth="1.4"
177
+ strokeLinecap="round" />
178
+ </svg>
179
+ );
180
+
181
+ const SendIcon = (
182
+ <svg viewBox="0 0 16 16"
183
+ aria-hidden="true">
184
+ <path d="M8 13V3.5M3.8 7.4L8 3.2l4.2 4.2"
185
+ fill="none"
186
+ stroke="currentColor"
187
+ strokeWidth="1.7"
188
+ strokeLinecap="round"
189
+ strokeLinejoin="round" />
190
+ </svg>
191
+ );
192
+
193
+ const StopIcon = (
194
+ <svg viewBox="0 0 16 16"
195
+ aria-hidden="true">
196
+ <rect x="4.5"
197
+ y="4.5"
198
+ width="7"
199
+ height="7"
200
+ rx="1.2"
201
+ fill="currentColor" />
202
+ </svg>
203
+ );
204
+
205
+ /** where the island floats: at the bottom over Home and the lists, over the tree in the editor */
206
+ export type TAssistantPlacement = 'bottom' | 'tree';
207
+
208
+ export interface IAssistantPanelProps {
209
+ readonly placement: TAssistantPlacement;
210
+ /** the platform writes dictation out (any language, no switch); else the browser recognizes it */
211
+ readonly transcription: boolean;
212
+ readonly onClose: () => void;
213
+ /** the workbench freezes while the assistant works */
214
+ readonly onWorking: (working: boolean) => void;
215
+ }
216
+
217
+ const INPUT_HEIGHT_MAX = 168;
218
+
219
+ /** a recording's length as a clock: 0:07, 1:42 */
220
+ const clock = (seconds: number): string => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
221
+
222
+ export function AssistantPanel({ placement, transcription, onClose, onWorking }: IAssistantPanelProps) {
223
+ const di = useDi(DI_CONTAINER_TOKEN);
224
+ const workspace = useDiOptional(WORKSPACE_TOKEN);
225
+ const router = useDiOptional(ROUTER_TOKEN);
226
+ const route = useRoute();
227
+ // the content module of THIS editor — where a changed draft is read again
228
+ const reference = useComponent().reference;
229
+ const contentId = fullServerTileId({ namespace: reference.namespace, version: reference.version, name: 'content' });
230
+ const [chat, setChat] = useState<IChatState>(readChat);
231
+ const [draft, setDraft] = useState('');
232
+ const draftRef = useRef(draft);
233
+ draftRef.current = draft;
234
+ const [working, setWorking] = useState(false);
235
+ // over Home and the lists the island folds to its input when the person wants the page back
236
+ const [folded, setFolded] = useState(false);
237
+ const abort = useRef<AbortController | null>(null);
238
+ const list = useRef<HTMLDivElement>(null);
239
+ const input = useRef<HTMLTextAreaElement>(null);
240
+ const dictation = useDictation(setDraft, () => draftRef.current, transcription);
241
+ useEffect(() => writeChat(chat), [chat]);
242
+ useEffect(() => {
243
+ list.current?.scrollTo({ top: list.current.scrollHeight });
244
+ }, [chat.items, folded]);
245
+ useEffect(() => onWorking(working), [working, onWorking]);
246
+ // the field grows with what is written, up to a few lines — never dragged
247
+ useLayoutEffect(() => {
248
+ const field = input.current;
249
+ if (!field) return;
250
+ field.style.height = 'auto';
251
+ field.style.height = `${Math.min(field.scrollHeight, INPUT_HEIGHT_MAX)}px`;
252
+ }, [draft]);
253
+
254
+ /** the canvases holding a draft: the workspace's tabs, each with its own session in the hub */
255
+ const sessionsOn = useCallback((kind: ContentKind, name: string, draftId: string): EditingSession[] => {
256
+ const hub = editingSessionHubOf(di);
257
+ const tabs = workspace?.snapshot().tabs ?? [];
258
+ const sessions: EditingSession[] = hub
259
+ ? tabs.map((tab) => hub.peek(tab.id)).filter((session): session is EditingSession => session !== null)
260
+ : [ensureEditingSession(di)];
261
+ return sessions.filter((session) => {
262
+ const state = session.snapshot();
263
+ return state.kind === kind && state.pageName === name && state.draftId === draftId;
264
+ });
265
+ }, [di, workspace]);
266
+
267
+ /** a draft the assistant changed, read again into every canvas that holds it unchanged — false: none does */
268
+ const reload = useCallback(async (touched: IDraftTouched, select: string | null): Promise<boolean> => {
269
+ const holding = sessionsOn(touched.kind, touched.name, touched.draftId);
270
+ if (!holding.length) return false;
271
+ const clean = holding.filter((session) => !session.snapshot().dirty);
272
+ if (!clean.length) return true;
273
+ const res = await fetch(`/api/server-action/${contentId}/loadPageDraft`, {
274
+ method: 'POST',
275
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
276
+ body: JSON.stringify({ name: touched.name, draftId: touched.draftId, kind: touched.kind }),
277
+ });
278
+ const body = (await res.json().catch(() => null)) as { ok?: boolean; draft?: ILoadedDraft<IPageDoc> } | null;
279
+ if (!body?.ok || !body.draft) return true;
280
+ for (const session of clean) {
281
+ session.openPage(touched.name, touched.draftId, body.draft.doc, touched.kind);
282
+ if (select) session.selectNode(select);
283
+ }
284
+ return true;
285
+ }, [contentId, sessionsOn]);
286
+
287
+ /**
288
+ * A draft opened in front of the person: as a tab where the editor is up, else by a move to the
289
+ * editor's page (Home and the lists have no tabs) — the way the pages list opens one
290
+ */
291
+ const openDraft = useCallback((doc: IOpenDoc): void => {
292
+ if (workspace) {
293
+ workspace.open(doc);
294
+ return;
295
+ }
296
+ if (!route || !router?.active) return;
297
+ const { pathname, version } = pathOfView(basePathOf(route.pattern ?? null, route.path ?? '/'), { view: 'editor', ...doc });
298
+ const query = new URLSearchParams(version).toString();
299
+ void router.navigate(query ? `${pathname}?${query}` : pathname);
300
+ }, [route, router, workspace]);
301
+
302
+ /** the block a tool is about to look at or change, selected where its draft is open */
303
+ const aboutToTouch = (name: string, args: unknown): void => {
304
+ const node = nodeOfCall(name, args);
305
+ const a = args as { name?: unknown; draftId?: unknown; kind?: unknown };
306
+ if (!node || typeof a.name !== 'string' || typeof a.draftId !== 'string') return;
307
+ for (const session of sessionsOn(a.kind === 'prefabs' ? 'prefabs' : 'pages', a.name, a.draftId)) session.selectNode(node);
308
+ };
309
+
310
+ /** a draft a tool changed, in front of the person: opened when new, read again where open, opened where not */
311
+ const follow = async (touched: IDraftTouched | null, result: string): Promise<void> => {
312
+ if (!touched) return;
313
+ const doc = { kind: touched.kind, page: touched.name, draftId: touched.draftId };
314
+ if (touched.fresh) {
315
+ openDraft(doc);
316
+ return;
317
+ }
318
+ const shown = await reload(touched, touchedNodes(result)[0] ?? null);
319
+ if (!shown) openDraft(doc);
320
+ };
321
+
322
+ /** what the person looks at — the page and the block selected in the canvas in front */
323
+ const contextNow = (): Record<string, unknown> => {
324
+ const active = editingSessionHubOf(di)?.active.snapshot();
325
+ return {
326
+ path: `${window.location.pathname}${window.location.search}`,
327
+ ...(active?.pageName ? {
328
+ selection: {
329
+ kind: active.kind,
330
+ page: active.pageName,
331
+ ...(active.draftId ? { draftId: active.draftId } : {}),
332
+ ...(active.selectedNodeId ? { node: active.selectedNodeId } : {}),
333
+ },
334
+ } : {}),
335
+ };
336
+ };
337
+
338
+ const send = useCallback(async (typed: string): Promise<void> => {
339
+ if (working) return;
340
+ // sent while dictating: what is being said goes with it
341
+ const message = (dictation.listening || dictation.busy ? await dictation.finish() : typed).trim();
342
+ if (!message) return;
343
+ setDraft('');
344
+ setFolded(false);
345
+ setWorking(true);
346
+ const controller = new AbortController();
347
+ abort.current = controller;
348
+ let state = withUserMessage(chat, message);
349
+ setChat(state);
350
+ const calls = new Map<string, { name: string; args: unknown }>();
351
+ let changed = false;
352
+ try {
353
+ const res = await fetch(ASSISTANT_PATH, {
354
+ method: 'POST',
355
+ headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
356
+ body: JSON.stringify({ history: chat.history, input: { text: message, context: contextNow() } }),
357
+ signal: controller.signal,
358
+ });
359
+ if (!res.ok || !res.body) {
360
+ const failure = (await res.json().catch(() => null)) as { error?: { message?: string } } | null;
361
+ state = chatReduce(state, { type: 'error', message: failure?.error?.message ?? `the assistant answered ${res.status}` });
362
+ setChat(state);
363
+ return;
364
+ }
365
+ for await (const event of chatEvents(res.body)) {
366
+ state = chatReduce(state, event);
367
+ setChat(state);
368
+ if (event.type === 'tool') {
369
+ calls.set(event.id, { name: event.name, args: event.args });
370
+ aboutToTouch(event.name, event.args);
371
+ }
372
+ if (event.type === 'tool_result' && event.ok && changesContent(event.name)) {
373
+ changed = true;
374
+ const call = calls.get(event.id);
375
+ await follow(call ? draftTouched(call.name, call.args, event.text) : null, event.text);
376
+ }
377
+ }
378
+ } catch (e) {
379
+ if (!controller.signal.aborted) {
380
+ state = chatReduce(state, { type: 'error', message: `the conversation broke off: ${(e as Error).message}` });
381
+ setChat(state);
382
+ }
383
+ } finally {
384
+ abort.current = null;
385
+ setWorking(false);
386
+ // the lists and the pages panel read the site anew
387
+ if (changed) {
388
+ ensureServerActionClient(di);
389
+ di.getByToken(SERVER_DATA_MANAGER).invalidate(['pages']);
390
+ }
391
+ }
392
+ }, [chat, di, dictation, openDraft, reload, sessionsOn, working]);
393
+
394
+ const startOver = (): void => {
395
+ if (working) return;
396
+ setChat(EMPTY_CHAT);
397
+ };
398
+
399
+ const listShown = !(placement === 'bottom' && folded);
400
+ return (
401
+ <aside className={`assistant assistant--${placement}${listShown ? '' : ' assistant--folded'}`}
402
+ data-test-id="assistant-panel"
403
+ data-placement={placement}
404
+ data-working={working || undefined}>
405
+ <div className="assistant__head">
406
+ <span className="assistant__title">Assistant</span>
407
+ {chat.cost > 0 ? <span className="assistant__cost"
408
+ data-tooltip="What this conversation has cost">${chat.cost.toFixed(3)}</span> : null}
409
+ <button type="button"
410
+ className="assistant__head-button"
411
+ data-test-id="assistant-new"
412
+ disabled={working || !chat.items.length}
413
+ onClick={startOver}>New chat</button>
414
+ {placement === 'bottom' ? (
415
+ <button type="button"
416
+ className="assistant__head-button"
417
+ data-test-id="assistant-fold"
418
+ aria-label={folded ? 'Show the conversation' : 'Fold to the input'}
419
+ data-tooltip={folded ? 'Show the conversation' : 'Fold to the input'}
420
+ onClick={() => setFolded(!folded)}>{folded ? '▴' : '▾'}</button>
421
+ ) : null}
422
+ <button type="button"
423
+ className="assistant__head-button"
424
+ data-test-id="assistant-close"
425
+ aria-label="Close the assistant"
426
+ data-tooltip="Close (⌘J opens it again)"
427
+ onClick={onClose}>✕</button>
428
+ </div>
429
+ {listShown ? (
430
+ <div className="assistant__list"
431
+ ref={list}>
432
+ {chat.items.length ? chat.items.map((item, i) => <Item key={i}
433
+ item={item} />) : (
434
+ <div className="assistant__empty">
435
+ <p>Ask for what you want on the site — the assistant builds it in a draft you can see here, and publishes only when you say so.</p>
436
+ {SUGGESTIONS.map((suggestion) => (
437
+ <button key={suggestion}
438
+ type="button"
439
+ className="assistant__suggestion"
440
+ onClick={() => {
441
+ setDraft(suggestion);
442
+ input.current?.focus();
443
+ }}>{suggestion}</button>
444
+ ))}
445
+ </div>
446
+ )}
447
+ {working && chat.items[chat.items.length - 1]?.kind === 'user' ? <div className="assistant__thinking">Thinking…</div> : null}
448
+ </div>
449
+ ) : null}
450
+ <form className="assistant__composer"
451
+ onSubmit={(e) => {
452
+ e.preventDefault();
453
+ void send(draft);
454
+ }}>
455
+ <div className={`assistant__field${dictation.listening ? ' assistant__field--listening' : ''}`}>
456
+ <textarea className="assistant__input"
457
+ data-test-id="assistant-input"
458
+ ref={input}
459
+ value={draft}
460
+ rows={1}
461
+ placeholder={working ? 'The assistant is working…' : dictation.listening ? 'Listening…' : dictation.busy ? 'Writing down what you said…' : 'What should change on the site?'}
462
+ disabled={working}
463
+ onChange={(e) => setDraft(e.target.value)}
464
+ onKeyDown={(e) => {
465
+ if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
466
+ e.preventDefault();
467
+ void send(draft);
468
+ }
469
+ }} />
470
+ <div className="assistant__actions">
471
+ {dictation.supported && !working ? (
472
+ <>
473
+ {dictation.listening && !dictation.lang ? (
474
+ <span className="assistant__clock"
475
+ data-test-id="assistant-dictation-clock">{clock(dictation.seconds)}</span>
476
+ ) : null}
477
+ {dictation.lang ? (
478
+ <button type="button"
479
+ className="assistant__lang"
480
+ data-test-id="assistant-dictation-lang"
481
+ data-tooltip="The language you dictate in"
482
+ onClick={dictation.nextLang}>{dictation.lang.split('-')[0].toUpperCase()}</button>
483
+ ) : null}
484
+ <button type="button"
485
+ className={`assistant__icon${dictation.listening ? ' assistant__icon--on' : ''}`}
486
+ data-test-id="assistant-dictate"
487
+ data-busy={dictation.busy || undefined}
488
+ aria-pressed={dictation.listening}
489
+ aria-label={dictation.listening ? 'Stop dictating' : dictation.busy ? 'Writing down what you said' : 'Dictate'}
490
+ data-tooltip={dictation.listening ? 'Stop — the words appear in a moment'
491
+ : dictation.lang ? 'Dictate — speak, the words appear here' : 'Dictate in any language — press again when done'}
492
+ disabled={dictation.busy}
493
+ onClick={dictation.toggle}>{dictation.busy ? <span className="assistant__spinner" /> : MicIcon}</button>
494
+ </>
495
+ ) : null}
496
+ {working ? (
497
+ <button type="button"
498
+ className="assistant__icon assistant__icon--send"
499
+ data-test-id="assistant-stop"
500
+ aria-label="Stop"
501
+ data-tooltip="Stop"
502
+ onClick={() => abort.current?.abort()}>{StopIcon}</button>
503
+ ) : (
504
+ <button type="submit"
505
+ className="assistant__icon assistant__icon--send"
506
+ data-test-id="assistant-send"
507
+ aria-label="Send"
508
+ disabled={!draft.trim() && !dictation.listening && !dictation.busy}>{SendIcon}</button>
509
+ )}
510
+ </div>
511
+ </div>
512
+ {dictation.problem ? <div className="assistant__hint">{dictation.problem}</div> : null}
513
+ </form>
514
+ </aside>
515
+ );
516
+ }
@@ -0,0 +1,165 @@
1
+ // The ASSISTANT's conversation as the workbench holds it — no react here: what the panel shows
2
+ // (the person's words, the assistant's, each tool it used and how that went), the history the
3
+ // server gets back whole with the next message, and how one event of the server's stream changes
4
+ // both. The history only grows: every message is kept exactly as the server sent it.
5
+ import type { ContentKind } from '@mosetta/content';
6
+
7
+ export const ASSISTANT_PATH = '/__mosetta__/assistant';
8
+
9
+ /** a picture a tool showed the assistant (a screenshot): its address and what it is */
10
+ export interface IPicture {
11
+ readonly url: string;
12
+ readonly caption: string;
13
+ }
14
+
15
+ export type TChatItem
16
+ = | { readonly kind: 'user'; readonly text: string }
17
+ | { readonly kind: 'assistant'; readonly text: string }
18
+ | { readonly kind: 'tool'; readonly id: string; readonly name: string; readonly summary: string; readonly status: 'running' | 'ok' | 'failed'; readonly result?: string; readonly pictures?: readonly IPicture[] }
19
+ | { readonly kind: 'error'; readonly text: string };
20
+
21
+ export interface IChatState {
22
+ readonly items: readonly TChatItem[];
23
+ /** the messages as the server sent them — sent back whole with the next one */
24
+ readonly history: readonly unknown[];
25
+ /** what the conversation has cost so far, in dollars */
26
+ readonly cost: number;
27
+ }
28
+
29
+ export const EMPTY_CHAT: IChatState = { items: [], history: [], cost: 0 };
30
+
31
+ export type TChatEvent
32
+ = | { readonly type: 'message'; readonly message: { readonly role: string } }
33
+ | { readonly type: 'text'; readonly delta: string }
34
+ | { readonly type: 'tool'; readonly id: string; readonly name: string; readonly args: unknown }
35
+ | { readonly type: 'tool_result'; readonly id: string; readonly name: string; readonly ok: boolean; readonly text: string; readonly pictures?: readonly IPicture[] }
36
+ | { readonly type: 'done'; readonly usage: { readonly cost?: number } }
37
+ | { readonly type: 'error'; readonly message: string };
38
+
39
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
40
+
41
+ /** an address in a few words: its host and path */
42
+ function shortUrl(href: string): string {
43
+ try {
44
+ const url = new URL(href);
45
+ const shown = `${url.host}${url.pathname === '/' ? '' : url.pathname}`;
46
+ return shown.length > 60 ? `${shown.slice(0, 59)}…` : shown;
47
+ } catch {
48
+ return href.slice(0, 60);
49
+ }
50
+ }
51
+
52
+ /** a tool call in a few words: what it acts on */
53
+ export function toolSummary(name: string, args: unknown): string {
54
+ const a = isObject(args) ? args : {};
55
+ const what = [typeof a.url === 'string' ? shortUrl(a.url) : null, a.name, a.node, a.pattern, a.device === 'mobile' ? 'mobile' : null]
56
+ .filter((part): part is string => typeof part === 'string').join(' · ');
57
+ const ops = Array.isArray(a.ops) ? ` — ${a.ops.length} ${a.ops.length === 1 ? 'change' : 'changes'}` : '';
58
+ return `${name.replace(/_/g, ' ')}${what ? ` ${what}` : ''}${ops}`;
59
+ }
60
+
61
+ /** the conversation after one event of the stream */
62
+ export function chatReduce(state: IChatState, event: TChatEvent): IChatState {
63
+ switch (event.type) {
64
+ case 'message':
65
+ return { ...state, history: [...state.history, event.message] };
66
+ case 'text': {
67
+ const last = state.items[state.items.length - 1];
68
+ if (last?.kind === 'assistant') return { ...state, items: [...state.items.slice(0, -1), { kind: 'assistant', text: last.text + event.delta }] };
69
+ return { ...state, items: [...state.items, { kind: 'assistant', text: event.delta }] };
70
+ }
71
+ case 'tool':
72
+ return { ...state, items: [...state.items, { kind: 'tool', id: event.id, name: event.name, summary: toolSummary(event.name, event.args), status: 'running' }] };
73
+ case 'tool_result':
74
+ return {
75
+ ...state,
76
+ items: state.items.map((item) => (item.kind === 'tool' && item.id === event.id
77
+ ? { ...item, status: event.ok ? 'ok' : 'failed', result: event.text, ...(event.pictures?.length ? { pictures: event.pictures } : {}) }
78
+ : item)),
79
+ };
80
+ case 'done':
81
+ return { ...state, cost: state.cost + (typeof event.usage.cost === 'number' ? event.usage.cost : 0) };
82
+ case 'error':
83
+ return { ...state, items: [...state.items, { kind: 'error', text: event.message }] };
84
+ default:
85
+ return state;
86
+ }
87
+ }
88
+
89
+ /** the person's message, shown at once — the server's copy of it joins the history when it comes */
90
+ export const withUserMessage = (state: IChatState, text: string): IChatState => ({ ...state, items: [...state.items, { kind: 'user', text }] });
91
+
92
+ /** the events of a server's stream, as they come */
93
+ export async function* chatEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<TChatEvent> {
94
+ const reader = body.getReader();
95
+ const decoder = new TextDecoder();
96
+ let buffer = '';
97
+ for (;;) {
98
+ const { done, value } = await reader.read();
99
+ if (done) return;
100
+ buffer += decoder.decode(value, { stream: true });
101
+ for (let at = buffer.indexOf('\n\n'); at >= 0; at = buffer.indexOf('\n\n')) {
102
+ const block = buffer.slice(0, at);
103
+ buffer = buffer.slice(at + 2);
104
+ const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('');
105
+ if (!data) continue;
106
+ try {
107
+ yield JSON.parse(data) as TChatEvent;
108
+ } catch { /* not an event */ }
109
+ }
110
+ }
111
+ }
112
+
113
+ /** what a finished tool call changed — a draft to reload where it is open, or to open */
114
+ export interface IDraftTouched {
115
+ readonly kind: ContentKind;
116
+ readonly name: string;
117
+ readonly draftId: string;
118
+ /** a draft the assistant opened or made: the workbench opens it */
119
+ readonly fresh: boolean;
120
+ }
121
+
122
+ const CHANGING = new Set(['edit_draft', 'save_draft', 'create_draft', 'create_page', 'publish_page', 'discard_draft']);
123
+
124
+ /** whether a tool changes the site's content — lists are read anew after it */
125
+ export const changesContent = (name: string): boolean => CHANGING.has(name);
126
+
127
+ /** the draft a successful tool call touched, from its arguments and its answer */
128
+ export function draftTouched(name: string, args: unknown, result: string): IDraftTouched | null {
129
+ const a = isObject(args) ? args : {};
130
+ const kind: ContentKind = a.kind === 'prefabs' ? 'prefabs' : 'pages';
131
+ if (typeof a.name !== 'string') return null;
132
+ if (name === 'edit_draft' || name === 'save_draft') {
133
+ return typeof a.draftId === 'string' ? { kind, name: a.name, draftId: a.draftId, fresh: false } : null;
134
+ }
135
+ if (name === 'create_draft' || name === 'create_page') {
136
+ // the answer may arrive cut for showing — the id comes first in it
137
+ const draftId = /"draftId":\s*"([a-z0-9-]+)"/.exec(result)?.[1];
138
+ return draftId ? { kind: name === 'create_page' ? 'pages' : kind, name: a.name, draftId, fresh: true } : null;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * The blocks an edit touched and still has — what the canvas shows selected once the edit lands:
145
+ * read off the edit's own account (`set <id> › …`, `insert <id> into …`, `move <id> into …`).
146
+ */
147
+ export function touchedNodes(result: string): string[] {
148
+ const ids: string[] = [];
149
+ for (const m of result.matchAll(/"(?:set|unset|insert|move) ([^\s"›]+)/g)) if (!ids.includes(m[1])) ids.push(m[1]);
150
+ return ids;
151
+ }
152
+
153
+ /** the block a tool is about to look at or change — selected in the canvas while it works */
154
+ export function nodeOfCall(name: string, args: unknown): string | null {
155
+ const a = isObject(args) ? args : {};
156
+ if (name === 'read_node' && typeof a.node === 'string') return a.node;
157
+ if (name === 'edit_draft' && Array.isArray(a.ops)) {
158
+ for (const op of a.ops) {
159
+ if (!isObject(op)) continue;
160
+ if (typeof op.node === 'string' && op.op !== 'remove') return op.node;
161
+ if (typeof op.parent === 'string') return op.parent;
162
+ }
163
+ }
164
+ return null;
165
+ }