@djangocfg/ui-tools 2.1.395 → 2.1.399

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.395",
3
+ "version": "2.1.399",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -154,8 +154,8 @@
154
154
  "test:watch": "vitest"
155
155
  },
156
156
  "peerDependencies": {
157
- "@djangocfg/i18n": "^2.1.395",
158
- "@djangocfg/ui-core": "^2.1.395",
157
+ "@djangocfg/i18n": "^2.1.399",
158
+ "@djangocfg/ui-core": "^2.1.399",
159
159
  "consola": "^3.4.2",
160
160
  "lodash-es": "^4.18.1",
161
161
  "lucide-react": "^0.545.0",
@@ -209,9 +209,9 @@
209
209
  "material-file-icons": "^2.4.0"
210
210
  },
211
211
  "devDependencies": {
212
- "@djangocfg/i18n": "^2.1.395",
213
- "@djangocfg/typescript-config": "^2.1.395",
214
- "@djangocfg/ui-core": "^2.1.395",
212
+ "@djangocfg/i18n": "^2.1.399",
213
+ "@djangocfg/typescript-config": "^2.1.399",
214
+ "@djangocfg/ui-core": "^2.1.399",
215
215
  "@types/lodash-es": "^4.17.12",
216
216
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
217
217
  "@types/node": "^24.7.2",
@@ -715,13 +715,32 @@ Stories live next to components — open `@djangocfg/playground`:
715
715
  | `composerAttachmentTray` | Above composer textarea | `renderAttachmentTray({ attachments })` |
716
716
  | `jumpToLatest` | Sticky overlay | `renderJumpToLatest({ unread, scrollToBottom })` |
717
717
  | `renderToolCall` | Per tool-call panel | `(call) => ReactNode` |
718
- | `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` (rich UI like vehicle cards) |
718
+ | `renderAfterCalls` | After all tool panels | `(calls) => ReactNode` **only renders when the message has tool calls** |
719
+ | `renderAfterMessage` | Below every assistant bubble | `(message) => ReactNode` — fires for every message, independent of `toolCalls` |
719
720
 
720
721
  Flags:
721
722
 
722
723
  - `hideComposer` — agent-pause / human-in-the-loop pause; composer is unmounted.
723
724
  - `hideToolCalls` — show only `renderAfterCalls` rich UI without raw tool panels.
724
725
 
726
+ ### Which slot for product widgets — `renderAfterCalls` vs `renderAfterMessage`?
727
+
728
+ Both put custom content under the assistant bubble; the difference is **what triggers them**.
729
+
730
+ - `renderAfterCalls` is gated on `message.toolCalls?.length > 0`. Use it when the widget is **derived from raw tool output** (read `call.output`) and you can rely on the host streaming the `tool_call` / `tool_result` SSE frames. Admin / dev flows typically work this way.
731
+ - `renderAfterMessage` fires **for every message**, regardless of `toolCalls`. Use it when the widget is driven by a side channel — e.g. typed `ui_payload` SSE frames the host emits independently of the raw tool surface. This is the correct slot when the public-prod stream **hides** `tool_call` events for security: the message lands with `toolCalls === undefined`, so `renderAfterCalls` would never mount, but `renderAfterMessage` still runs and the widget renders from the side channel.
732
+
733
+ Recommended pairing for a "vehicle cards" / "tax breakdown" / "chart" widget on a public chat:
734
+
735
+ ```tsx
736
+ <ChatRoot
737
+ transport={transport}
738
+ renderAfterMessage={(m) => <VehicleCardsForMessage messageId={m.id} />}
739
+ />
740
+ ```
741
+
742
+ `VehicleCardsForMessage` subscribes to your `ui_payload` event bus (filtering by `m.id`) and returns `null` when there's nothing to show. Streaming-safe: the renderer is called for streaming messages too, so progressive UI (skeletons that fill in as payloads arrive) works as expected.
743
+
725
744
  ## Hotkeys
726
745
 
727
746
  - `Enter` — send (or `Cmd+Enter` if `prefs.submitOn = 'cmd+enter'`).
@@ -68,6 +68,14 @@ export interface ChatRootProps {
68
68
  // ---- render-prop slots (need access to data) --------------------------
69
69
  /** Replace `<MessageBubble>` per message. */
70
70
  renderMessage?: (m: ChatMessage, i: number) => ReactNode;
71
+ /**
72
+ * Render arbitrary content beneath every default `<MessageBubble>`
73
+ * (not invoked when `renderMessage` is set — the host owns layout
74
+ * in that case). Useful for product widgets driven by a side channel
75
+ * (e.g. `ui_payload` SSE frames) where the widget is per-message but
76
+ * not tied to the bubble's tool-calls array.
77
+ */
78
+ renderAfterMessage?: (m: ChatMessage) => ReactNode;
71
79
  /** Render the header lazily — receives the chat context. */
72
80
  renderHeader?: (ctx: ChatContextValue) => ReactNode;
73
81
  /** Render the empty-state lazily — receives a `setValue` to seed the composer. */
@@ -202,6 +210,7 @@ function ChatRootShell({ className, listClassName, slots }: ChatRootShellProps)
202
210
  toolCallsProps={slots.toolCallsProps}
203
211
  attachmentRenderers={slots.attachmentRenderers}
204
212
  onAttachmentOpen={slots.onAttachmentOpen}
213
+ renderAfterMessage={slots.renderAfterMessage}
205
214
  onCopy={() => copy(m.content)}
206
215
  onRegenerate={() => void chat.regenerate(m.id)}
207
216
  onDelete={() => chat.deleteMessage(m.id)}
@@ -79,6 +79,21 @@ export interface MessageBubbleProps {
79
79
  * you need different behaviour per slot.
80
80
  */
81
81
  streamingIndicator?: (m: ChatMessage) => ReactNode;
82
+ /**
83
+ * Render arbitrary content beneath the message body, regardless of
84
+ * whether the message carries tool calls. Used for product widgets
85
+ * that ride a side channel (e.g. `ui_payload` SSE frames driving
86
+ * vehicle cards, tax tables, charts) and therefore can't piggy-back
87
+ * on the `toolCallsRenderer` slot — the latter is gated on the
88
+ * message having a non-empty `toolCalls` array, which doesn't hold
89
+ * when raw tool events are hidden from public clients.
90
+ *
91
+ * Receives the message so the host can scope the widget by id /
92
+ * role / timing. Renders even on streaming messages, so progressive
93
+ * UI hints (skeletons that fill in as payloads arrive) work as
94
+ * expected.
95
+ */
96
+ renderAfterMessage?: (m: ChatMessage) => ReactNode;
82
97
  }
83
98
 
84
99
  const MessageBubbleInner = ({
@@ -107,6 +122,7 @@ const MessageBubbleInner = ({
107
122
  onDelete,
108
123
  messageActionsExtra,
109
124
  streamingIndicator,
125
+ renderAfterMessage,
110
126
  }: MessageBubbleProps) => {
111
127
  const isUser = isUserProp ?? message.role === 'user';
112
128
  const isStreaming = !!message.isStreaming;
@@ -208,6 +224,8 @@ const MessageBubbleInner = ({
208
224
  : <ToolCalls calls={message.toolCalls} {...toolCallsProps} />
209
225
  : null}
210
226
 
227
+ {renderAfterMessage ? renderAfterMessage(message) : null}
228
+
211
229
  {message.sources?.length && !isStreaming
212
230
  ? sourcesRenderer
213
231
  ? sourcesRenderer(message.sources)
@@ -15,7 +15,7 @@ interface UseMermaidRendererProps {
15
15
  }
16
16
 
17
17
  interface MermaidRenderResult {
18
- mermaidRef: React.RefObject<HTMLDivElement>;
18
+ mermaidRef: React.RefObject<HTMLDivElement | null>;
19
19
  svgContent: string;
20
20
  isVertical: boolean;
21
21
  isRendering: boolean;
@@ -143,7 +143,9 @@ export function createWebSpeechEngine(
143
143
  rec.onresult = (e) => {
144
144
  for (let i = e.resultIndex; i < e.results.length; i += 1) {
145
145
  const res = e.results[i];
146
+ if (!res) continue;
146
147
  const alt = res[0];
148
+ if (!alt) continue;
147
149
  const text = alt.transcript;
148
150
  if (!currentSegmentId) currentSegmentId = newSegmentId();
149
151
  if (res.isFinal) {
@@ -223,7 +223,7 @@ export function countryFromTag(tag: string | null | undefined): string | null {
223
223
  const parts = tag.split('-');
224
224
  for (let i = parts.length - 1; i >= 0; i -= 1) {
225
225
  const p = parts[i];
226
- if (p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
226
+ if (p && p.length === 2 && /^[A-Za-z]{2}$/.test(p)) return p.toUpperCase();
227
227
  }
228
228
  return null;
229
229
  }
@@ -31,7 +31,7 @@ export function useMicLevel(stream: MediaStream | null): number {
31
31
  const tick = (): void => {
32
32
  analyser.getFloatTimeDomainData(buf);
33
33
  let sum = 0;
34
- for (let i = 0; i < buf.length; i += 1) sum += buf[i] * buf[i];
34
+ for (let i = 0; i < buf.length; i += 1) sum += buf[i]! * buf[i]!;
35
35
  const rms = Math.sqrt(sum / buf.length);
36
36
  // soft compression so loud peaks don't dominate the meter
37
37
  setLevel(Math.min(1, rms * 2.5));
@@ -97,7 +97,7 @@ export function useSpeechLanguageInfo(): SpeechLanguageInfo {
97
97
  const found = findSpeechLanguage(tag);
98
98
  return {
99
99
  tag,
100
- iso: found?.language.iso ?? tag.split('-')[0].toLowerCase(),
100
+ iso: found?.language.iso ?? (tag.split('-')[0] ?? tag).toLowerCase(),
101
101
  country: countryFromTag(tag),
102
102
  name: found?.language.name ?? null,
103
103
  englishName: found?.language.englishName ?? null,