@lowdefy/blocks-antd-x 5.6.0 → 6.1.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.
@@ -16,27 +16,36 @@
16
16
  import { useChat } from '@ai-sdk/react';
17
17
  import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
18
18
  import { FileCard, Prompts, Sender } from '@ant-design/x';
19
- import { Button } from 'antd';
19
+ import { Button, Skeleton } from 'antd';
20
20
  import { PaperClipOutlined } from '@ant-design/icons';
21
21
  import { isReserved, setKey, type } from '@lowdefy/helpers';
22
+ import getLegacyObjectUrl from '@lowdefy/blocks-files/utils/getLegacyObjectUrl.js';
23
+ import getUploadPolicy from '@lowdefy/blocks-files/utils/getUploadPolicy.js';
24
+ import uploadFile from '@lowdefy/blocks-files/utils/uploadFile.js';
22
25
  import { getFileCardType, getFileCardIcon } from './fileCardUtils.js';
23
26
  import DrawerWrapper from './DrawerWrapper.js';
24
27
  import createLowdefyChatTransport from './LowdefyChatTransport.js';
25
28
  import MessageList from './MessageList.js';
29
+ import replayableMessages from './replayableMessages.js';
26
30
  import useAgentEvents, { collectExternalEventIds } from './useAgentEvents.js';
27
31
  import WelcomeScreen from './WelcomeScreen.js';
28
32
  function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageId, properties }) {
29
- const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions } = properties;
33
+ const { agentId, urlQuery, sharedState, welcome, messageDisplay, sender, conversationId, feedbackValues: storedFeedbackValues, messages: externalMessages, display, drawer: drawerConfig, suggestions, loading } = properties;
30
34
  const senderRef = useRef(null);
31
35
  const finishMetaRef = useRef(null);
32
36
  const fileInputRef = useRef(null);
33
37
  const [attachedFiles, setAttachedFiles] = useState([]);
34
38
  const [dragOver, setDragOver] = useState(false);
39
+ // Controlled composer value, so a starter prompt (or the setInput method) can
40
+ // fill the box, and a send can leave typed text in place when it is rejected.
41
+ const [inputValue, setInputValue] = useState('');
35
42
  // Mirror the operator-evaluated sharedState object into a ref so transport.body()
36
43
  // sees the freshest value at send time without re-constructing the transport.
37
44
  const sharedStateRef = useRef(null);
38
45
  sharedStateRef.current = type.isObject(sharedState) && Object.keys(sharedState).length > 0 ? sharedState : null;
39
46
  const attachmentsConfig = sender?.attachments;
47
+ const uploadPolicyRequestId = attachmentsConfig?.uploadPolicyRequestId ?? attachmentsConfig?.s3PostPolicyRequestId;
48
+ const downloadPolicyRequestId = attachmentsConfig?.downloadPolicyRequestId;
40
49
  const switchConfigs = sender?.switches ?? [];
41
50
  const [headerOpen, setHeaderOpen] = useState(sender?.header?.open ?? true);
42
51
  const [switchState, setSwitchState] = useState(()=>{
@@ -68,6 +77,11 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
68
77
  urlQueryKey
69
78
  ]);
70
79
  const bubbleListRef = useRef(null);
80
+ // Call ids of client-side tools still running: their parts sit unanswered until
81
+ // addToolOutput, exactly like a cut-off call, and must survive the dead-call filter.
82
+ const pendingToolCallsRef = useRef(new Set());
83
+ // Read by the filter from mount-time method closures, which would see a stale `status`.
84
+ const statusRef = useRef('ready');
71
85
  const { messages, sendMessage, status, stop, addToolApprovalResponse, addToolOutput, setMessages, regenerate, clearError } = useChat({
72
86
  // Key the Chat instance by conversation: without an id, useChat creates its
73
87
  // Chat once per mount and CAPTURES that transport — the transport rebuilt by
@@ -85,6 +99,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
85
99
  async onToolCall ({ toolCall }) {
86
100
  if (toolCall.dynamic) return;
87
101
  if (toolCall.toolName === 'update-page-state') {
102
+ pendingToolCallsRef.current.add(toolCall.toolCallId);
88
103
  try {
89
104
  const updates = toolCall.input?.updates ?? {};
90
105
  // Allowlist writes against the keys currently exposed via sharedState —
@@ -131,10 +146,19 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
131
146
  state: 'output-error',
132
147
  errorText: err.message
133
148
  });
149
+ } finally{
150
+ pendingToolCallsRef.current.delete(toolCall.toolCallId);
134
151
  }
135
152
  }
136
153
  },
137
154
  onError: (error)=>{
155
+ // A request that fails after the stream's `start` chunk leaves the
156
+ // assistant shell the SDK pushed on that chunk — id set, parts: [] — in
157
+ // the message list. Left there, every later send fails UIMessage
158
+ // validation ("Message must contain at least one part") and the
159
+ // conversation is dead until a reload. Drop empty shells so the user can
160
+ // simply try again; there is nothing in them to show or to send.
161
+ setMessages((prev)=>prev.filter((m)=>m.role !== 'assistant' || (m.parts?.length ?? 0) > 0));
138
162
  methods.triggerEvent({
139
163
  name: 'onError',
140
164
  event: {
@@ -160,6 +184,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
160
184
  });
161
185
  }
162
186
  });
187
+ statusRef.current = status;
163
188
  // The control is otherwise write-only: it reports a rating and immediately forgets it,
164
189
  // so the thumb un-highlights on the next render and the message looks unrated. This holds
165
190
  // what was clicked THIS visit; the block still persists nothing itself.
@@ -190,13 +215,29 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
190
215
  effectiveConversationId,
191
216
  setMessages
192
217
  ]);
218
+ // Abort a reply still streaming into the conversation being left. useChat swaps its
219
+ // Chat instance during the render that changes the id, and the `stop` it returns is
220
+ // the NEW instance's — calling it in the effect above would stop an idle chat and
221
+ // leave the old request running, still firing onDataPart / onToolCall / onFinish
222
+ // into the page (and the server still generating) for a conversation no longer shown.
223
+ // A cleanup keyed only on the id closes over the previous render's `stop`, which is
224
+ // the old instance's. stop() on an idle chat is a no-op.
225
+ useEffect(()=>()=>stop(), [
226
+ effectiveConversationId
227
+ ]); // eslint-disable-line react-hooks/exhaustive-deps
193
228
  // Sync external messages when provided — undefined means "not provided" (no sync),
194
229
  // null means "clear messages", array means "load these messages".
195
230
  // Compare by count + last ID to avoid re-syncing on every Lowdefy re-render
196
231
  // (operators like _state create new array references even when data hasn't changed).
232
+ // The conversation id is part of the key: two conversations whose transcripts have the
233
+ // same length and last id (every assistant message an older onFinish hook persisted
234
+ // carries id '', so any two same-length transcripts collide) would otherwise not
235
+ // re-sync on a switch, leaving the new conversation's transcript empty.
236
+ // Seeded with the mount-time id so an undefined `messages` stays "no sync" on mount.
197
237
  const prevExternalRef = useRef({
198
238
  count: 0,
199
- lastId: null
239
+ lastId: null,
240
+ conversationId: effectiveConversationId
200
241
  });
201
242
  // Event-dedup ids of externally loaded messages. Restored history must not
202
243
  // replay onToolCall / onToolResult / onUserMessage / onTitleGenerated side
@@ -207,36 +248,38 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
207
248
  const msgs = externalMessages ?? [];
208
249
  const count = msgs.length;
209
250
  const lastId = count > 0 ? msgs[count - 1]?.id : null;
210
- if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId) {
251
+ if (count !== prevExternalRef.current.count || lastId !== prevExternalRef.current.lastId || effectiveConversationId !== prevExternalRef.current.conversationId) {
211
252
  prevExternalRef.current = {
212
253
  count,
213
- lastId
254
+ lastId,
255
+ conversationId: effectiveConversationId
214
256
  };
215
- externalIdsRef.current = collectExternalEventIds(msgs);
216
- setMessages(msgs);
257
+ const replayable = withoutDeadToolCalls(msgs);
258
+ externalIdsRef.current = collectExternalEventIds(replayable);
259
+ setMessages(replayable);
217
260
  }
218
261
  }, [
219
262
  externalMessages,
263
+ effectiveConversationId,
220
264
  setMessages
221
265
  ]);
222
266
  // Register CallMethod methods so YAML actions can control the chat.
223
267
  useEffect(()=>{
224
268
  methods.registerMethod('regenerate', (args)=>{
225
- regenerate(args?.messageId ? {
226
- messageId: args.messageId
227
- } : undefined);
269
+ if (sendingRef.current) return;
270
+ regenerateReply(args?.messageId);
228
271
  });
229
272
  methods.registerMethod('setMessages', (args)=>{
230
- const msgs = args?.messages ?? [];
273
+ const msgs = withoutDeadToolCalls(args?.messages ?? []);
231
274
  externalIdsRef.current = collectExternalEventIds(msgs);
232
275
  setMessages(msgs);
233
276
  });
234
277
  methods.registerMethod('sendMessage', (args)=>{
235
- if (args?.text) {
236
- sendMessage({
278
+ if (args?.text && !sendingRef.current) {
279
+ deliver({
237
280
  text: args.text,
238
281
  ...args.files ? {
239
- experimental_attachments: args.files
282
+ files: args.files
240
283
  } : {},
241
284
  ...args.metadata ? {
242
285
  metadata: args.metadata
@@ -244,6 +287,9 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
244
287
  });
245
288
  }
246
289
  });
290
+ methods.registerMethod('setInput', (args)=>{
291
+ setInputValue(typeof args?.text === 'string' ? args.text : '');
292
+ });
247
293
  methods.registerMethod('clearMessages', ()=>{
248
294
  setMessages([]);
249
295
  });
@@ -275,19 +321,36 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
275
321
  }
276
322
  ]
277
323
  });
278
- if (attachmentsConfig?.s3PostPolicyRequestId) {
324
+ if (uploadPolicyRequestId) {
325
+ if (attachmentsConfig?.s3PostPolicyRequestId) {
326
+ console.warn('AgentChat attachments property "s3PostPolicyRequestId" is deprecated. Use "uploadPolicyRequestId" instead.');
327
+ }
279
328
  methods.registerEvent({
280
- name: '__getS3PostPolicy',
329
+ name: '__getUploadPolicy',
281
330
  actions: [
282
331
  {
283
- id: '__getS3PostPolicy',
332
+ id: '__getUploadPolicy',
284
333
  type: 'Request',
285
334
  params: [
286
- attachmentsConfig.s3PostPolicyRequestId
335
+ uploadPolicyRequestId
287
336
  ]
288
337
  }
289
338
  ]
290
339
  });
340
+ if (downloadPolicyRequestId) {
341
+ methods.registerEvent({
342
+ name: '__getDownloadPolicy',
343
+ actions: [
344
+ {
345
+ id: '__getDownloadPolicy',
346
+ type: 'Request',
347
+ params: [
348
+ downloadPolicyRequestId
349
+ ]
350
+ }
351
+ ]
352
+ });
353
+ }
291
354
  }
292
355
  }, []);
293
356
  useAgentEvents({
@@ -300,6 +363,45 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
300
363
  });
301
364
  const isEmpty = messages.length === 0;
302
365
  const isBusy = status === 'streaming' || status === 'submitted';
366
+ // The app is still fetching this conversation's transcript (the `loading` property): a
367
+ // skeleton stands in for the message area and the composer is disabled, so nothing can
368
+ // be sent into a conversation whose history has not synced yet.
369
+ const isLoading = loading === true;
370
+ // A send awaits onBeforeSend while useChat still reports `ready`, so every submit during a
371
+ // slow quota check used to send again. The ref refuses re-entry (state is a render behind a
372
+ // double click); the state is what the Sender shows. Every other way of sending (prompts,
373
+ // suggestions, edit, regenerate, the sendMessage and regenerate methods) checks the ref too,
374
+ // or it would go out first and the composer's message straight after it.
375
+ const sendingRef = useRef(false);
376
+ const [sending, setSending] = useState(false);
377
+ // An unanswered tool call left by a reply cut off this session fails validation for the
378
+ // whole history, as one in a loaded history does. A reply still streaming is not cut off,
379
+ // and a client-side tool still running will add its output.
380
+ function withoutDeadToolCalls(list) {
381
+ if (statusRef.current === 'streaming' || statusRef.current === 'submitted') return list;
382
+ return replayableMessages({
383
+ messages: list,
384
+ liveToolCallIds: pendingToolCallsRef.current
385
+ });
386
+ }
387
+ // Every send goes through here. useChat applies the setter synchronously, so sendMessage
388
+ // reads the filtered list.
389
+ function deliver(message) {
390
+ setMessages((prev)=>withoutDeadToolCalls(prev));
391
+ sendMessage(message);
392
+ }
393
+ function regenerateReply(messageId) {
394
+ let kept = [];
395
+ setMessages((prev)=>{
396
+ kept = withoutDeadToolCalls(prev);
397
+ return kept;
398
+ });
399
+ // The reply asked for may have held nothing but a dead call and be gone; regenerate
400
+ // from the end instead of letting useChat throw on the missing id.
401
+ regenerate(kept.some((m)=>m.id === messageId) ? {
402
+ messageId
403
+ } : undefined);
404
+ }
303
405
  function fileToContentPart(file) {
304
406
  return new Promise((resolve)=>{
305
407
  const reader = new FileReader();
@@ -314,42 +416,41 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
314
416
  reader.readAsDataURL(file);
315
417
  });
316
418
  }
317
- async function uploadFileToS3(file) {
318
- const { name, size, type: fileType } = file;
319
- const s3PostPolicyResponse = await methods.triggerEvent({
320
- name: '__getS3PostPolicy',
321
- event: {
322
- file: {
323
- name,
324
- size,
325
- type: fileType
326
- }
327
- }
419
+ async function uploadFileToStorage(file) {
420
+ const { name, type: fileType } = file;
421
+ const descriptor = await getUploadPolicy({
422
+ methods,
423
+ file
328
424
  });
329
- if (s3PostPolicyResponse.success !== true) {
330
- throw new Error('S3 post policy request failed.');
331
- }
332
- const { url, fields = {} } = s3PostPolicyResponse.responses.__getS3PostPolicy.response[0];
333
- const { key } = fields;
334
- const formData = new FormData();
335
- Object.keys(fields).forEach((field)=>{
336
- formData.append(field, fields[field]);
425
+ await uploadFile({
426
+ descriptor,
427
+ file
337
428
  });
338
- formData.append('file', file);
339
- await new Promise((resolve, reject)=>{
340
- const xhr = new XMLHttpRequest();
341
- xhr.addEventListener('load', ()=>{
342
- if (xhr.status >= 200 && xhr.status < 300) {
343
- resolve();
344
- } else {
345
- reject(new Error(`S3 upload failed with status ${xhr.status}`));
429
+ // The model provider fetches this URL, so resolve it through the download
430
+ // request when configured (stable public URL or signed URL). Otherwise
431
+ // fall back to the legacy unsigned object URL from the descriptor.
432
+ let objectUrl;
433
+ if (downloadPolicyRequestId) {
434
+ const response = await methods.triggerEvent({
435
+ name: '__getDownloadPolicy',
436
+ event: {
437
+ file: {
438
+ bucket: descriptor.bucket,
439
+ key: descriptor.key,
440
+ name,
441
+ type: fileType
442
+ }
346
443
  }
347
444
  });
348
- xhr.addEventListener('error', ()=>reject(new Error('S3 upload network error')));
349
- xhr.open('POST', url);
350
- xhr.send(formData);
351
- });
352
- const objectUrl = url.endsWith('/') ? `${url}${key}` : `${url}/${key}`;
445
+ if (response.success !== true) {
446
+ throw new Error('Download policy request failed.');
447
+ }
448
+ objectUrl = response.responses.__getDownloadPolicy.response[0];
449
+ } else {
450
+ objectUrl = getLegacyObjectUrl({
451
+ descriptor
452
+ });
453
+ }
353
454
  return {
354
455
  url: objectUrl,
355
456
  mediaType: fileType,
@@ -393,7 +494,18 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
393
494
  return Array.from(e.dataTransfer?.types ?? []).includes('Files');
394
495
  }
395
496
  async function handleSend(text) {
497
+ if (sendingRef.current) return;
396
498
  if (!text.trim() && attachedFiles.length === 0) return;
499
+ sendingRef.current = true;
500
+ setSending(true);
501
+ try {
502
+ await submitMessage(text);
503
+ } finally{
504
+ sendingRef.current = false;
505
+ setSending(false);
506
+ }
507
+ }
508
+ async function submitMessage(text) {
397
509
  const filesMeta = attachedFiles.map((file)=>({
398
510
  name: file.name,
399
511
  size: file.size,
@@ -417,9 +529,9 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
417
529
  text
418
530
  }
419
531
  ];
420
- if (attachmentsConfig?.s3PostPolicyRequestId) {
532
+ if (uploadPolicyRequestId) {
421
533
  try {
422
- const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToS3(file)));
534
+ const uploadResults = await Promise.all(attachedFiles.map((file)=>uploadFileToStorage(file)));
423
535
  for (const result of uploadResults){
424
536
  parts.push({
425
537
  type: 'file',
@@ -432,7 +544,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
432
544
  methods.triggerEvent({
433
545
  name: 'onError',
434
546
  event: {
435
- message: error.message ?? 'File upload to S3 failed.'
547
+ message: error.message ?? 'File upload failed.'
436
548
  }
437
549
  });
438
550
  return;
@@ -443,16 +555,21 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
443
555
  parts.push(part);
444
556
  }
445
557
  }
446
- sendMessage({
558
+ deliver({
447
559
  parts
448
560
  });
449
561
  setAttachedFiles([]);
450
562
  } else {
451
- sendMessage({
563
+ deliver({
452
564
  text
453
565
  });
454
566
  }
455
- senderRef.current?.clear();
567
+ // Empty the composer here, after the sends and downstream of both the
568
+ // onBeforeSend cancellation return and the upload await, so a send that was
569
+ // rejected or failed to upload leaves the user's text in the box. Resetting
570
+ // from the Sender's onSubmit handler instead would silently lose typed input
571
+ // on every rejected send.
572
+ setInputValue('');
456
573
  }
457
574
  function handleStop() {
458
575
  stop();
@@ -464,18 +581,26 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
464
581
  });
465
582
  }
466
583
  function handlePromptClick(prompt) {
467
- sendMessage({
584
+ if (sendingRef.current) return;
585
+ deliver({
468
586
  text: prompt.label
469
587
  });
470
588
  }
589
+ // A two-track welcome starter fills the composer instead of sending, so a
590
+ // generic shipped default is an editable first draft rather than a message the
591
+ // user never meant to send. Reuses the controlled Sender value.
592
+ function handleWelcomePromptFill(text) {
593
+ setInputValue(typeof text === 'string' ? text : '');
594
+ }
471
595
  function handleSuggestionClick(suggestion) {
596
+ if (sendingRef.current) return;
472
597
  methods.triggerEvent({
473
598
  name: 'onSuggestionClick',
474
599
  event: {
475
600
  suggestion
476
601
  }
477
602
  });
478
- sendMessage({
603
+ deliver({
479
604
  text: suggestion.label
480
605
  });
481
606
  }
@@ -505,6 +630,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
505
630
  messages
506
631
  ]);
507
632
  const activeSuggestions = agentSuggestions ?? suggestions;
633
+ // Hidden while a composer send awaits onBeforeSend as well as while a reply streams.
634
+ const showSuggestions = !isEmpty && !isLoading && (activeSuggestions?.length ?? 0) > 0 && !isBusy && !sending;
508
635
  // A link in an answer was a plain anchor: a full browser navigation out of the
509
636
  // conversation, with no way for an app to do anything else with it. Default is only
510
637
  // prevented when the app actually handles onLinkClick — otherwise the link keeps
@@ -551,6 +678,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
551
678
  });
552
679
  }
553
680
  function handleRegenerate({ messageId }) {
681
+ if (sendingRef.current) return;
554
682
  methods.triggerEvent({
555
683
  name: 'onRegenerate',
556
684
  event: {
@@ -558,11 +686,10 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
558
686
  messages
559
687
  }
560
688
  });
561
- regenerate({
562
- messageId
563
- });
689
+ regenerateReply(messageId);
564
690
  }
565
691
  function handleEditMessage({ messageId, originalContent, newContent }) {
692
+ if (sendingRef.current) return;
566
693
  methods.triggerEvent({
567
694
  name: 'onEditMessage',
568
695
  event: {
@@ -575,7 +702,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
575
702
  const messageIndex = messages.findIndex((m)=>m.id === messageId);
576
703
  if (messageIndex >= 0) {
577
704
  setMessages((prev)=>prev.slice(0, messageIndex));
578
- sendMessage({
705
+ deliver({
579
706
  text: newContent
580
707
  });
581
708
  }
@@ -609,7 +736,36 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
609
736
  minHeight: 0,
610
737
  padding: '16px 0'
611
738
  }
612
- }, isEmpty ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
739
+ }, isLoading ? /*#__PURE__*/ React.createElement("div", {
740
+ className: "agent-chat-loading",
741
+ style: {
742
+ display: 'flex',
743
+ flexDirection: 'column',
744
+ gap: 24,
745
+ padding: '0 16px'
746
+ }
747
+ }, /*#__PURE__*/ React.createElement(Skeleton, {
748
+ active: true,
749
+ title: false,
750
+ paragraph: {
751
+ rows: 2,
752
+ width: [
753
+ '60%',
754
+ '40%'
755
+ ]
756
+ }
757
+ }), /*#__PURE__*/ React.createElement(Skeleton, {
758
+ active: true,
759
+ title: false,
760
+ paragraph: {
761
+ rows: 3,
762
+ width: [
763
+ '90%',
764
+ '75%',
765
+ '50%'
766
+ ]
767
+ }
768
+ })) : isEmpty && !welcome?.tracks ? /*#__PURE__*/ React.createElement(WelcomeScreen, {
613
769
  config: welcome,
614
770
  onPromptClick: handlePromptClick
615
771
  }) : /*#__PURE__*/ React.createElement(MessageList, {
@@ -617,6 +773,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
617
773
  messages: messages,
618
774
  isStreaming: isBusy,
619
775
  config: messageDisplay,
776
+ welcome: welcome,
777
+ onWelcomePromptFill: handleWelcomePromptFill,
620
778
  addToolApprovalResponse: addToolApprovalResponse,
621
779
  onFeedback: handleFeedback,
622
780
  onLinkClick: handleLinkClick,
@@ -626,7 +784,7 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
626
784
  onDelete: handleDelete,
627
785
  onEditMessage: handleEditMessage,
628
786
  translate: methods.translate
629
- })), !isEmpty && activeSuggestions && activeSuggestions.length > 0 && !isBusy && /*#__PURE__*/ React.createElement("div", {
787
+ })), showSuggestions && /*#__PURE__*/ React.createElement("div", {
630
788
  style: {
631
789
  padding: '0 16px 8px'
632
790
  }
@@ -699,6 +857,8 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
699
857
  }
700
858
  }), /*#__PURE__*/ React.createElement(Sender, {
701
859
  ref: senderRef,
860
+ value: inputValue,
861
+ onChange: setInputValue,
702
862
  placeholder: sender?.placeholder ?? methods.translate('agent.sender.placeholder'),
703
863
  submitType: sender?.submitType ?? 'enter',
704
864
  allowSpeech: sender?.allowSpeech ?? false,
@@ -708,8 +868,12 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
708
868
  onPasteFile: attachmentsConfig?.enabled ? (files)=>addFiles(files, {
709
869
  pasted: true
710
870
  }) : undefined,
711
- onCancel: handleStop,
712
- loading: isBusy,
871
+ // Not during the onBeforeSend wait: nothing is streaming, so onStop would be false.
872
+ onCancel: isBusy ? handleStop : undefined,
873
+ loading: isBusy || sending,
874
+ disabled: isLoading,
875
+ styles: sender?.styles,
876
+ classNames: sender?.classNames,
713
877
  prefix: attachmentsConfig?.enabled ? /*#__PURE__*/ React.createElement(Button, {
714
878
  type: "text",
715
879
  icon: /*#__PURE__*/ React.createElement(PaperClipOutlined, null),
@@ -741,7 +905,6 @@ function AgentChat({ blockId, components: { Icon, Link }, events, methods, pageI
741
905
  }
742
906
  AgentChat.meta = {
743
907
  category: 'display',
744
- icons: [],
745
- styles: []
908
+ icons: []
746
909
  };
747
910
  export default AgentChat;
@@ -14,7 +14,9 @@
14
14
  limitations under the License.
15
15
  */ import { DefaultChatTransport } from 'ai';
16
16
  function createLowdefyChatTransport({ pageId, agentId, conversationId, urlQuery, sharedStateRef }) {
17
- const base = `/api/agent/${pageId}/${agentId}`;
17
+ // The server route takes the last path segment as the agentId — module-scoped
18
+ // agent ids contain '/', so encode the agentId into a single segment.
19
+ const base = `/api/agent/${pageId}/${encodeURIComponent(agentId)}`;
18
20
  const api = conversationId ? `${base}?conversationId=${encodeURIComponent(conversationId)}` : base;
19
21
  return new DefaultChatTransport({
20
22
  api,
@@ -21,6 +21,7 @@ import { hasVisibleContent } from './messageParts.js';
21
21
  import MessageBubble from './MessageBubble.js';
22
22
  import ThinkingIndicator from './ThinkingIndicator.js';
23
23
  import useThinkingMessage from './useThinkingMessage.js';
24
+ import WelcomeScreen from './WelcomeScreen.js';
24
25
  function roleAvatar(roleConfig, fallbackIcon) {
25
26
  if (roleConfig?.avatar) {
26
27
  return /*#__PURE__*/ React.createElement(Avatar, {
@@ -55,7 +56,7 @@ function ThinkingBubbleContent({ bubbleId, config }) {
55
56
  label: label
56
57
  });
57
58
  }
58
- const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
59
+ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messages, isStreaming, config, welcome, onWelcomePromptFill, addToolApprovalResponse, onFeedback, onLinkClick, Link, feedbackValues, onRegenerate, onDelete, onEditMessage, translate }, ref) {
59
60
  // Build a lookup map for message parts.
60
61
  // Bubble.List's contentRender callback only receives (content, info) where info.key is the
61
62
  // item key — it does not receive the full message object with its parts array. This map
@@ -119,14 +120,42 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
119
120
  streaming: true
120
121
  });
121
122
  }
123
+ // The welcome renders as a leading item so it scrolls up with the transcript
124
+ // and stays reachable by scrolling back, rather than being swapped out on the
125
+ // first send. With no messages yet it is simply the only item in the list.
126
+ if (welcome?.tracks) {
127
+ items.unshift({
128
+ key: '__welcome__',
129
+ role: 'welcome'
130
+ });
131
+ }
122
132
  return /*#__PURE__*/ React.createElement(Bubble.List, {
123
133
  ref: ref,
124
134
  items: items,
125
135
  role: {
136
+ welcome: {
137
+ placement: 'start',
138
+ variant: 'borderless',
139
+ style: {
140
+ maxWidth: '100%'
141
+ },
142
+ styles: {
143
+ content: {
144
+ width: '100%'
145
+ }
146
+ },
147
+ contentRender: ()=>/*#__PURE__*/ React.createElement(WelcomeScreen, {
148
+ config: welcome,
149
+ onFill: onWelcomePromptFill,
150
+ inFlow: true
151
+ })
152
+ },
126
153
  user: {
127
154
  placement: 'end',
128
155
  variant: config?.roles?.user?.variant ?? 'filled',
129
156
  shape: config?.roles?.user?.shape ?? 'round',
157
+ styles: config?.roles?.user?.styles,
158
+ classNames: config?.roles?.user?.classNames,
130
159
  avatar: roleAvatar(config?.roles?.user, /*#__PURE__*/ React.createElement(UserOutlined, null)),
131
160
  header: roleHeader(config?.roles?.user, 'agent.message.userHeader', translate),
132
161
  editable: config?.editableMessages !== false ? {
@@ -173,6 +202,8 @@ const MessageList = /*#__PURE__*/ React.forwardRef(function MessageList({ messag
173
202
  style: {
174
203
  maxWidth: '100%'
175
204
  },
205
+ styles: config?.roles?.assistant?.styles,
206
+ classNames: config?.roles?.assistant?.classNames,
176
207
  avatar: roleAvatar(config?.roles?.assistant, /*#__PURE__*/ React.createElement(RobotOutlined, null)),
177
208
  header: roleHeader(config?.roles?.assistant, 'agent.message.assistantHeader', translate),
178
209
  typing: config?.roles?.assistant?.typing ? config.roles.assistant.typing : {
@@ -14,8 +14,95 @@
14
14
  limitations under the License.
15
15
  */ import React from 'react';
16
16
  import { Welcome, Prompts } from '@ant-design/x';
17
- function WelcomeScreen({ config, onPromptClick }) {
17
+ import { Button } from 'antd';
18
+ // One track: a bordered card holding a heading and a column of starter chips.
19
+ // A chip fills the composer (onFill) rather than sending — a near-miss starter
20
+ // becomes an editable first draft, not a message the user never meant to send.
21
+ function TrackCard({ track, onFill }) {
22
+ const prompts = track.prompts ?? [];
23
+ return /*#__PURE__*/ React.createElement("div", {
24
+ style: {
25
+ flex: '1 1 240px',
26
+ border: '1px solid var(--ant-color-border)',
27
+ borderRadius: 'var(--ant-border-radius-lg)',
28
+ padding: 16,
29
+ backgroundColor: 'var(--ant-color-bg-container)',
30
+ display: 'flex',
31
+ flexDirection: 'column',
32
+ gap: 8
33
+ }
34
+ }, track.label && /*#__PURE__*/ React.createElement("h5", {
35
+ style: {
36
+ margin: '0 0 4px',
37
+ fontSize: 'var(--ant-font-size-heading-5)',
38
+ fontWeight: 'var(--ant-font-weight-strong, 600)',
39
+ lineHeight: 'var(--ant-line-height-heading-5)'
40
+ }
41
+ }, track.label), prompts.map((prompt, index)=>{
42
+ const text = typeof prompt === 'string' ? prompt : prompt.label;
43
+ return /*#__PURE__*/ React.createElement(Button, {
44
+ key: index,
45
+ block: true,
46
+ // antd buttons are nowrap and size to their longest line; height auto
47
+ // plus whiteSpace normal lets a sentence-length starter wrap left.
48
+ style: {
49
+ height: 'auto',
50
+ whiteSpace: 'normal',
51
+ textAlign: 'left',
52
+ paddingTop: 6,
53
+ paddingBottom: 6
54
+ },
55
+ onClick: ()=>onFill?.(text)
56
+ }, text);
57
+ }));
58
+ }
59
+ // The chat empty state. Two modes:
60
+ // - `config.tracks` present → a two-track teaching panel (ask a question /
61
+ // build a report), each track a card of fill-the-composer starters. When
62
+ // `inFlow` it renders top-aligned as a leading item inside the message
63
+ // scroll area, so it scrolls up with the conversation and stays reachable
64
+ // by scrolling back rather than being swapped out on the first send.
65
+ // - otherwise → the flat Welcome + Prompts row, centred, sending on click.
66
+ function WelcomeScreen({ config, onPromptClick, onFill, inFlow }) {
18
67
  if (!config) return null;
68
+ if (config.tracks) {
69
+ return /*#__PURE__*/ React.createElement("div", {
70
+ style: inFlow ? {
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ gap: 16,
74
+ width: '100%'
75
+ } : {
76
+ flex: 1,
77
+ display: 'flex',
78
+ flexDirection: 'column',
79
+ alignItems: 'center',
80
+ justifyContent: 'center',
81
+ gap: 16
82
+ }
83
+ }, config.title && /*#__PURE__*/ React.createElement("h3", {
84
+ style: {
85
+ margin: 0,
86
+ fontSize: 'var(--ant-font-size-heading-3)',
87
+ fontWeight: 'var(--ant-font-weight-strong, 600)',
88
+ lineHeight: 'var(--ant-line-height-heading-3)'
89
+ }
90
+ }, config.title), config.description && /*#__PURE__*/ React.createElement("div", {
91
+ style: {
92
+ color: 'var(--ant-color-text-secondary)'
93
+ }
94
+ }, config.description), /*#__PURE__*/ React.createElement("div", {
95
+ style: {
96
+ display: 'flex',
97
+ gap: 12,
98
+ flexWrap: 'wrap'
99
+ }
100
+ }, config.tracks.map((track, index)=>/*#__PURE__*/ React.createElement(TrackCard, {
101
+ key: index,
102
+ track: track,
103
+ onFill: onFill
104
+ }))));
105
+ }
19
106
  const promptItems = (config.prompts ?? []).map((prompt, index)=>({
20
107
  key: prompt.key ?? `prompt-${index}`,
21
108
  label: prompt.label,
@@ -0,0 +1,60 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ // A reply cut off mid-stream leaves its tool call with no result, and UIMessage validation
17
+ // rejects the whole history for one such part: every later send fails, and a history stored
18
+ // that way fails on every load. Approval states stay; a pending approval is answerable on resume.
19
+ // A client-side tool the block is still running is in the same state, so its call id is in
20
+ // liveToolCallIds and the part is kept: its output is on the way.
21
+ const UNANSWERED = new Set([
22
+ 'input-streaming',
23
+ 'input-available'
24
+ ]);
25
+ function isDeadToolPart({ part, liveToolCallIds }) {
26
+ const partType = part?.type;
27
+ const isTool = partType === 'dynamic-tool' || type.isString(partType) && partType.startsWith('tool-');
28
+ return isTool && UNANSWERED.has(part.state) && !liveToolCallIds.has(part.toolCallId);
29
+ }
30
+ function replayableMessages({ messages, liveToolCallIds = new Set() }) {
31
+ const list = messages ?? [];
32
+ let changed = false;
33
+ const result = [];
34
+ for (const message of list){
35
+ if (!type.isArray(message?.parts)) {
36
+ result.push(message);
37
+ continue;
38
+ }
39
+ const parts = message.parts.filter((part)=>!isDeadToolPart({
40
+ part,
41
+ liveToolCallIds
42
+ }));
43
+ // Only a step marker left is the empty shell onError already drops.
44
+ if (!parts.some((part)=>part?.type !== 'step-start')) {
45
+ changed = true;
46
+ continue;
47
+ }
48
+ if (parts.length === message.parts.length) {
49
+ result.push(message);
50
+ continue;
51
+ }
52
+ changed = true;
53
+ result.push({
54
+ ...message,
55
+ parts
56
+ });
57
+ }
58
+ return changed ? result : list;
59
+ }
60
+ export default replayableMessages;
@@ -137,6 +137,14 @@
137
137
  "shape": {
138
138
  "type": "string",
139
139
  "enum": ["default", "round", "corner"]
140
+ },
141
+ "styles": {
142
+ "type": "object",
143
+ "description": "Inline styles per bubble part: root, content, body, header, footer, avatar, extra. The way to recolour a bubble: the filled and outlined colours come from the theme's colorPrimary and are not reachable from a stylesheet."
144
+ },
145
+ "classNames": {
146
+ "type": "object",
147
+ "description": "Class names per bubble part, same keys as styles."
140
148
  }
141
149
  }
142
150
  },
@@ -152,6 +160,14 @@
152
160
  "shape": {
153
161
  "type": "string",
154
162
  "enum": ["default", "round", "corner"]
163
+ },
164
+ "styles": {
165
+ "type": "object",
166
+ "description": "Inline styles per bubble part: root, content, body, header, footer, avatar, extra."
167
+ },
168
+ "classNames": {
169
+ "type": "object",
170
+ "description": "Class names per bubble part, same keys as styles."
155
171
  }
156
172
  }
157
173
  }
@@ -188,6 +204,14 @@
188
204
  "default": "enter"
189
205
  },
190
206
  "allowSpeech": { "type": "boolean", "default": false },
207
+ "styles": {
208
+ "type": "object",
209
+ "description": "Inline styles per composer part: root, prefix, input, suffix, footer, switch, content. The way to recolour the composer border, which the theme sets with a precedence no page stylesheet beats."
210
+ },
211
+ "classNames": {
212
+ "type": "object",
213
+ "description": "Class names per composer part, same keys as styles."
214
+ },
191
215
  "attachments": {
192
216
  "type": "object",
193
217
  "description": "File attachments on the composer. When enabled, files can be picked with the paperclip, pasted from the clipboard (e.g. a screenshot) or dragged onto the composer; accept and maxSize apply to all three.",
@@ -201,9 +225,17 @@
201
225
  "type": "number",
202
226
  "description": "Maximum file size in bytes. Larger files are silently skipped."
203
227
  },
228
+ "uploadPolicyRequestId": {
229
+ "type": "string",
230
+ "description": "Id of an upload-policy request (e.g. AwsS3PresignedPostPolicy, GcsSignedPostPolicy, AzureBlobUploadSas). When set, files are uploaded to storage and their URLs are sent to the LLM instead of base64 data."
231
+ },
232
+ "downloadPolicyRequestId": {
233
+ "type": "string",
234
+ "description": "Id of a download request used to resolve the URL of an uploaded attachment. The URL is fetched by the model provider, so it should remain reachable — use public: true on the request for public buckets, or a long expiry. When unset, a legacy unsigned object URL is constructed from the upload response (S3-shaped, deprecated)."
235
+ },
204
236
  "s3PostPolicyRequestId": {
205
237
  "type": "string",
206
- "description": "Request ID of type AwsS3PresignedPostPolicy. When set, files are uploaded to S3 and their URLs are sent to the LLM instead of base64 data."
238
+ "description": "Deprecated — use uploadPolicyRequestId instead."
207
239
  }
208
240
  }
209
241
  },
@@ -235,6 +267,11 @@
235
267
  "title": { "type": "string" }
236
268
  }
237
269
  },
270
+ "loading": {
271
+ "type": "boolean",
272
+ "default": false,
273
+ "description": "The app is still loading this conversation's transcript. While true, a skeleton replaces the message area, suggestions are hidden and the composer is disabled, so nothing can be sent into a conversation whose messages have not synced yet."
274
+ },
238
275
  "suggestions": {
239
276
  "type": "array",
240
277
  "items": {
@@ -0,0 +1,339 @@
1
+ # Copyright 2020-2026 Lowdefy, Inc
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ id: agent-chat
16
+ type: PageHeaderMenu
17
+ properties:
18
+ title: AgentChat
19
+ blocks:
20
+ # Both chats are driven from the messages property, so nothing here calls the agent.
21
+ - id: chat_wired
22
+ type: AgentChat
23
+ properties:
24
+ agentId: test_agent
25
+ height: 300
26
+ conversationId: wired
27
+ messageDisplay:
28
+ actions:
29
+ - feedback
30
+ messages:
31
+ - id: w1
32
+ role: assistant
33
+ parts:
34
+ - type: text
35
+ text: |
36
+ [in-app link](/agent-chat-target) and [external link](https://example.com)
37
+ and [**bold** link](/agent-chat-target?tab=two)
38
+ events:
39
+ onFeedback:
40
+ - id: record_rating
41
+ type: SetState
42
+ params:
43
+ last_rating:
44
+ _event: rating
45
+ onLinkClick:
46
+ - id: record_link_click
47
+ type: SetState
48
+ params:
49
+ last_href:
50
+ _event: href
51
+ last_text:
52
+ _event: text
53
+ click_count:
54
+ _sum:
55
+ - _if_none:
56
+ - _state: click_count
57
+ - 0
58
+ - 1
59
+
60
+ # Seeded from feedbackValues rather than from a click: what a reload or a conversation
61
+ # switch looks like to an app that stored the rating itself.
62
+ - id: chat_seeded
63
+ type: AgentChat
64
+ properties:
65
+ agentId: test_agent
66
+ height: 300
67
+ conversationId: seeded
68
+ messageDisplay:
69
+ actions:
70
+ - feedback
71
+ feedbackValues:
72
+ s1: like
73
+ messages:
74
+ - id: s1
75
+ role: assistant
76
+ parts:
77
+ - type: text
78
+ text: A rated answer.
79
+ events:
80
+ onFeedback:
81
+ - id: record_seeded_rating
82
+ type: SetState
83
+ params:
84
+ seeded_rating:
85
+ _event: rating
86
+
87
+ - id: wired_readout
88
+ type: Html
89
+ properties:
90
+ html:
91
+ _nunjucks:
92
+ template: |
93
+ <span id="readout_href">{{ href }}</span>
94
+ <span id="readout_text">{{ text }}</span>
95
+ <span id="readout_count">{{ count or 0 }}</span>
96
+ <span id="readout_rating">{{ rating }}</span>
97
+ <span id="readout_seeded_rating">{{ seeded_rating }}</span>
98
+ on:
99
+ href:
100
+ _state: last_href
101
+ text:
102
+ _state: last_text
103
+ count:
104
+ _state: click_count
105
+ rating:
106
+ _state: last_rating
107
+ seeded_rating:
108
+ _state: seeded_rating
109
+
110
+ - id: chat_unwired
111
+ type: AgentChat
112
+ properties:
113
+ agentId: test_agent
114
+ height: 300
115
+ conversationId: unwired
116
+ messages:
117
+ - id: u1
118
+ role: assistant
119
+ parts:
120
+ - type: text
121
+ text: '[in-app link](/agent-chat-target) and [external link](https://example.com)'
122
+
123
+ # A history stored after a reply was cut off mid-stream: c2's tool call never got its result,
124
+ # c3 is nothing but that, and c4 is a call that completed and must be left alone.
125
+ - id: chat_cutoff
126
+ type: AgentChat
127
+ properties:
128
+ agentId: test_agent
129
+ height: 300
130
+ conversationId: cutoff
131
+ messages:
132
+ - id: c1
133
+ role: user
134
+ parts:
135
+ - type: text
136
+ text: What is in this repo?
137
+ - id: c2
138
+ role: assistant
139
+ parts:
140
+ - type: step-start
141
+ - type: text
142
+ text: Let me look.
143
+ - type: tool-search
144
+ toolCallId: tc1
145
+ state: input-available
146
+ input: {}
147
+ - id: c3
148
+ role: assistant
149
+ parts:
150
+ - type: step-start
151
+ - type: tool-fetch
152
+ toolCallId: tc2
153
+ state: input-available
154
+ input: {}
155
+ - id: c4
156
+ role: assistant
157
+ parts:
158
+ - type: tool-lookup
159
+ toolCallId: tc3
160
+ state: output-available
161
+ input: {}
162
+ output:
163
+ ok: true
164
+ - type: text
165
+ text: Done looking.
166
+
167
+ # onBeforeSend that takes as long as a quota request might, counts how often it ran, then
168
+ # refuses the send so nothing ever calls the agent.
169
+ - id: chat_guarded
170
+ type: AgentChat
171
+ properties:
172
+ agentId: test_agent
173
+ height: 300
174
+ conversationId: guarded
175
+ messages:
176
+ - id: g1
177
+ role: assistant
178
+ parts:
179
+ - type: text
180
+ text: Ask me something.
181
+ events:
182
+ onBeforeSend:
183
+ - id: count_before_send
184
+ type: SetState
185
+ params:
186
+ before_send_count:
187
+ _sum:
188
+ - _if_none:
189
+ - _state: before_send_count
190
+ - 0
191
+ - 1
192
+ - id: slow_check
193
+ type: Wait
194
+ params:
195
+ ms: 1500
196
+ - id: refuse
197
+ type: Throw
198
+ params:
199
+ throw: true
200
+ message: Refused by the test.
201
+
202
+ - id: guarded_readout
203
+ type: Html
204
+ properties:
205
+ html:
206
+ _nunjucks:
207
+ template: '<span id="readout_before_send">{{ n or 0 }}</span>'
208
+ on:
209
+ n:
210
+ _state: before_send_count
211
+
212
+ - id: chat_styled
213
+ type: AgentChat
214
+ properties:
215
+ agentId: test_agent
216
+ height: 300
217
+ conversationId: styled
218
+ messageDisplay:
219
+ roles:
220
+ assistant:
221
+ styles:
222
+ content:
223
+ backgroundColor: 'rgb(1, 2, 3)'
224
+ user:
225
+ classNames:
226
+ content: styled-user-content
227
+ sender:
228
+ styles:
229
+ root:
230
+ borderColor: 'rgb(4, 5, 6)'
231
+ messages:
232
+ - id: y1
233
+ role: user
234
+ parts:
235
+ - type: text
236
+ text: Styled question
237
+ - id: y2
238
+ role: assistant
239
+ parts:
240
+ - type: text
241
+ text: Styled answer
242
+
243
+ # Two conversations with the SAME message count and the same last id (''), the shape
244
+ # every persisted transcript had before assistant messages were given ids. Switching
245
+ # between them has to re-sync the transcript from `messages`.
246
+ - id: switch_to_a
247
+ type: Button
248
+ properties:
249
+ title: Switch to A
250
+ events:
251
+ onClick:
252
+ - id: set_a
253
+ type: SetState
254
+ params:
255
+ switch_id: conv-a
256
+ - id: switch_to_b
257
+ type: Button
258
+ properties:
259
+ title: Switch to B
260
+ events:
261
+ onClick:
262
+ - id: set_b
263
+ type: SetState
264
+ params:
265
+ switch_id: conv-b
266
+ - id: chat_switch
267
+ type: AgentChat
268
+ properties:
269
+ agentId: test_agent
270
+ height: 300
271
+ conversationId:
272
+ _if_none:
273
+ - _state: switch_id
274
+ - conv-a
275
+ messages:
276
+ _if:
277
+ test:
278
+ _eq:
279
+ - _if_none:
280
+ - _state: switch_id
281
+ - conv-a
282
+ - conv-b
283
+ then:
284
+ - id: b-user
285
+ role: user
286
+ parts:
287
+ - type: text
288
+ text: Question in B
289
+ - id: ''
290
+ role: assistant
291
+ parts:
292
+ - type: text
293
+ text: Answer from conversation B.
294
+ else:
295
+ - id: a-user
296
+ role: user
297
+ parts:
298
+ - type: text
299
+ text: Question in A
300
+ - id: ''
301
+ role: assistant
302
+ parts:
303
+ - type: text
304
+ text: Answer from conversation A.
305
+
306
+ # The loading property: a skeleton in place of the transcript, composer disabled.
307
+ - id: toggle_loading
308
+ type: Button
309
+ properties:
310
+ title: Toggle loading
311
+ events:
312
+ onClick:
313
+ - id: flip_loading
314
+ type: SetState
315
+ params:
316
+ chat_loading:
317
+ _not:
318
+ _eq:
319
+ - _state: chat_loading
320
+ - true
321
+ - id: chat_loading
322
+ type: AgentChat
323
+ properties:
324
+ agentId: test_agent
325
+ height: 300
326
+ conversationId: loading
327
+ loading:
328
+ _eq:
329
+ - _state: chat_loading
330
+ - true
331
+ suggestions:
332
+ - key: s
333
+ label: A suggestion
334
+ messages:
335
+ - id: l1
336
+ role: assistant
337
+ parts:
338
+ - type: text
339
+ text: Loaded answer.
@@ -0,0 +1,25 @@
1
+ # Copyright 2020-2026 Lowdefy, Inc
2
+
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ id: agent-chat-target
16
+ type: PageHeaderMenu
17
+ properties:
18
+ title: Link target
19
+ blocks:
20
+ - id: target_marker
21
+ type: Html
22
+ properties:
23
+ # No id on the span: Lowdefy renders the block id into the DOM, so repeating it here
24
+ # gives two matching elements and a strict-mode violation.
25
+ html: <span>arrived</span>
@@ -82,9 +82,6 @@ function AgentConversations({ blockId, methods, properties }) {
82
82
  }
83
83
  AgentConversations.meta = {
84
84
  category: 'display',
85
- icons: [],
86
- styles: [
87
- 'blocks/AgentConversations/style.less'
88
- ]
85
+ icons: []
89
86
  };
90
87
  export default AgentConversations;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/blocks-antd-x",
3
- "version": "5.6.0",
3
+ "version": "6.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy Ant Design X Blocks",
6
6
  "homepage": "https://lowdefy.com",
@@ -41,17 +41,19 @@
41
41
  "@ant-design/icons": "6.1.0",
42
42
  "@ant-design/x": "2.7.0",
43
43
  "@ant-design/x-markdown": "2.7.0",
44
- "@lowdefy/block-utils": "5.6.0",
45
- "@lowdefy/helpers": "5.6.0",
44
+ "@lowdefy/block-utils": "6.1.0",
45
+ "@lowdefy/blocks-files": "6.1.0",
46
+ "@lowdefy/helpers": "6.1.0",
46
47
  "ai": "6.0.176"
47
48
  },
48
49
  "devDependencies": {
49
- "@lowdefy/block-dev-e2e": "5.6.0",
50
- "@lowdefy/e2e-utils": "5.6.0",
51
- "@playwright/test": "1.50.1",
52
- "@swc/cli": "0.8.0",
53
- "@swc/core": "1.15.18",
54
- "copyfiles": "2.4.1"
50
+ "@lowdefy/block-dev-e2e": "6.1.0",
51
+ "@lowdefy/e2e-utils": "6.1.0",
52
+ "@playwright/test": "1.59.1",
53
+ "@swc/cli": "0.8.1",
54
+ "@swc/core": "1.15.32",
55
+ "copyfiles": "2.4.1",
56
+ "react-dom": "18.2.0"
55
57
  },
56
58
  "peerDependencies": {
57
59
  "antd": "6.3.1",
@@ -63,7 +65,7 @@
63
65
  "scripts": {
64
66
  "build": "swc src --out-dir dist --config-file ../../../../.swcrc --cli-config-file ../../../../.swc-cli.json && pnpm copyfiles",
65
67
  "clean": "rm -rf dist",
66
- "copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.yaml\" -e \"./src/**/*.snap\"",
68
+ "copyfiles": "copyfiles -u 1 \"./src/**/*\" dist -e \"./src/**/*.js\" -e \"./src/**/*.snap\"",
67
69
  "e2e": "playwright test --config e2e/playwright.config.js",
68
70
  "e2e:ui": "playwright test --config e2e/playwright.config.js --ui"
69
71
  }
@@ -1 +0,0 @@
1
- // AgentConversations styles — currently uses inline styles and Ant Design X defaults.