@adecore/agents-react 0.20.0 → 0.21.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.
Files changed (134) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/logic/generated-image.d.ts +27 -0
  3. package/dist/chat/logic/generated-image.js +32 -0
  4. package/dist/chat/logic/timeline-target.d.ts +19 -1
  5. package/dist/chat/logic/timeline-target.js +49 -3
  6. package/dist/chat/logic/timeline.js +25 -17
  7. package/dist/chat/subagent-conversation.d.ts +2 -1
  8. package/dist/chat/subagent-conversation.js +7 -1
  9. package/dist/chat/timeline-flash.d.ts +3 -1
  10. package/dist/chat/timeline-flash.js +2 -2
  11. package/dist/chat/ui/CodeBlock.d.ts +3 -1
  12. package/dist/chat/ui/CodeBlock.js +38 -7
  13. package/dist/chat/ui/Composer.js +13 -3
  14. package/dist/chat/ui/ImageView.d.ts +8 -2
  15. package/dist/chat/ui/ImageView.js +12 -6
  16. package/dist/chat/ui/Markdown.d.ts +7 -1
  17. package/dist/chat/ui/Markdown.js +80 -23
  18. package/dist/chat/ui/MessageActions.js +6 -7
  19. package/dist/chat/ui/SubagentTimeline.js +7 -3
  20. package/dist/chat/ui/Timeline.js +68 -8
  21. package/dist/chat/ui/TimelineMenu.js +7 -3
  22. package/dist/chat/ui/file-links.d.ts +2 -1
  23. package/dist/chat/ui/file-links.js +8 -3
  24. package/dist/chat/ui/intelligent-ui/UiBlockFooter.d.ts +15 -0
  25. package/dist/chat/ui/intelligent-ui/UiBlockFooter.js +50 -0
  26. package/dist/chat/ui/intelligent-ui/UiBlockFrame.d.ts +10 -0
  27. package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +61 -0
  28. package/dist/chat/ui/intelligent-ui/UiFallbackPart.d.ts +10 -0
  29. package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +11 -0
  30. package/dist/chat/ui/intelligent-ui/UiReply.d.ts +13 -0
  31. package/dist/chat/ui/intelligent-ui/UiReply.js +325 -0
  32. package/dist/chat/ui/intelligent-ui/block-local.d.ts +2 -0
  33. package/dist/chat/ui/intelligent-ui/block-local.js +13 -0
  34. package/dist/chat/ui/intelligent-ui/chart-data.d.ts +16 -0
  35. package/dist/chat/ui/intelligent-ui/chart-data.js +46 -0
  36. package/dist/chat/ui/intelligent-ui/node-text.d.ts +9 -0
  37. package/dist/chat/ui/intelligent-ui/node-text.js +52 -0
  38. package/dist/chat/ui/intelligent-ui/registry.d.ts +2 -0
  39. package/dist/chat/ui/intelligent-ui/registry.js +50 -0
  40. package/dist/chat/ui/intelligent-ui/render-context.d.ts +50 -0
  41. package/dist/chat/ui/intelligent-ui/render-context.js +3 -0
  42. package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +3 -0
  43. package/dist/chat/ui/intelligent-ui/renderers/chart.js +125 -0
  44. package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +4 -0
  45. package/dist/chat/ui/intelligent-ui/renderers/choices.js +41 -0
  46. package/dist/chat/ui/intelligent-ui/renderers/content.d.ts +14 -0
  47. package/dist/chat/ui/intelligent-ui/renderers/content.js +66 -0
  48. package/dist/chat/ui/intelligent-ui/renderers/data.d.ts +7 -0
  49. package/dist/chat/ui/intelligent-ui/renderers/data.js +87 -0
  50. package/dist/chat/ui/intelligent-ui/renderers/inputs.d.ts +7 -0
  51. package/dist/chat/ui/intelligent-ui/renderers/inputs.js +60 -0
  52. package/dist/chat/ui/intelligent-ui/renderers/links.d.ts +10 -0
  53. package/dist/chat/ui/intelligent-ui/renderers/links.js +86 -0
  54. package/dist/chat/ui/intelligent-ui/renderers/structure.d.ts +6 -0
  55. package/dist/chat/ui/intelligent-ui/renderers/structure.js +32 -0
  56. package/dist/chat/ui/intelligent-ui/renderers/text.d.ts +8 -0
  57. package/dist/chat/ui/intelligent-ui/renderers/text.js +61 -0
  58. package/dist/chat/ui/intelligent-ui/reply-parts.d.ts +9 -0
  59. package/dist/chat/ui/intelligent-ui/reply-parts.js +18 -0
  60. package/dist/chat/ui/intelligent-ui/table-data.d.ts +31 -0
  61. package/dist/chat/ui/intelligent-ui/table-data.js +79 -0
  62. package/dist/chat/ui/intelligent-ui/tones.d.ts +7 -0
  63. package/dist/chat/ui/intelligent-ui/tones.js +31 -0
  64. package/dist/chat/ui/intelligent-ui/use-later.d.ts +1 -0
  65. package/dist/chat/ui/intelligent-ui/use-later.js +16 -0
  66. package/dist/chat/ui/intelligent-ui/use-ui-links.d.ts +9 -0
  67. package/dist/chat/ui/intelligent-ui/use-ui-links.js +107 -0
  68. package/dist/chat/ui/intelligent-ui/use-ui-queries.d.ts +20 -0
  69. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +259 -0
  70. package/dist/chat/ui/reply-context.d.ts +8 -0
  71. package/dist/chat/ui/reply-context.js +1 -0
  72. package/dist/chat/ui/rows/GeneratedImageRow.d.ts +5 -0
  73. package/dist/chat/ui/rows/GeneratedImageRow.js +121 -0
  74. package/dist/chat/ui/rows/MessageRows.js +27 -7
  75. package/dist/chat/ui/rows/Rows.js +4 -2
  76. package/dist/chat/ui/rows/SubagentRow.js +5 -0
  77. package/dist/chat/ui/rows/WorkRows.d.ts +4 -1
  78. package/dist/chat/ui/rows/WorkRows.js +4 -3
  79. package/dist/host.d.ts +25 -3
  80. package/dist/host.js +1 -0
  81. package/dist/locales/en/agent-chat.json +89 -1
  82. package/dist/locales/nl/agent-chat.json +89 -1
  83. package/dist/state/chats.js +9 -0
  84. package/dist/theme.css +38 -2
  85. package/package.json +133 -7
  86. package/src/chat/chat-client.ts +1 -0
  87. package/src/chat/logic/generated-image.ts +48 -0
  88. package/src/chat/logic/timeline-target.ts +69 -4
  89. package/src/chat/logic/timeline.ts +28 -17
  90. package/src/chat/subagent-conversation.ts +13 -2
  91. package/src/chat/timeline-flash.ts +5 -3
  92. package/src/chat/ui/CodeBlock.tsx +70 -21
  93. package/src/chat/ui/Composer.tsx +22 -5
  94. package/src/chat/ui/ImageView.tsx +51 -8
  95. package/src/chat/ui/Markdown.tsx +123 -26
  96. package/src/chat/ui/MessageActions.tsx +15 -8
  97. package/src/chat/ui/SubagentTimeline.tsx +26 -1
  98. package/src/chat/ui/Timeline.tsx +98 -22
  99. package/src/chat/ui/TimelineMenu.tsx +6 -3
  100. package/src/chat/ui/file-links.ts +9 -3
  101. package/src/chat/ui/intelligent-ui/UiBlockFooter.tsx +139 -0
  102. package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +89 -0
  103. package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +21 -0
  104. package/src/chat/ui/intelligent-ui/UiReply.tsx +531 -0
  105. package/src/chat/ui/intelligent-ui/block-local.ts +16 -0
  106. package/src/chat/ui/intelligent-ui/chart-data.ts +67 -0
  107. package/src/chat/ui/intelligent-ui/node-text.ts +59 -0
  108. package/src/chat/ui/intelligent-ui/registry.ts +52 -0
  109. package/src/chat/ui/intelligent-ui/render-context.ts +78 -0
  110. package/src/chat/ui/intelligent-ui/renderers/chart.tsx +321 -0
  111. package/src/chat/ui/intelligent-ui/renderers/choices.tsx +98 -0
  112. package/src/chat/ui/intelligent-ui/renderers/content.tsx +145 -0
  113. package/src/chat/ui/intelligent-ui/renderers/data.tsx +187 -0
  114. package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +121 -0
  115. package/src/chat/ui/intelligent-ui/renderers/links.tsx +157 -0
  116. package/src/chat/ui/intelligent-ui/renderers/structure.tsx +84 -0
  117. package/src/chat/ui/intelligent-ui/renderers/text.tsx +124 -0
  118. package/src/chat/ui/intelligent-ui/reply-parts.ts +22 -0
  119. package/src/chat/ui/intelligent-ui/table-data.ts +104 -0
  120. package/src/chat/ui/intelligent-ui/tones.ts +37 -0
  121. package/src/chat/ui/intelligent-ui/use-later.ts +17 -0
  122. package/src/chat/ui/intelligent-ui/use-ui-links.ts +123 -0
  123. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +282 -0
  124. package/src/chat/ui/reply-context.ts +7 -0
  125. package/src/chat/ui/rows/GeneratedImageRow.tsx +244 -0
  126. package/src/chat/ui/rows/MessageRows.tsx +69 -7
  127. package/src/chat/ui/rows/Rows.tsx +4 -2
  128. package/src/chat/ui/rows/SubagentRow.tsx +5 -0
  129. package/src/chat/ui/rows/WorkRows.tsx +20 -5
  130. package/src/host.ts +46 -3
  131. package/src/locales/en/agent-chat.json +89 -1
  132. package/src/locales/nl/agent-chat.json +89 -1
  133. package/src/state/chats.ts +9 -0
  134. package/src/theme.css +38 -2
@@ -32,8 +32,17 @@ import {
32
32
  ticksOf,
33
33
  ticksWithHits
34
34
  } from '../logic/scrubber';
35
- import { EMPTY_TARGET, readTimelineTarget, withCurrentText, type TimelineTarget } from '../logic/timeline-target';
35
+ import {
36
+ EMPTY_TARGET,
37
+ readTimelineTarget,
38
+ revealUiReplyBlock,
39
+ uiReplyJump,
40
+ withCurrentText,
41
+ type TimelineTarget,
42
+ type UiReplyTarget
43
+ } from '../logic/timeline-target';
36
44
  import { forkRefusal } from '../logic/fork';
45
+ import { UiReplyNavigationContext } from './reply-context';
37
46
  import { FindRevealContext } from './find-reveal';
38
47
  import { BookmarkMarker } from './BookmarkMarker';
39
48
  import { MessageActions } from './MessageActions';
@@ -207,10 +216,26 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
207
216
  const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
208
217
  const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
209
218
  // A message a jump is on its way to; it may first have to open the turn its reply folded into.
210
- const [seeking, setSeeking] = useState<string | null>(null);
219
+ const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string } | null>(null);
220
+ const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
211
221
  // A turn a jump is on its way to, which waits for the page its messages are on to come in.
212
222
  const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
213
223
  const flash = useTimelineFlash((s) => s.target);
224
+ const revealBlock = useCallback(
225
+ (block: UiReplyTarget) => {
226
+ setLanding(null);
227
+ setSeeking({ itemId: block.itemId, block, key: keyOf(chatId) });
228
+ },
229
+ [keyOf, chatId]
230
+ );
231
+ const uiNavigation = useMemo(
232
+ () => ({
233
+ chatId,
234
+ reveal: revealBlock,
235
+ flash: flash?.key === keyOf(chatId) && flash.block ? { itemId: flash.rowId, ...flash.block, nonce: flash.nonce } : null
236
+ }),
237
+ [revealBlock, flash, keyOf, chatId]
238
+ );
214
239
 
215
240
  // Whether the thread's text clears the strip depends on the width of the view, which only the thread's own frame tells.
216
241
  useEffect(() => {
@@ -389,7 +414,8 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
389
414
  return;
390
415
  }
391
416
  if (tick.bookmark !== null) {
392
- setSeeking(tick.bookmark.itemId);
417
+ setLanding(null);
418
+ setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
393
419
  return;
394
420
  }
395
421
  followRef.current = false;
@@ -400,14 +426,34 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
400
426
  if (seeking === null) {
401
427
  return;
402
428
  }
403
- const index = rows.findIndex((row) => row.id === seeking);
429
+ if (seeking.key !== keyOf(chatId)) {
430
+ setSeeking(null);
431
+ return;
432
+ }
433
+ if (seeking.block) {
434
+ const jump = uiReplyJump(seeking.block, rows, items, turns.ids, cursor !== null);
435
+ if (jump.kind === 'earlier') {
436
+ loadEarlier();
437
+ } else if (jump.kind === 'turn') {
438
+ turns.add(jump.turnId);
439
+ } else {
440
+ setSeeking(null);
441
+ if (jump.kind === 'row') {
442
+ followRef.current = false;
443
+ virtualizer.scrollToIndex(jump.index, { align: 'start' });
444
+ setLanding({ target: seeking.block, key: seeking.key });
445
+ }
446
+ }
447
+ return;
448
+ }
449
+ const index = rows.findIndex((row) => row.id === seeking.itemId);
404
450
  if (index !== -1) {
405
451
  setSeeking(null);
406
452
  followRef.current = false;
407
453
  virtualizer.scrollToIndex(index, { align: 'start' });
408
454
  return;
409
455
  }
410
- const item = items?.[seeking];
456
+ const item = items?.[seeking.itemId];
411
457
  if (item === undefined && cursor !== null) {
412
458
  loadEarlier();
413
459
  return;
@@ -418,8 +464,36 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
418
464
  return;
419
465
  }
420
466
  setSeeking(null);
421
- }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier]);
422
- useEffect(() => registerItemJumper(keyOf(chatId), setSeeking), [keyOf, chatId]);
467
+ }, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
468
+ useEffect(
469
+ () =>
470
+ registerItemJumper(keyOf(chatId), (itemId) => {
471
+ setLanding(null);
472
+ setSeeking({ itemId, key: keyOf(chatId) });
473
+ }),
474
+ [keyOf, chatId]
475
+ );
476
+ useLayoutEffect(() => {
477
+ if (!landing || landing.key !== keyOf(chatId)) {
478
+ return;
479
+ }
480
+ let frame = 0;
481
+ let attempts = 0;
482
+ const reveal = () => {
483
+ const scroller = scrollRef.current;
484
+ if (scroller && revealUiReplyBlock(scroller, landing.target)) {
485
+ useTimelineFlash.getState().flash(keyOf(chatId), landing.target.itemId, false, landing.target);
486
+ setLanding(null);
487
+ } else if (++attempts < 30) {
488
+ // Virtual rows mount after scrollToIndex updates the visible range.
489
+ frame = requestAnimationFrame(reveal);
490
+ } else {
491
+ setLanding(null);
492
+ }
493
+ };
494
+ frame = requestAnimationFrame(reveal);
495
+ return () => cancelAnimationFrame(frame);
496
+ }, [landing, keyOf, chatId]);
423
497
 
424
498
  useLayoutEffect(() => {
425
499
  if (revealing === null) {
@@ -498,7 +572,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
498
572
  // A file an answer names is relative to the folder this chat runs in, which is a worktree as
499
573
  // often as it is the project itself.
500
574
  <FileLinkContext.Provider value={info?.cwd ?? null}>
501
- <div ref={frameRef} className="relative flex min-h-0 grow flex-col">
575
+ <div ref={frameRef} className="relative flex min-h-0 grow flex-col" data-file-cwd={info?.cwd ?? ''} data-file-scope-id={scope.id}>
502
576
  {searching && chatFind.bar}
503
577
  <ContextMenu.Root>
504
578
  <div
@@ -562,7 +636,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
562
636
  top={virtualRow.start}
563
637
  ref={virtualizer.measureElement}
564
638
  >
565
- {flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && (
639
+ {flash !== null && flash.key === keyOf(chatId) && flash.rowId === row.id && !flash.block && (
566
640
  <span
567
641
  key={flash.nonce}
568
642
  className="chat-flash pointer-events-none absolute inset-0 rounded-lg"
@@ -574,19 +648,21 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
574
648
  <ReplyHeader chatId={chatId} at={header.at} />
575
649
  </div>
576
650
  )}
577
- <FindRevealContext.Provider value={chatFind.reveal}>
578
- <MarkableRow row={row} chatId={chatId} bookmark={marks.get(row.id) ?? null}>
579
- <Row
580
- row={row}
581
- chatId={chatId}
582
- toggleGroup={groups.toggle}
583
- toggleTurn={turns.toggle}
584
- toggleSubagent={subagents.toggle}
585
- openSubagent={openSubagent}
586
- openConversation={openConversation}
587
- />
588
- </MarkableRow>
589
- </FindRevealContext.Provider>
651
+ <UiReplyNavigationContext.Provider value={uiNavigation}>
652
+ <FindRevealContext.Provider value={chatFind.reveal}>
653
+ <MarkableRow row={row} chatId={chatId} bookmark={marks.get(row.id) ?? null}>
654
+ <Row
655
+ row={row}
656
+ chatId={chatId}
657
+ toggleGroup={groups.toggle}
658
+ toggleTurn={turns.toggle}
659
+ toggleSubagent={subagents.toggle}
660
+ openSubagent={openSubagent}
661
+ openConversation={openConversation}
662
+ />
663
+ </MarkableRow>
664
+ </FindRevealContext.Provider>
665
+ </UiReplyNavigationContext.Provider>
590
666
  </RowContainer>
591
667
  );
592
668
  })}
@@ -8,7 +8,7 @@ import type { TimelineTarget } from '../logic/timeline-target';
8
8
  import { chatHost } from '../../host';
9
9
  import { useChatScope } from '../../scope';
10
10
  import { useChatRow } from '../../state/chats';
11
- import { openFileLink, useFileLinkCwd } from './file-links';
11
+ import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
12
12
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
13
13
 
14
14
  /*
@@ -34,10 +34,13 @@ export function TimelineMenuPopup({
34
34
  const markable = chatId !== null && (target.row?.kind === 'user' || target.row?.kind === 'assistant') ? target.row.id : null;
35
35
  // A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
36
36
  const cwd = useFileLinkCwd();
37
+ const scopeId = useFileLinkScopeId();
37
38
  const { fork, fileLinks } = chatHost();
38
39
  const openInPreview = (): void => {
39
- if (target.path !== null) {
40
- openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false });
40
+ if (target.file != null) {
41
+ openFileLink(target.file.cwd, target.file.ref, target.file.scopeId);
42
+ } else if (target.path !== null) {
43
+ openFileLink(cwd, { path: target.path, ...(target.line === null ? {} : { line: target.line }), directory: false }, scopeId);
41
44
  }
42
45
  };
43
46
  return (
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, useMemo } from 'react';
2
2
  import { chatHost, type FileRef } from '../../host';
3
+ import { ChatScopeContext } from '../../scope';
3
4
 
4
5
  /*
5
6
  * The folder a relative reference in rendered text counts from: a chat's own cwd, which is a
@@ -12,13 +13,18 @@ export function useFileLinkCwd(): string | null {
12
13
  return useContext(FileLinkContext);
13
14
  }
14
15
 
16
+ export function useFileLinkScopeId(): string | null {
17
+ return useContext(ChatScopeContext)?.id ?? null;
18
+ }
19
+
15
20
  /* The reference a piece of text names, only where this spot on screen could open it. */
16
21
  export function useFileLinkTarget(text: string): FileRef | null {
17
22
  const cwd = useFileLinkCwd();
18
- return useMemo(() => chatHost().fileLinks?.target(text, cwd) ?? null, [text, cwd]);
23
+ const scopeId = useFileLinkScopeId();
24
+ return useMemo(() => chatHost().fileLinks?.target(text, cwd, scopeId) ?? null, [text, cwd, scopeId]);
19
25
  }
20
26
 
21
27
  /* A reference followed, the way the app opens a file or a folder. */
22
- export function openFileLink(cwd: string | null, ref: FileRef): void {
23
- chatHost().fileLinks?.open(cwd, ref);
28
+ export function openFileLink(cwd: string | null, ref: FileRef, scopeId: string | null = null): void {
29
+ chatHost().fileLinks?.open(cwd, ref, scopeId);
24
30
  }
@@ -0,0 +1,139 @@
1
+ import { useId, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Lock, Radio, TriangleAlert, Wrench } from 'lucide-react';
4
+ import { Icon, Spinner, Tooltip, useTickingText } from '@adecore/ui';
5
+ import { formatAgo, formatMoment } from '@adecore/ui/format';
6
+ import type { UiLiveStatus } from './render-context';
7
+ import { useLater } from './use-later';
8
+
9
+ /* Something the compiler repaired, such as a prop it dropped; never an error to a person. */
10
+ export interface UiBlockFix {
11
+ code: string;
12
+ message: string;
13
+ }
14
+
15
+ export interface UiBlockFooterProps {
16
+ live?: UiLiveStatus | null;
17
+ /* The label of the Choice that answered the block, and when, in milliseconds since the epoch. */
18
+ answered?: { label: string; at: number } | null;
19
+ fixes?: readonly UiBlockFix[];
20
+ /* Why the whole block is drawn as its text, such as that it is too large. */
21
+ shownAsText?: string | null;
22
+ }
23
+
24
+ const NO_FIXES: readonly UiBlockFix[] = [];
25
+ const READING_SPINNER_MS = 1000;
26
+
27
+ /* The one sign of a live block: what it reads, and when it last read it. A reading spins only after a second. */
28
+ function LivePart({ live }: { live: UiLiveStatus }) {
29
+ const { t } = useTranslation('agent-chat');
30
+ const slow = useLater(live.state === 'reading', READING_SPINNER_MS);
31
+ const { readAt } = live;
32
+ const fresh = live.state === 'fresh' || live.state === 'reading';
33
+ const ago = useTickingText(() => {
34
+ if (readAt === null) {
35
+ return '';
36
+ }
37
+ const time = formatAgo(Date.now() - readAt);
38
+ return fresh ? t('blocks.live.read', { time }) : time;
39
+ });
40
+ const source = live.source ?? live.sources[0] ?? '';
41
+ const icon = slow ? (
42
+ <Spinner size={12} />
43
+ ) : live.state === 'failed' ? (
44
+ <Icon icon={TriangleAlert} size={12} className="text-status-needs-you" />
45
+ ) : live.state === 'refused' ? (
46
+ <Icon icon={Lock} size={12} />
47
+ ) : (
48
+ <Icon icon={Radio} size={12} className="text-status-idle" />
49
+ );
50
+ const words =
51
+ live.state === 'failed'
52
+ ? t('blocks.live.failed', { source })
53
+ : live.state === 'refused'
54
+ ? t('blocks.live.refused', { source })
55
+ : t('blocks.live.label');
56
+ const hint =
57
+ live.state === 'failed' && live.reason !== undefined
58
+ ? live.reason
59
+ : live.sources.length > 0
60
+ ? t('blocks.live.sources', { sources: live.sources.join(', ') })
61
+ : null;
62
+ const body = (
63
+ <span tabIndex={hint === null ? undefined : 0} className="flex items-center gap-1.5">
64
+ {icon}
65
+ <span>{words}</span>
66
+ {readAt !== null && live.state !== 'refused' && (
67
+ <>
68
+ <span aria-hidden>·</span>
69
+ <time dateTime={new Date(readAt).toISOString()}>
70
+ <span ref={ago} className="tabular-nums" />
71
+ </time>
72
+ </>
73
+ )}
74
+ {readAt === null && fresh && (
75
+ <>
76
+ <span aria-hidden>·</span>
77
+ <span>{t('blocks.live.notRead')}</span>
78
+ </>
79
+ )}
80
+ </span>
81
+ );
82
+ return hint === null ? body : <Tooltip label={hint}>{body}</Tooltip>;
83
+ }
84
+
85
+ /*
86
+ * The line under a block, only when it has something to say: live, answered, repaired or drawn as
87
+ * text, in that order. A hairline of the soft border runs the full width of the card over it.
88
+ */
89
+ export function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
90
+ const { t } = useTranslation('agent-chat');
91
+ const [fixesOpen, setFixesOpen] = useState(false);
92
+ const listId = useId();
93
+ if (live === null && answered === null && fixes.length === 0 && shownAsText === null) {
94
+ return null;
95
+ }
96
+ return (
97
+ <div className="-mx-2.25 mt-2.25 -mb-2.25 flex flex-col gap-1.5 border-t border-border-soft px-4.25 py-2 text-xs text-text-faint">
98
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
99
+ {live !== null && <LivePart live={live} />}
100
+ {answered !== null && (
101
+ <span className="flex items-center gap-1.5">
102
+ <span>{t('blocks.answered', { label: answered.label })}</span>
103
+ <span aria-hidden>·</span>
104
+ <time dateTime={new Date(answered.at).toISOString()}>{formatMoment(answered.at)}</time>
105
+ </span>
106
+ )}
107
+ {fixes.length > 0 && (
108
+ <button
109
+ type="button"
110
+ aria-expanded={fixesOpen}
111
+ aria-controls={listId}
112
+ className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
113
+ onClick={() => setFixesOpen(!fixesOpen)}
114
+ >
115
+ <Icon icon={Wrench} size={12} />
116
+ {t('blocks.fixes', { count: fixes.length })}
117
+ </button>
118
+ )}
119
+ {shownAsText !== null && (
120
+ <span className="flex items-center gap-1.5">
121
+ <span>{t('blocks.shownAsText')}</span>
122
+ <span aria-hidden>·</span>
123
+ <span>{shownAsText}</span>
124
+ </span>
125
+ )}
126
+ </div>
127
+ {fixes.length > 0 && (
128
+ <ul id={listId} hidden={!fixesOpen} className="flex flex-col gap-0.5 text-text-muted">
129
+ {fixes.map((fix, index) => (
130
+ <li key={index} className="flex gap-2">
131
+ <span className="shrink-0 font-mono">{fix.code}</span>
132
+ <span className="min-w-0">{fix.message}</span>
133
+ </li>
134
+ ))}
135
+ </ul>
136
+ )}
137
+ </div>
138
+ );
139
+ }
@@ -0,0 +1,89 @@
1
+ import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import type { UiViewNode } from '@adecore/intelligent-ui';
5
+ import { Skeleton } from '@adecore/ui';
6
+ import { uiBlockHead } from './node-text';
7
+ import { UiHeadContext } from './render-context';
8
+ import { UiBlockFooter, type UiBlockFooterProps } from './UiBlockFooter';
9
+
10
+ export interface UiBlockFrameProps extends UiBlockFooterProps {
11
+ /* The evaluated top-level nodes, read for the Summary that heads the block. */
12
+ nodes: readonly UiViewNode[];
13
+ phase: 'streaming' | 'final';
14
+ /* The rendered nodes, or the markdown of the whole block when it is shown as text. */
15
+ children: ReactNode;
16
+ className?: string;
17
+ }
18
+
19
+ const RELEASE_MS = 200;
20
+
21
+ /*
22
+ * The tallest the card has been, while `active`: a streaming block grows and never shrinks until its
23
+ * last compile. Then the card eases down to its own height, or drops there for a person who asked
24
+ * for less motion.
25
+ */
26
+ function useRisingFloor(active: boolean): [RefObject<HTMLDivElement | null>, number] {
27
+ const ref = useRef<HTMLDivElement>(null);
28
+ const held = useRef(0);
29
+ const [floor, setFloor] = useState(0);
30
+ useLayoutEffect(() => {
31
+ const element = ref.current;
32
+ if (element === null) {
33
+ return;
34
+ }
35
+ if (active) {
36
+ const observer = new ResizeObserver(() => {
37
+ const height = Math.ceil(element.getBoundingClientRect().height);
38
+ held.current = Math.max(held.current, height);
39
+ setFloor(held.current);
40
+ });
41
+ observer.observe(element);
42
+ return () => {
43
+ observer.disconnect();
44
+ setFloor(0);
45
+ };
46
+ }
47
+ // The final render already let go of the floor, so the card measures its own height here.
48
+ const from = held.current;
49
+ const to = element.getBoundingClientRect().height;
50
+ held.current = 0;
51
+ const reduced = element.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches === true;
52
+ if (from - to < 1 || reduced) {
53
+ return;
54
+ }
55
+ const release = element.animate([{ minHeight: `${from}px` }, { minHeight: `${to}px` }], { duration: RELEASE_MS, easing: 'ease-out' });
56
+ return () => release.cancel();
57
+ }, [active]);
58
+ return [ref, floor];
59
+ }
60
+
61
+ /*
62
+ * The card of one block, with its footer. Named by its Summary and busy while it streams, but never
63
+ * live: reading out every compile would be noise. A node fades in as it arrives, without a stagger,
64
+ * and for a person who asked for less motion it only fades; a node inside another only fades
65
+ * (`chat-ui-nodes`). Until the first node a skeleton holds the place of the head.
66
+ */
67
+ export function UiBlockFrame({ nodes, phase, children, className, ...footer }: UiBlockFrameProps) {
68
+ const { t } = useTranslation('agent-chat');
69
+ const streaming = phase === 'streaming';
70
+ const head = uiBlockHead(nodes);
71
+ const [ref, floor] = useRisingFloor(streaming);
72
+ return (
73
+ <UiHeadContext value={head?.id ?? null}>
74
+ <div
75
+ ref={ref}
76
+ role="group"
77
+ aria-label={head === null || head.label === '' ? t('blocks.label') : head.label}
78
+ aria-busy={streaming || undefined}
79
+ className={clsx('flex min-h-11 flex-col rounded-2xl border border-border bg-surface-raised p-2.25', className)}
80
+ style={streaming && floor > 0 ? { minHeight: floor } : undefined}
81
+ >
82
+ <div className="flex flex-col gap-3 *:transition-[opacity,translate] *:duration-160 *:ease-out *:starting:opacity-0 motion-safe:*:starting:translate-y-0.5">
83
+ {nodes.length === 0 && streaming ? <Skeleton className="mx-2 my-1 w-2/5" /> : children}
84
+ </div>
85
+ <UiBlockFooter {...footer} />
86
+ </div>
87
+ </UiHeadContext>
88
+ );
89
+ }
@@ -0,0 +1,21 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { Markdown } from '../Markdown';
3
+
4
+ /* Why a part is drawn as its text: a component this version does not know, or one it could not draw. */
5
+ export type UiFallbackProblem = { kind: 'unknown'; component: string } | { kind: 'failed' };
6
+
7
+ /*
8
+ * One part of a block as the markdown it stands for, in a quiet surface with a line that says why.
9
+ * A person sees it as a repair, never as an error, so nothing here is red.
10
+ */
11
+ export function UiFallbackPart({ fallback, problem }: { fallback: string; problem: UiFallbackProblem }) {
12
+ const { t } = useTranslation('agent-chat');
13
+ return (
14
+ <div className="flex flex-col gap-1 rounded-md bg-surface-hover p-2">
15
+ <span className="text-xs text-text-faint">
16
+ {problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')}
17
+ </span>
18
+ {fallback !== '' && <Markdown text={fallback} fileLinks={false} />}
19
+ </div>
20
+ );
21
+ }