phoenix_duskmoon 9.16.13 → 9.16.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ### Added
4
4
 
5
+ - Public React Chat hook factory, custom composer and renderer integration, and
6
+ application metadata, runtime and admission state across snapshots and events.
5
7
  - Native Core components: carousel, countdown, diff, keyboard keys, radial progress,
6
8
  floating actions, swap, filter group, hero, indicator, join, mask, sidebar layout,
7
9
  stack and megamenu, with Storybook examples.
@@ -9,6 +11,10 @@
9
11
 
10
12
  ### Changed
11
13
 
14
+ - Update Core/CSS Art to 1.20.4, Elements/Art Elements to 1.9.1 and React Components
15
+ to 0.5.0, with current supporting JavaScript and Hex dependencies.
16
+ - Support HTTP 0.21.x alongside 0.20.x; the umbrella selects 0.21.3. Update MDEx
17
+ Mermaid to 0.4.0, which escapes diagram source and preserves explicit raw HTML options.
12
18
  - Update Elements, Art Elements and declared individual element packages to 1.9.0,
13
19
  including Chat Scroll timeline fixes and Markdown's DOMPurify update. Remove the
14
20
  retired `el-pin-input` theme selector; native `dm_pin_input` remains supported.
@@ -26,6 +32,16 @@
26
32
 
27
33
  ### Fixed
28
34
 
35
+ - Resolve production chunk imports by their importing module, preventing identical
36
+ relative paths in unrelated packages from becoming missing browser assets.
37
+ - Preserve lazy custom-element registration and resolve preload URLs relative to
38
+ the emitted bundle when Storybook pages use nested routes.
39
+ - Preserve shared development vendor runtimes and CommonJS exports during cache
40
+ recovery and late imports, with separate vendor caches for each Mix environment
41
+ and a shared initial static graph. Keep vendor URLs stable during discovery and
42
+ refresh cached URLs when Phoenix initializes its asset plug or restarts.
43
+ - Complete required and optional transitive dependencies of nested npm packages,
44
+ and repair incomplete existing lockfiles before reporting successful installation.
29
45
  - Restore amd64/arm64 Alpine support across the native build toolchain, with
30
46
  published musl binaries and checksums plus Alpine release validation.
31
47
  - Code Engine's temporary stylesheet fallback now works after minification and
@@ -4,8 +4,18 @@ import { Input } from "@duskmoon-dev/components/input";
4
4
  import { Button } from "@duskmoon-dev/components/button";
5
5
  import { Chat } from "@duskmoon-dev/components/chat";
6
6
  const h = React.createElement;
7
+ const defaultOptions = {};
7
8
 
8
- const Message = memo(function Message({ message, bridge, targetId, timeline }) {
9
+ const Message = memo(function Message(props) {
10
+ const {
11
+ message,
12
+ bridge,
13
+ targetId,
14
+ timeline,
15
+ options,
16
+ connected,
17
+ synchronizing,
18
+ } = props;
9
19
  const [notice, setNotice] = useState("");
10
20
  return h(
11
21
  Chat,
@@ -16,7 +26,9 @@ const Message = memo(function Message({ message, bridge, targetId, timeline }) {
16
26
  timeline,
17
27
  "data-message-id": message.id,
18
28
  },
19
- h(Chat.Header, null, message.author || message.role),
29
+ options.renderHeader
30
+ ? h(options.renderHeader, props)
31
+ : h(Chat.Header, null, message.author || message.role),
20
32
  h(
21
33
  Chat.Bubble,
22
34
  { streaming: message.status === "streaming" },
@@ -24,114 +36,229 @@ const Message = memo(function Message({ message, bridge, targetId, timeline }) {
24
36
  h(
25
37
  Chat.Tool,
26
38
  { key: tool.id, status: tool.status || "running" },
27
- h(Chat.ToolHeader, null, `${tool.name || tool.id}: ${tool.status || "running"}`),
39
+ h(
40
+ Chat.ToolHeader,
41
+ null,
42
+ `${tool.name || tool.id}: ${tool.status || "running"}`,
43
+ ),
28
44
  h(
29
45
  tool.result == null ? Chat.ToolCall : Chat.ToolResult,
30
46
  null,
31
- h("pre", null, JSON.stringify(tool.result ?? tool.input ?? {}, null, 2)),
47
+ h(
48
+ "pre",
49
+ null,
50
+ JSON.stringify(tool.result ?? tool.input ?? {}, null, 2),
51
+ ),
32
52
  ),
33
53
  ),
34
54
  ),
35
- h(Markdown, { className: "chat-bubble-content", markdown: message.content || "" }),
55
+ h(Markdown, {
56
+ className: "chat-bubble-content",
57
+ markdown: message.content || "",
58
+ }),
36
59
  ),
37
60
  message.status === "streaming" &&
38
- h(Chat.Status, { role: "status" }, h(Chat.StatusItem, null, "Generating…")),
39
- message.error && h("p", { role: "alert" }, message.error),
40
- h(
41
- Chat.Footer,
42
- null,
43
61
  h(
44
- Chat.Actions,
45
- null,
46
- h(
47
- Button,
48
- {
49
- type: "button",
50
- appearance: "ghost",
51
- onClick: async () => {
52
- try {
53
- await navigator.clipboard.writeText(message.content || "");
54
- setNotice("Copied");
55
- } catch {
56
- setNotice("Copy failed");
57
- }
58
- },
59
- },
60
- "Copy",
61
- ),
62
- ["error", "cancelled"].includes(message.status) &&
63
- h(
64
- Button,
65
- {
66
- type: "button",
67
- appearance: "ghost",
68
- onClick: () =>
69
- bridge
70
- .send("retry", { message_id: message.id })
71
- .catch((error) => setNotice(error.message)),
72
- },
73
- "Retry",
74
- ),
75
- ...(message.actions || []).map((action) =>
62
+ Chat.Status,
63
+ { role: "status" },
64
+ h(Chat.StatusItem, null, "Generating…"),
65
+ ),
66
+ message.error && h("p", { role: "alert" }, message.error),
67
+ options.renderFooter
68
+ ? h(options.renderFooter, props)
69
+ : h(
70
+ Chat.Footer,
71
+ null,
76
72
  h(
77
- Button,
78
- {
79
- key: action.id,
80
- type: "button",
81
- appearance: "ghost",
82
- onClick: () =>
83
- bridge
84
- .send("action", { message_id: message.id, action: action.id })
85
- .catch((error) => setNotice(error.message)),
86
- },
87
- action.label,
73
+ Chat.Actions,
74
+ null,
75
+ h(
76
+ Button,
77
+ {
78
+ type: "button",
79
+ appearance: "ghost",
80
+ onClick: async () => {
81
+ try {
82
+ await navigator.clipboard.writeText(message.content || "");
83
+ setNotice("Copied");
84
+ } catch {
85
+ setNotice("Copy failed");
86
+ }
87
+ },
88
+ },
89
+ "Copy",
90
+ ),
91
+ ["error", "cancelled"].includes(message.status) &&
92
+ h(
93
+ Button,
94
+ {
95
+ type: "button",
96
+ appearance: "ghost",
97
+ disabled: !connected || synchronizing,
98
+ onClick: () =>
99
+ bridge
100
+ .send("retry", { message_id: message.id })
101
+ .catch((error) => setNotice(error.message)),
102
+ },
103
+ "Retry",
104
+ ),
105
+ ...(message.actions || []).map((action) =>
106
+ h(
107
+ Button,
108
+ {
109
+ key: action.id,
110
+ disabled:
111
+ action.disabled ||
112
+ action.eligible === false ||
113
+ !connected ||
114
+ synchronizing,
115
+ type: "button",
116
+ appearance: "ghost",
117
+ onClick: () =>
118
+ bridge
119
+ .send("action", {
120
+ message_id: message.id,
121
+ action: action.id,
122
+ })
123
+ .catch((error) => setNotice(error.message)),
124
+ },
125
+ action.label,
126
+ ),
127
+ ),
88
128
  ),
89
129
  ),
90
- ),
91
- ),
92
130
  h("span", { role: "status" }, notice),
93
131
  );
94
132
  });
95
133
 
96
- export function ReactChat({ state, bridge, connected, label }) {
97
- const transcriptId = useId();
98
- const [draft, setDraft] = useState("");
134
+ // A composer owns its draft/attachments. This helper only owns admission and UI requests.
135
+ export function useChatSubmission({ bridge, canSubmit }) {
99
136
  const [busy, setBusy] = useState(false);
100
137
  const [notice, setNotice] = useState("");
101
138
  const pending = useRef(false);
102
139
  const alive = useRef(true);
103
- const scroll = useRef(null);
104
- const follow = useRef(true);
105
- useEffect(
106
- () => () => {
107
- alive.current = false;
108
- },
109
- [],
110
- );
111
140
  useEffect(() => {
112
- if (follow.current && scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight;
113
- }, [state.messages]);
114
- const streaming = state.messages.some((message) => message.status === "streaming");
115
- const replies = state.messages.filter((message) => message.role === "assistant").slice(-24);
116
- const timelines = new Map(replies.map((message, index) => [message.id, index + 1]));
117
- const targetId = (message) => `${transcriptId}-message-${encodeURIComponent(message.id)}`;
118
- async function submit(event) {
119
- event.preventDefault();
120
- if (!draft.trim() || pending.current || streaming || !connected || state.needsSync) return;
121
- const text = draft;
141
+ alive.current = true;
142
+ return () => {
143
+ alive.current = false;
144
+ };
145
+ }, []);
146
+ async function submit(payload) {
147
+ if (!canSubmit || pending.current)
148
+ throw new Error("Chat is not accepting messages");
122
149
  pending.current = true;
123
150
  setBusy(true);
124
151
  setNotice("");
125
152
  try {
126
- await bridge.send("send", { text, request_id: crypto.randomUUID() });
127
- if (alive.current) setDraft((current) => (current === text ? "" : current));
153
+ return await bridge.send("send", {
154
+ request_id: crypto.randomUUID(),
155
+ ...payload,
156
+ });
128
157
  } catch (error) {
129
158
  if (alive.current) setNotice(error.message);
159
+ throw error;
130
160
  } finally {
131
161
  pending.current = false;
132
162
  if (alive.current) setBusy(false);
133
163
  }
134
164
  }
165
+ return { submit, busy, notice };
166
+ }
167
+
168
+ function DefaultComposer(props) {
169
+ const { bridge, connected, active, canSubmit } = props;
170
+ const [draft, setDraft] = useState("");
171
+ const { submit, busy, notice } = useChatSubmission(props);
172
+ const [stopNotice, setStopNotice] = useState("");
173
+ async function onSubmit(event) {
174
+ event.preventDefault();
175
+ if (!draft.trim() || busy || !canSubmit) return;
176
+ const text = draft;
177
+ try {
178
+ await submit({ text });
179
+ setDraft((current) => (current === text ? "" : current));
180
+ } catch {
181
+ /* The helper preserves the draft and renders the rejection. */
182
+ }
183
+ }
184
+ return h(
185
+ React.Fragment,
186
+ null,
187
+ (notice || stopNotice) && h("p", { role: "alert" }, notice || stopNotice),
188
+ h(
189
+ "form",
190
+ { className: "flex gap-2", onSubmit },
191
+ h(Input, {
192
+ value: draft,
193
+ onChange: (event) => setDraft(event.target.value),
194
+ "aria-label": "Message",
195
+ placeholder: "Write a message",
196
+ }),
197
+ h(
198
+ Button,
199
+ { type: "submit", disabled: busy || !canSubmit || !draft.trim() },
200
+ "Send",
201
+ ),
202
+ active &&
203
+ h(
204
+ Button,
205
+ {
206
+ type: "button",
207
+ disabled: !connected,
208
+ onClick: () =>
209
+ bridge
210
+ .send("stop", {})
211
+ .catch((error) => setStopNotice(error.message)),
212
+ },
213
+ "Stop",
214
+ ),
215
+ ),
216
+ );
217
+ }
218
+
219
+ export function ReactChat({
220
+ state,
221
+ bridge,
222
+ connected,
223
+ label,
224
+ options = defaultOptions,
225
+ }) {
226
+ const transcriptId = useId();
227
+ const scroll = useRef(null);
228
+ const follow = useRef(true);
229
+ useEffect(() => {
230
+ if (follow.current && scroll.current)
231
+ scroll.current.scrollTop = scroll.current.scrollHeight;
232
+ }, [state.messages]);
233
+ const streaming = state.messages.some(
234
+ (message) => message.status === "streaming",
235
+ );
236
+ const active = state.runtime.active ?? streaming;
237
+ const canSubmit =
238
+ connected &&
239
+ !state.needsSync &&
240
+ state.admission.canSubmit !== false &&
241
+ (!active || state.admission.allowWhileStreaming === true);
242
+ const context = {
243
+ state,
244
+ bridge,
245
+ connected,
246
+ synchronizing: state.needsSync,
247
+ metadata: state.metadata,
248
+ runtime: state.runtime,
249
+ admission: state.admission,
250
+ streaming,
251
+ active,
252
+ canSubmit,
253
+ };
254
+ const replies = state.messages
255
+ .filter((message) => message.role === "assistant")
256
+ .slice(-24);
257
+ const timelines = new Map(
258
+ replies.map((message, index) => [message.id, index + 1]),
259
+ );
260
+ const targetId = (message) =>
261
+ `${transcriptId}-message-${encodeURIComponent(message.id)}`;
135
262
  return h(
136
263
  "section",
137
264
  { className: "flex flex-col gap-3 h-full", "aria-label": label },
@@ -143,7 +270,8 @@ export function ReactChat({ state, bridge, connected, label }) {
143
270
  "data-chat-messages": true,
144
271
  onScroll: () => {
145
272
  const el = scroll.current;
146
- follow.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
273
+ follow.current =
274
+ el.scrollHeight - el.scrollTop - el.clientHeight < 48;
147
275
  },
148
276
  },
149
277
  h(
@@ -164,7 +292,13 @@ export function ReactChat({ state, bridge, connected, label }) {
164
292
  Chat.ScrollBody,
165
293
  { className: "gap-3" },
166
294
  state.messages.map((message) =>
167
- h(Message, {
295
+ h(options.renderMessage || Message, {
296
+ ...(options.renderMessage ||
297
+ options.renderHeader ||
298
+ options.renderFooter
299
+ ? context
300
+ : { connected, synchronizing: state.needsSync }),
301
+ options,
168
302
  key: message.id,
169
303
  message,
170
304
  bridge,
@@ -190,36 +324,14 @@ export function ReactChat({ state, bridge, connected, label }) {
190
324
  h(
191
325
  "p",
192
326
  { role: "status" },
193
- connected ? "Synchronizing conversation…" : "Disconnected. Draft preserved.",
194
- ),
195
- notice && h("p", { role: "alert" }, notice),
196
- h(
197
- "form",
198
- { className: "flex gap-2", onSubmit: submit },
199
- h(Input, {
200
- value: draft,
201
- onChange: (event) => setDraft(event.target.value),
202
- "aria-label": "Message",
203
- placeholder: "Write a message",
204
- }),
205
- h(
206
- Button,
207
- {
208
- type: "submit",
209
- disabled: busy || streaming || !connected || state.needsSync || !draft.trim(),
210
- },
211
- "Send",
327
+ connected
328
+ ? "Synchronizing conversation…"
329
+ : "Disconnected. Draft preserved.",
212
330
  ),
213
- streaming &&
214
- h(
215
- Button,
216
- {
217
- type: "button",
218
- disabled: !connected,
219
- onClick: () => bridge.send("stop", {}).catch((error) => setNotice(error.message)),
220
- },
221
- "Stop",
222
- ),
223
- ),
331
+ options.renderStatus && h(options.renderStatus, context),
332
+ h(options.Composer || DefaultComposer, {
333
+ ...context,
334
+ key: state.conversationId,
335
+ }),
224
336
  );
225
337
  }
@@ -3,103 +3,129 @@ import { createRoot } from "react-dom/client";
3
3
  import { ReactChat } from "./chat.js";
4
4
  import { initialState, reduceChat } from "./state.js";
5
5
 
6
- export const DuskmoonReactChat = {
7
- mounted() {
8
- this.config = JSON.parse(this.el.dataset.chat);
9
- this.state = initialState(this.config.conversationId, this.config.messages);
10
- this.root = createRoot(this.el);
11
- this.connected = true;
12
- this.requests = new Set();
13
- this.bridge = { send: (action, payload) => this.sendChat(action, payload) };
14
- this.eventRef = this.handleEvent(this.config.event, (event) => {
15
- if (event.id !== this.el.id) return;
16
- const previous = this.state;
17
- this.state = reduceChat(this.state, event);
18
- if (previous === this.state) return;
19
- if (this.state.needsSync && !previous.needsSync) this.requestSync();
20
- this.scheduleRender();
21
- });
22
- this.renderChat();
23
- },
24
- sendChat(action, payload) {
25
- if (!this.connected || this.dead)
26
- return Promise.reject(new Error("Chat is disconnected"));
27
- return new Promise((resolve, reject) => {
28
- const finish = (error, reply) => {
29
- if (!this.requests.delete(request)) return;
30
- clearTimeout(request.timer);
31
- error ? reject(error) : resolve(reply);
32
- };
33
- const request = {
34
- cancel: () =>
35
- finish(new Error("Chat disconnected; check status before retrying")),
36
- };
37
- this.requests.add(request);
38
- request.timer = setTimeout(
39
- () =>
40
- finish(
41
- new Error("Chat request timed out; check status before retrying"),
42
- ),
43
- 15000,
6
+ export { ReactChat, useChatSubmission } from "./chat.js";
7
+ export { initialState, reduceChat } from "./state.js";
8
+
9
+ export function createDuskmoonReactChat(options = {}) {
10
+ return {
11
+ mounted() {
12
+ this.config = JSON.parse(this.el.dataset.chat);
13
+ this.state = initialState(
14
+ this.config.conversationId,
15
+ this.config.messages,
16
+ this.config,
44
17
  );
45
- const body = {
46
- ...payload,
47
- id: this.el.id,
48
- conversation_id: this.state.conversationId,
18
+ this.root = createRoot(this.el);
19
+ this.connected = true;
20
+ this.requests = new Set();
21
+ this.bridge = {
22
+ send: (action, payload) => this.sendChat(action, payload),
49
23
  };
50
- const reply = (response) =>
51
- finish(
52
- response?.status === "ok"
53
- ? null
54
- : new Error(response?.message || "Chat request rejected"),
55
- response,
24
+ this.eventRef = this.handleEvent(this.config.event, (event) => {
25
+ if (event.id !== this.el.id) return;
26
+ const previous = this.state;
27
+ this.state = reduceChat(this.state, event);
28
+ if (previous === this.state) return;
29
+ if (this.state.needsSync && !previous.needsSync) this.requestSync();
30
+ this.scheduleRender();
31
+ });
32
+ this.renderChat();
33
+ },
34
+ sendChat(action, payload) {
35
+ if (!this.connected || this.dead)
36
+ return Promise.reject(new Error("Chat is disconnected"));
37
+ if (action === "send" && this.state.needsSync)
38
+ return Promise.reject(new Error("Chat is synchronizing"));
39
+ return new Promise((resolve, reject) => {
40
+ const finish = (error, reply) => {
41
+ if (!this.requests.delete(request)) return;
42
+ clearTimeout(request.timer);
43
+ error ? reject(error) : resolve(reply);
44
+ };
45
+ const request = {
46
+ cancel: () =>
47
+ finish(
48
+ new Error("Chat disconnected; check status before retrying"),
49
+ ),
50
+ };
51
+ this.requests.add(request);
52
+ request.timer = setTimeout(
53
+ () =>
54
+ finish(
55
+ new Error("Chat request timed out; check status before retrying"),
56
+ ),
57
+ 15000,
56
58
  );
57
- try {
58
- const event = this.config.actions[action];
59
- if (this.config.target)
60
- this.pushEventTo(this.config.target, event, body, reply);
61
- else this.pushEvent(event, body, reply);
62
- } catch (error) {
63
- finish(error);
64
- }
65
- });
66
- },
67
- requestSync() {
68
- this.sendChat("sync", { sequences: this.state.sequences }).catch(() => {});
69
- },
70
- scheduleRender() {
71
- if (!this.frame)
72
- this.frame = requestAnimationFrame(() => {
73
- this.frame = null;
74
- if (!this.dead) this.renderChat();
59
+ const body = {
60
+ ...payload,
61
+ id: this.el.id,
62
+ conversation_id: this.state.conversationId,
63
+ };
64
+ const reply = (response) => {
65
+ const error =
66
+ response?.status === "ok"
67
+ ? null
68
+ : new Error(response?.message || "Chat request rejected");
69
+ if (error) error.response = response;
70
+ finish(error, response);
71
+ };
72
+ try {
73
+ const event = this.config.actions[action];
74
+ if (this.config.target || this.el.dataset.target)
75
+ this.pushEventTo(
76
+ this.config.target || this.el.dataset.target,
77
+ event,
78
+ body,
79
+ reply,
80
+ );
81
+ else this.pushEvent(event, body, reply);
82
+ } catch (error) {
83
+ finish(error);
84
+ }
75
85
  });
76
- },
77
- renderChat() {
78
- this.root.render(
79
- React.createElement(ReactChat, {
80
- state: this.state,
81
- bridge: this.bridge,
82
- connected: this.connected,
83
- label: this.config.label,
84
- }),
85
- );
86
- },
87
- disconnected() {
88
- this.connected = false;
89
- this.requests.forEach((request) => request.cancel());
90
- this.renderChat();
91
- },
92
- reconnected() {
93
- this.connected = true;
94
- this.state = { ...this.state, needsSync: true };
95
- this.renderChat();
96
- this.requestSync();
97
- },
98
- destroyed() {
99
- this.dead = true;
100
- cancelAnimationFrame(this.frame);
101
- this.removeHandleEvent(this.eventRef);
102
- this.requests.forEach((request) => request.cancel());
103
- this.root.unmount();
104
- },
105
- };
86
+ },
87
+ requestSync() {
88
+ this.sendChat("sync", { sequences: this.state.sequences }).catch(
89
+ () => {},
90
+ );
91
+ },
92
+ scheduleRender() {
93
+ if (!this.frame)
94
+ this.frame = requestAnimationFrame(() => {
95
+ this.frame = null;
96
+ if (!this.dead) this.renderChat();
97
+ });
98
+ },
99
+ renderChat() {
100
+ this.root.render(
101
+ React.createElement(ReactChat, {
102
+ state: this.state,
103
+ bridge: this.bridge,
104
+ connected: this.connected,
105
+ label: this.config.label,
106
+ options,
107
+ }),
108
+ );
109
+ },
110
+ disconnected() {
111
+ this.connected = false;
112
+ this.requests.forEach((request) => request.cancel());
113
+ this.renderChat();
114
+ },
115
+ reconnected() {
116
+ this.connected = true;
117
+ this.state = { ...this.state, needsSync: true };
118
+ this.renderChat();
119
+ this.requestSync();
120
+ },
121
+ destroyed() {
122
+ this.dead = true;
123
+ cancelAnimationFrame(this.frame);
124
+ this.removeHandleEvent(this.eventRef);
125
+ this.requests.forEach((request) => request.cancel());
126
+ this.root.unmount();
127
+ },
128
+ };
129
+ }
130
+
131
+ export const DuskmoonReactChat = createDuskmoonReactChat();