@adecore/agents-react 0.37.2 → 0.38.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 (115) hide show
  1. package/dist/chat/chat-client.d.ts +1 -0
  2. package/dist/chat/chat-client.js +2 -1
  3. package/dist/chat/chat-references.d.ts +16 -1
  4. package/dist/chat/chat-references.js +29 -3
  5. package/dist/chat/drafts.d.ts +2 -0
  6. package/dist/chat/drafts.js +21 -5
  7. package/dist/chat/index.d.ts +1 -1
  8. package/dist/chat/logic/visual-height.d.ts +1 -0
  9. package/dist/chat/logic/visual-height.js +1 -1
  10. package/dist/chat/mentions.d.ts +31 -14
  11. package/dist/chat/mentions.js +186 -41
  12. package/dist/chat/stash.d.ts +1 -0
  13. package/dist/chat/stash.js +4 -2
  14. package/dist/chat/ui/Composer.js +438 -429
  15. package/dist/chat/ui/ComposerInput.d.ts +1 -0
  16. package/dist/chat/ui/ComposerInput.js +2 -0
  17. package/dist/chat/ui/HostMenuItems.d.ts +4 -0
  18. package/dist/chat/ui/HostMenuItems.js +15 -0
  19. package/dist/chat/ui/Markdown.d.ts +3 -1
  20. package/dist/chat/ui/Markdown.js +194 -120
  21. package/dist/chat/ui/MessageActions.js +70 -66
  22. package/dist/chat/ui/ReferenceChip.d.ts +15 -0
  23. package/dist/chat/ui/ReferenceChip.js +255 -0
  24. package/dist/chat/ui/Timeline.js +52 -29
  25. package/dist/chat/ui/TimelineMenu.js +84 -78
  26. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  27. package/dist/chat/ui/VisualFrame.js +217 -115
  28. package/dist/chat/ui/chip-clipboard.d.ts +9 -0
  29. package/dist/chat/ui/chip-clipboard.js +105 -0
  30. package/dist/chat/ui/chips.d.ts +5 -0
  31. package/dist/chat/ui/chips.js +15 -1
  32. package/dist/chat/ui/composer/ComposerMenu.js +140 -79
  33. package/dist/chat/ui/composer/DraftParts.d.ts +0 -4
  34. package/dist/chat/ui/composer/DraftParts.js +0 -41
  35. package/dist/chat/ui/composer/chip-paste.d.ts +15 -0
  36. package/dist/chat/ui/composer/chip-paste.js +104 -0
  37. package/dist/chat/ui/composer/chips.d.ts +5 -0
  38. package/dist/chat/ui/composer/chips.js +59 -9
  39. package/dist/chat/ui/composer/key-handler.js +9 -0
  40. package/dist/chat/ui/composer/use-composer-draft.d.ts +0 -1
  41. package/dist/chat/ui/composer/use-composer-draft.js +2 -10
  42. package/dist/chat/ui/composer/use-composer-menus.d.ts +12 -2
  43. package/dist/chat/ui/composer/use-composer-menus.js +192 -149
  44. package/dist/chat/ui/composer/use-composer-submit.js +17 -12
  45. package/dist/chat/ui/composer/use-draft-files.d.ts +7 -1
  46. package/dist/chat/ui/composer/use-draft-files.js +90 -58
  47. package/dist/chat/ui/composer/use-take-back.js +2 -1
  48. package/dist/chat/ui/index.d.ts +1 -1
  49. package/dist/chat/ui/index.js +1 -1
  50. package/dist/chat/ui/reference-icons.d.ts +3 -0
  51. package/dist/chat/ui/reference-icons.js +15 -0
  52. package/dist/chat/ui/reference-lookup.d.ts +7 -0
  53. package/dist/chat/ui/reference-lookup.js +6 -0
  54. package/dist/chat/ui/rehype-chips.d.ts +8 -1
  55. package/dist/chat/ui/rehype-chips.js +35 -11
  56. package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
  57. package/dist/chat/ui/rows/MessageRows.js +218 -158
  58. package/dist/chat/ui/rows/VisualRow.js +184 -142
  59. package/dist/chat/ui/rows/row-rhythm.js +6 -4
  60. package/dist/chat/ui/thread-references.d.ts +4 -0
  61. package/dist/chat/ui/thread-references.js +78 -0
  62. package/dist/host-hooks.d.ts +2 -0
  63. package/dist/host-hooks.js +7 -0
  64. package/dist/host.d.ts +14 -1
  65. package/dist/index.d.ts +1 -1
  66. package/dist/locales/en/agent-chat.d.ts +20 -3
  67. package/dist/locales/en/agent-chat.js +24 -7
  68. package/dist/locales/nl/agent-chat.d.ts +20 -3
  69. package/dist/locales/nl/agent-chat.js +24 -7
  70. package/package.json +8 -8
  71. package/src/chat/chat-client.ts +4 -1
  72. package/src/chat/chat-references.ts +44 -3
  73. package/src/chat/drafts.ts +24 -6
  74. package/src/chat/index.ts +1 -1
  75. package/src/chat/logic/visual-height.ts +1 -1
  76. package/src/chat/mentions.ts +202 -36
  77. package/src/chat/stash.ts +6 -2
  78. package/src/chat/ui/Composer.tsx +14 -4
  79. package/src/chat/ui/ComposerInput.tsx +4 -0
  80. package/src/chat/ui/HostMenuItems.tsx +11 -0
  81. package/src/chat/ui/Markdown.tsx +57 -25
  82. package/src/chat/ui/MessageActions.tsx +5 -2
  83. package/src/chat/ui/ReferenceChip.tsx +74 -0
  84. package/src/chat/ui/Timeline.tsx +24 -2
  85. package/src/chat/ui/TimelineMenu.tsx +7 -3
  86. package/src/chat/ui/VisualFrame.tsx +68 -21
  87. package/src/chat/ui/chip-clipboard.ts +115 -0
  88. package/src/chat/ui/chips.ts +20 -1
  89. package/src/chat/ui/composer/ComposerMenu.tsx +53 -37
  90. package/src/chat/ui/composer/DraftParts.tsx +0 -12
  91. package/src/chat/ui/composer/chip-paste.ts +122 -0
  92. package/src/chat/ui/composer/chips.ts +60 -10
  93. package/src/chat/ui/composer/key-handler.ts +9 -0
  94. package/src/chat/ui/composer/use-composer-draft.ts +1 -7
  95. package/src/chat/ui/composer/use-composer-menus.ts +68 -29
  96. package/src/chat/ui/composer/use-composer-submit.ts +13 -6
  97. package/src/chat/ui/composer/use-draft-files.ts +39 -5
  98. package/src/chat/ui/composer/use-take-back.ts +2 -1
  99. package/src/chat/ui/index.ts +1 -1
  100. package/src/chat/ui/reference-icons.ts +17 -0
  101. package/src/chat/ui/reference-lookup.ts +13 -0
  102. package/src/chat/ui/rehype-chips.ts +38 -13
  103. package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
  104. package/src/chat/ui/rows/MessageRows.tsx +88 -64
  105. package/src/chat/ui/rows/VisualRow.tsx +5 -1
  106. package/src/chat/ui/rows/row-rhythm.ts +6 -4
  107. package/src/chat/ui/thread-references.ts +47 -0
  108. package/src/host-hooks.ts +9 -0
  109. package/src/host.ts +24 -1
  110. package/src/index.ts +1 -0
  111. package/src/locales/en/agent-chat.ts +24 -7
  112. package/src/locales/nl/agent-chat.ts +24 -7
  113. package/dist/chat/ui/ChatReferenceChip.d.ts +0 -4
  114. package/dist/chat/ui/ChatReferenceChip.js +0 -110
  115. package/src/chat/ui/ChatReferenceChip.tsx +0 -26
@@ -0,0 +1,74 @@
1
+ import { useContext, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react';
2
+ import clsx from 'clsx';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { CircleOff } from 'lucide-react';
5
+ import { Icon } from '@adecore/ui';
6
+ import { REFERENCE_GONE_TONE, REFERENCE_TONE, TOKEN_ICON, TOKEN_IN_MESSAGE } from './chips';
7
+ import { REFERENCE_ICONS } from './reference-icons';
8
+ import { ReferenceLookupContext } from './reference-lookup';
9
+
10
+ interface TokenChipProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
11
+ /* `gone` for a reference that stands for nothing any more, which a copy leaves as text. */
12
+ kind: 'mention' | 'skill' | 'reference' | 'gone';
13
+ /* The path, the skill's name or the reference's id. */
14
+ value?: string;
15
+ /* The sigil the mark stands in for. Hidden, but a selection copies it with the name. */
16
+ sigil?: string;
17
+ icon: ReactNode;
18
+ label: string;
19
+ tone: string;
20
+ onOpen?: () => void;
21
+ }
22
+
23
+ /*
24
+ * One token in the transcript. One that opens is a span with a button's role rather than a button,
25
+ * since a button is a box with a baseline of its own and would not sit on the line the others sit on.
26
+ */
27
+ export function TokenChip({ kind, value, sigil, icon, label, tone, onOpen, className, ...rest }: TokenChipProps) {
28
+ const onKeyDown = (e: KeyboardEvent<HTMLSpanElement>): void => {
29
+ if (onOpen !== undefined && (e.key === 'Enter' || e.key === ' ')) {
30
+ e.preventDefault();
31
+ onOpen();
32
+ }
33
+ };
34
+ return (
35
+ <span
36
+ {...rest}
37
+ className={clsx(TOKEN_IN_MESSAGE, tone, onOpen !== undefined && 'cursor-pointer focus-visible:outline-2 focus-visible:outline-accent', className)}
38
+ data-chip={kind}
39
+ data-chip-value={value}
40
+ data-chip-name={kind === 'reference' ? label : undefined}
41
+ role={onOpen === undefined ? undefined : 'button'}
42
+ tabIndex={onOpen === undefined ? undefined : 0}
43
+ onClick={onOpen}
44
+ onKeyDown={onOpen === undefined ? undefined : onKeyDown}
45
+ >
46
+ {icon}
47
+ {sigil !== undefined && <span className="sr-only">{sigil}</span>}
48
+ {label}
49
+ </span>
50
+ );
51
+ }
52
+
53
+ /* A view, node or chat a message points at, under the name it has now; one that went says so. */
54
+ export function ReferenceChip({ id }: { id: string }) {
55
+ const { t } = useTranslation('agent-chat');
56
+ const { items, open } = useContext(ReferenceLookupContext);
57
+ const item = items.find((candidate) => candidate.id === id);
58
+ if (item === undefined) {
59
+ return (
60
+ <TokenChip kind="gone" icon={<Icon icon={CircleOff} size={14} className={TOKEN_ICON} />} label={t('references.gone')} tone={REFERENCE_GONE_TONE} />
61
+ );
62
+ }
63
+ return (
64
+ <TokenChip
65
+ kind="reference"
66
+ value={item.id}
67
+ sigil="#"
68
+ icon={<Icon icon={REFERENCE_ICONS[item.kind]} size={14} className={TOKEN_ICON} />}
69
+ label={item.name || t('references.untitled')}
70
+ tone={clsx(REFERENCE_TONE, open !== null && 'hover:bg-text/12')}
71
+ onOpen={open === null ? undefined : () => open(item.id)}
72
+ />
73
+ );
74
+ }
@@ -29,6 +29,7 @@ import {
29
29
  setTimelineAtEnd,
30
30
  wantsEarlier,
31
31
  type ItemJumpOptions,
32
+ type ReadingAnchor,
32
33
  type TurnTarget
33
34
  } from '../timeline-scroll';
34
35
  import { useTimelineFlash } from '../timeline-flash';
@@ -226,6 +227,8 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
226
227
  const [frame, setFrame] = useState({ width: 0, height: 0, column: false });
227
228
  const [loadingEarlier, setLoadingEarlier] = useState(false);
228
229
  const loadingEarlierRef = useRef(false);
230
+ const anchorRef = useRef<ReadingAnchor | null>(null);
231
+ const headRef = useRef<string | undefined>(undefined);
229
232
 
230
233
  const loadEarlier = useCallback(() => {
231
234
  if (cursor === null || loadingEarlierRef.current) {
@@ -355,7 +358,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
355
358
  // Changing this callback invalidates every cached measurement, including on a scroll.
356
359
  const getItemKey = useCallback((index: number) => rows[index]!.id, [rows]);
357
360
 
358
- // Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
361
+ // Follow the browser's actual end below. Virtual end anchoring drifts when growth still fits without scrolling.
359
362
  // oxlint-disable-next-line react/incompatible-library
360
363
  const virtualizer = useVirtualizer({
361
364
  count: loadingHistory ? 0 : rows.length,
@@ -363,7 +366,6 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
363
366
  estimateSize: () => ESTIMATED_ROW_PX,
364
367
  getItemKey,
365
368
  overscan: 8,
366
- anchorTo: 'end',
367
369
  // Include the end before the first pin, so its actual heights are known before revealing it.
368
370
  rangeExtractor: (range) => {
369
371
  const indexes = defaultRangeExtractor(range);
@@ -414,6 +416,26 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
414
416
  scrollGeometry.current = { top: element.scrollTop, height: element.scrollHeight, viewport: element.clientHeight };
415
417
  }, [rows.length, totalSize, composerHeight, frame.height]);
416
418
 
419
+ // Prepending history moves rows; keep the reader on the same row at the same height.
420
+ useLayoutEffect(() => {
421
+ const element = scrollRef.current;
422
+ const head = order?.[0];
423
+ const moved = head !== headRef.current;
424
+ headRef.current = head;
425
+ if (element === null) {
426
+ return;
427
+ }
428
+ const anchor = anchorRef.current;
429
+ const index = moved && anchor !== null && !followRef.current ? rows.findIndex((row) => row.id === anchor.id) : -1;
430
+ if (anchor !== null && index !== -1) {
431
+ virtualizer.getTotalSize();
432
+ element.scrollTop = virtualizer.measurementsCache[index]!.start - anchor.offset;
433
+ return;
434
+ }
435
+ const row = virtualizer.getVirtualItemForOffset(element.scrollTop);
436
+ anchorRef.current = row === undefined ? null : { id: String(row.key), offset: row.start - element.scrollTop };
437
+ });
438
+
417
439
  // Close to the top, the page before goes in; again after each page that left too little above the reader.
418
440
  useEffect(() => {
419
441
  const element = scrollRef.current;
@@ -10,7 +10,9 @@ import type { TimelineTarget } from '../logic/timeline-target';
10
10
  import { useChatHost } from '../../host';
11
11
  import { useChatScope } from '../../scope';
12
12
  import { useChatRow } from '../../state/chats';
13
+ import { copyWithChips, rowClipboardChips } from './chip-clipboard';
13
14
  import { useFileLinkCwd, useFileLinkScopeId, useOpenFileLink } from './file-links';
15
+ import { useReferenceLookup } from './thread-references';
14
16
  import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
15
17
 
16
18
  /* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
@@ -28,7 +30,8 @@ export function TimelineMenuPopup({
28
30
  column?: boolean;
29
31
  }) {
30
32
  const { t } = useTranslation('agent-chat');
31
- const message = target.row === null ? null : messageTextOf(target.row);
33
+ const row = target.row;
34
+ const message = row === null ? null : messageTextOf(row);
32
35
  const turnId = chatId === null || target.row === null ? null : turnIdOfRow(target.row);
33
36
  const forkBlocked = useChatRow(chatId ?? '', (row) => (turnId === null ? null : forkRefusal(row?.info ?? null, row?.structure[turnId])));
34
37
  const markdown = target.row === null ? null : markdownOf(target.row);
@@ -36,6 +39,7 @@ export function TimelineMenuPopup({
36
39
  // A thread outside a chat (a sub-agent's transcript) has no cwd of its own; the app answers for it then.
37
40
  const cwd = useFileLinkCwd();
38
41
  const scopeId = useFileLinkScopeId();
42
+ const lookup = useReferenceLookup(chatId ?? '');
39
43
  const { fork, fileLinks } = useChatHost();
40
44
  const openFileLink = useOpenFileLink();
41
45
  const openInPreview = (): void => {
@@ -50,8 +54,8 @@ export function TimelineMenuPopup({
50
54
  <ContextMenu.Item disabled={target.selection === ''} onClick={() => copyText(target.selection)}>
51
55
  <Icon icon={Copy} size={14} /> {t('common.action.copy')} <Kbd shortcut={EDIT_SHORTCUTS.copy} />
52
56
  </ContextMenu.Item>
53
- {message !== null && (
54
- <ContextMenu.Item onClick={() => copyText(message)}>
57
+ {row !== null && message !== null && (
58
+ <ContextMenu.Item onClick={() => copyWithChips(rowClipboardChips(row, message, lookup))}>
55
59
  <Icon icon={MessageSquare} size={14} /> {t('timeline.menu.copyMessage')}
56
60
  </ContextMenu.Item>
57
61
  )}
@@ -1,11 +1,11 @@
1
- import { useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
- import { visualThemeFragment, type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
4
+ import { VISUAL_LIMITS, visualThemeFragment, type ChatVisual, type VisualAppearance, type VisualTheme } from '@adecore/agent-contracts/visual';
5
5
  import { Spinner, useMeasuredWidth } from '@adecore/ui';
6
6
  import { useChatHost } from '../../host';
7
7
  import { useChatScope } from '../../scope';
8
- import { initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
8
+ import { DEFAULT_HEIGHT_PX, initialVisualContentHeight, rememberVisualHeight } from '../logic/visual-height';
9
9
  import { VisualBridge } from '../visual-bridge';
10
10
  import { VisualViewportController } from '../visual-viewport';
11
11
  import { useVisualTheme } from './visual-theme';
@@ -18,6 +18,25 @@ export interface VisualFrameProps {
18
18
  className?: string;
19
19
  }
20
20
 
21
+ export interface VisualPageFrameProps {
22
+ /* Reads the page's HTML each time the frame opens; the frame opens again when this changes, so keep it the same function while the page is. */
23
+ page: () => Promise<string>;
24
+ /* The frame's accessible name. */
25
+ title: string;
26
+ /* The tallest the page may grow on a host page without the viewport; left out, the limit of every visual. */
27
+ maxHeight?: number;
28
+ /* Fills its positioned parent and lets the page scroll inside it, rather than taking the height the page reports. */
29
+ fill?: boolean;
30
+ className?: string;
31
+ }
32
+
33
+ interface PageFrameProps extends VisualPageFrameProps {
34
+ maxHeight: number;
35
+ /* The height to start at before the page reports one, for a frame `width` pixels wide. */
36
+ initialHeight(width: number): number;
37
+ onHeight?(width: number, height: number): void;
38
+ }
39
+
21
40
  /*
22
41
  * A link the page asks to open counts only while a person acts in the frame: it has the focus, and a
23
42
  * click or a key in it is recent. A page can take the focus by script, so this stops a link opened as
@@ -27,15 +46,43 @@ function personActsIn(frame: HTMLIFrameElement): boolean {
27
46
  return frame.ownerDocument.activeElement === frame && navigator.userActivation?.isActive !== false;
28
47
  }
29
48
 
49
+ const defaultHeight = (): number => DEFAULT_HEIGHT_PX;
50
+
51
+ /* A visual of a chat, its page read from the chat's attachments under the visual's id. */
52
+ export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
53
+ const { attachments } = useChatHost();
54
+ const scope = useChatScope();
55
+ // The frame opens again when its page changes, so the reader keeps its identity while the visual does.
56
+ const page = useCallback(() => attachments.read(scope.id, chatId, visual.id).then((blob) => blob.text()), [attachments, scope.id, chatId, visual.id]);
57
+ return (
58
+ <PageFrame
59
+ page={page}
60
+ title={visual.title}
61
+ maxHeight={visual.maxHeight}
62
+ fill={fill}
63
+ className={className}
64
+ initialHeight={(width) => initialVisualContentHeight(visual, width)}
65
+ onHeight={(width, height) => rememberVisualHeight(visual.id, width, height)}
66
+ />
67
+ );
68
+ }
69
+
30
70
  /*
31
- * One visual's page in a frame that loads the app's sandbox host page; the bridge hands it the page and
32
- * the theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even
33
- * when the host page is served on the app's own. Its box keeps one height while it loads or fails.
71
+ * A visual's page outside a chat thread, such as one an app kept: the same sandboxed frame, theme and
72
+ * links as in a thread, with the page read through `page`. Draws nothing while the host has no `visuals`.
34
73
  */
35
- export function VisualFrame({ chatId, visual, fill = false, className }: VisualFrameProps) {
36
- const { visuals, attachments } = useChatHost();
74
+ export function VisualPageFrame({ maxHeight = VISUAL_LIMITS.maxHeight, ...props }: VisualPageFrameProps) {
75
+ return <PageFrame {...props} maxHeight={maxHeight} initialHeight={defaultHeight} />;
76
+ }
77
+
78
+ /*
79
+ * One page in a frame that loads the app's sandbox host page; the bridge hands it the page and the
80
+ * theme. The frame never gets `allow-same-origin`, which keeps the page on an opaque origin even when
81
+ * the host page is served on the app's own. Its box keeps one height while it loads or fails.
82
+ */
83
+ function PageFrame({ page, title, maxHeight, fill = false, className, initialHeight, onHeight }: PageFrameProps) {
84
+ const { visuals } = useChatHost();
37
85
  const { t } = useTranslation('agent-chat');
38
- const scope = useChatScope();
39
86
  const frameUrl = visuals?.frameUrl ?? null;
40
87
  const boxRef = useRef<HTMLDivElement | null>(null);
41
88
  const frameRef = useRef<HTMLIFrameElement>(null);
@@ -50,6 +97,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
50
97
  };
51
98
  const theme = useVisualTheme(boxRef);
52
99
  const widthRef = useRef(width);
100
+ const onHeightRef = useRef(onHeight);
53
101
  const [reported, setReported] = useState<number | null>(null);
54
102
  // The appearance the page is drawn in once it runs; until then the frame matches the sandbox host page, which declares none.
55
103
  const [drawn, setDrawn] = useState<VisualAppearance | null>(null);
@@ -59,11 +107,12 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
59
107
  if (opening === null && frameUrl !== null && theme !== null && width > 0) {
60
108
  setOpening({ src: frameUrl + visualThemeFragment(theme), theme });
61
109
  }
62
- const height = fill || width === 0 ? undefined : (reported ?? initialVisualContentHeight(visual, width));
110
+ const height = fill || width === 0 ? undefined : (reported ?? initialHeight(width));
63
111
 
64
112
  useLayoutEffect(() => {
65
113
  widthRef.current = width;
66
- }, [width]);
114
+ onHeightRef.current = onHeight;
115
+ }, [width, onHeight]);
67
116
 
68
117
  useLayoutEffect(() => {
69
118
  const frame = frameRef.current;
@@ -72,8 +121,8 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
72
121
  }
73
122
  const bridge = new VisualBridge({
74
123
  frame: () => frame.contentWindow,
75
- visual: { maxHeight: visual.maxHeight },
76
- page: attachments.read(scope.id, chatId, visual.id).then((blob) => blob.text()),
124
+ visual: { maxHeight },
125
+ page: page(),
77
126
  theme: opening.theme,
78
127
  mayOpenLink: () => personActsIn(frame),
79
128
  openLink: (url) => visuals?.openLink(url),
@@ -82,7 +131,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
82
131
  return;
83
132
  }
84
133
  setReported(next);
85
- rememberVisualHeight(visual.id, widthRef.current, next);
134
+ onHeightRef.current?.(widthRef.current, next);
86
135
  },
87
136
  ...(!fill
88
137
  ? {
@@ -102,7 +151,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
102
151
  bridge.dispose();
103
152
  bridgeRef.current = null;
104
153
  };
105
- }, [opening, scope.id, chatId, visual.id, visual.maxHeight, fill, visuals, attachments]);
154
+ }, [opening, page, maxHeight, fill, visuals]);
106
155
 
107
156
  useLayoutEffect(() => {
108
157
  const box = boxRef.current;
@@ -118,7 +167,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
118
167
  controller.dispose();
119
168
  controllerRef.current = null;
120
169
  };
121
- }, [fill, viewportReady, opening, scope.id, chatId, visual.id, visual.maxHeight]);
170
+ }, [fill, viewportReady, opening, page, maxHeight]);
122
171
 
123
172
  useLayoutEffect(() => controllerRef.current?.refresh(), [height, width]);
124
173
 
@@ -135,7 +184,7 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
135
184
  <iframe
136
185
  ref={frameRef}
137
186
  src={opening.src}
138
- title={visual.title}
187
+ title={title}
139
188
  loading="lazy"
140
189
  sandbox="allow-scripts allow-forms"
141
190
  className={clsx('block size-full border-0', failed && 'invisible')}
@@ -145,13 +194,11 @@ export function VisualFrame({ chatId, visual, fill = false, className }: VisualF
145
194
  />
146
195
  )}
147
196
  {failed ? (
148
- <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">
149
- {t('visuals.failed', { title: visual.title })}
150
- </p>
197
+ <p className="absolute inset-0 grid place-items-center px-4 text-center text-xs text-text-muted">{t('visuals.failed', { title })}</p>
151
198
  ) : (
152
199
  drawn === null && (
153
200
  <span className="chat-visual-wait pointer-events-none absolute inset-0 grid place-items-center text-text-faint">
154
- <Spinner size={16} label={t('visuals.loading', { title: visual.title })} />
201
+ <Spinner size={16} label={t('visuals.loading', { title })} />
155
202
  </span>
156
203
  )
157
204
  )}
@@ -0,0 +1,115 @@
1
+ import type { ChatAssistantItem, ChatUserItem } from '@adecore/agent-contracts';
2
+ import { copyText } from '@adecore/ui';
3
+ import { referenceTokens } from '../chat-references';
4
+ import {
5
+ clipboardChipsOf,
6
+ hasClipboardChips,
7
+ tokenizeChips,
8
+ tokenizeNamedReferences,
9
+ writeClipboardChips,
10
+ type ChipSegment,
11
+ type ClipboardChips
12
+ } from '../mentions';
13
+ import type { TimelineRow } from '../logic/timeline';
14
+ import type { ReferenceLookup } from './reference-lookup';
15
+
16
+ /* The chips a copy of a whole message carries: what the person picked, or what a reply names. */
17
+ export function messageClipboardChips(
18
+ item: ChatUserItem | ChatAssistantItem,
19
+ text: string,
20
+ lookup: Pick<ReferenceLookup, 'items' | 'referenced'>
21
+ ): ClipboardChips {
22
+ if (item.kind === 'assistant') {
23
+ return clipboardChipsOf(text, tokenizeNamedReferences(text, lookup.items, lookup.referenced));
24
+ }
25
+ const tokens = referenceTokens([...(item.chats ?? []), ...(item.references ?? [])], lookup.items);
26
+ return clipboardChipsOf(text, tokenizeChips(text, item.mentions ?? [], item.skills ?? [], tokens));
27
+ }
28
+
29
+ /* The chips a copy of a row of the thread carries; only a message has any. */
30
+ export function rowClipboardChips(row: TimelineRow, text: string, lookup: Pick<ReferenceLookup, 'items' | 'referenced'>): ClipboardChips {
31
+ if (row.kind === 'user' || row.kind === 'assistant') {
32
+ return messageClipboardChips(row.item, text, lookup);
33
+ }
34
+ return clipboardChipsOf(text, []);
35
+ }
36
+
37
+ /* What a chip the transcript drew stands for, read back from the attributes `TokenChip` writes. */
38
+ function segmentOf(chip: Element): ChipSegment | null {
39
+ const value = chip.getAttribute('data-chip-value');
40
+ if (value === null) {
41
+ return null;
42
+ }
43
+ switch (chip.getAttribute('data-chip')) {
44
+ case 'mention':
45
+ return { kind: 'mention', path: value };
46
+ case 'skill':
47
+ return { kind: 'skill', name: value };
48
+ case 'reference': {
49
+ const name = chip.getAttribute('data-chip-name');
50
+ return name === null || name === '' ? null : { kind: 'reference', id: value, name };
51
+ }
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /* The chips a selection holds whole, beside the text it copies; a chip cut in half is only text. */
58
+ export function selectionChips(selection: Selection): ClipboardChips {
59
+ const segments: ChipSegment[] = [];
60
+ for (let i = 0; i < selection.rangeCount; i++) {
61
+ const container = selection.getRangeAt(i).commonAncestorContainer;
62
+ const root = container instanceof Element ? container : container.parentElement;
63
+ const own = root?.closest('[data-chip]');
64
+ const candidates = own ? [own] : [...(root?.querySelectorAll('[data-chip]') ?? [])];
65
+ for (const chip of candidates) {
66
+ const segment = selection.containsNode(chip, false) ? segmentOf(chip) : null;
67
+ if (segment !== null) {
68
+ segments.push(segment);
69
+ }
70
+ }
71
+ }
72
+ return clipboardChipsOf(selection.toString(), segments);
73
+ }
74
+
75
+ /* A copy of a selection in the thread, which carries the chips in it as well when it holds any. */
76
+ export function copySelectionWithChips(event: ClipboardEvent): void {
77
+ const selection = document.getSelection();
78
+ if (event.defaultPrevented || event.clipboardData === null || selection === null || selection.isCollapsed) {
79
+ return;
80
+ }
81
+ const chips = selectionChips(selection);
82
+ if (!hasClipboardChips(chips)) {
83
+ return;
84
+ }
85
+ writeClipboardChips(event.clipboardData, chips);
86
+ event.preventDefault();
87
+ }
88
+
89
+ /*
90
+ * Copies a text and its chips. The asynchronous clipboard writes no type of an app's own that a paste
91
+ * can read, so the copy goes through a copy event, which only a page with the user's gesture gets.
92
+ */
93
+ export function copyWithChips(chips: ClipboardChips): void {
94
+ if (!hasClipboardChips(chips) || typeof document.execCommand !== 'function') {
95
+ void copyText(chips.text);
96
+ return;
97
+ }
98
+ let written = false;
99
+ const write = (event: ClipboardEvent): void => {
100
+ if (event.clipboardData !== null) {
101
+ writeClipboardChips(event.clipboardData, chips);
102
+ event.preventDefault();
103
+ written = true;
104
+ }
105
+ };
106
+ document.addEventListener('copy', write, true);
107
+ try {
108
+ document.execCommand('copy');
109
+ } finally {
110
+ document.removeEventListener('copy', write, true);
111
+ }
112
+ if (!written) {
113
+ void copyText(chips.text);
114
+ }
115
+ }
@@ -1,4 +1,4 @@
1
- /* An `@file` or `$skill` token drawn as a pill: the tone says which of the two it is, the shape
1
+ /* An `@file`, `$skill` or `#reference` token drawn as a pill: the tone says which it is, the shape
2
2
  says where it is drawn. */
3
3
 
4
4
  export const MENTION_TONE = 'bg-accent-soft text-accent shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--accent)_25%,transparent)]';
@@ -6,10 +6,29 @@ export const MENTION_TONE = 'bg-accent-soft text-accent shadow-[inset_0_0_0_1px_
6
6
  /* A skill is the same token shape as a file mention, in its own hue. */
7
7
  export const SKILL_TONE = 'bg-skill-soft text-skill shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--skill)_25%,transparent)]';
8
8
 
9
+ /* A `#` to a view, node or chat is neutral, so it never reads as a file or a skill. */
10
+ export const REFERENCE_TONE = 'bg-text/8 text-text shadow-[inset_0_0_0_1px_var(--border-strong)]';
11
+
12
+ /* A `#` whose view or node is no longer in the project. */
13
+ export const REFERENCE_GONE_TONE = 'bg-text/4 text-text-faint shadow-[inset_0_0_0_1px_var(--border)]';
14
+
9
15
  /* The pill in the composer's editor, around the token's own characters: the editor places the caret
10
16
  from what it drew, so padding is safe, and a chip that wraps keeps it on every fragment. */
11
17
  export const CHIP_IN_EDITOR = 'box-decoration-clone rounded-md px-1 py-0.5';
12
18
 
19
+ /* A picked or named token in a message or a reply, a shade smaller than the text around it. Inline like
20
+ the editor's pill, so its name sits on the baseline of the sentence, and a selection copies it on the
21
+ same line as the words around it, where a flex box would put it on lines of its own. */
22
+ export const TOKEN_IN_MESSAGE = 'box-decoration-clone rounded-md px-1.5 py-0.5 text-xs font-medium';
23
+
24
+ /* The mark in place of the sigil, a 14px icon. Inline, since the base styles make an svg a block of its
25
+ own. Its middle goes on the middle of the name's capitals, where a mark reads as centered; the middle
26
+ of the x-height `Icon` aligns on sits low next to a capital. */
27
+ export const TOKEN_ICON = 'mr-1 inline align-[calc(0.5cap-7px)]!';
28
+
29
+ /* The same middle for the 12px mark the composer draws, in a box whose bottom edge is its baseline. */
30
+ export const TOKEN_ICON_IN_EDITOR = 'mr-1 inline-block align-[calc(0.5cap-6px)]';
31
+
13
32
  /* The same pill in the transcript: a real inline box a shade smaller than the text around it, with
14
33
  the icon in place of the sigil. */
15
34
  export const CHIP_IN_MESSAGE = 'inline-flex h-5 max-w-full items-center gap-1 rounded-md px-1.5 align-[-3px] text-xs/none font-medium';
@@ -1,8 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import clsx from 'clsx';
4
- import { MessageSquare, SquareSlash, Zap } from 'lucide-react';
4
+ import { SquareSlash, Zap } from 'lucide-react';
5
5
  import { FileIcon, Icon, Menu, MiddleTruncate } from '@adecore/ui';
6
+ import { REFERENCE_ICONS } from '../reference-icons';
6
7
  import type { ComposerMenus as Menus } from './use-composer-menus';
7
8
  import { optionIdOf } from './menu-aria';
8
9
 
@@ -72,52 +73,64 @@ function SkillMenu({ menus }: { menus: Menus }) {
72
73
 
73
74
  function MentionMenu({ menus, query }: { menus: Menus; query: string }) {
74
75
  const { t } = useTranslation('agent-chat');
75
- const { listId, chatMatches, files, mentionCount } = menus;
76
- const mentionsLabelId = `${listId}-chats`;
76
+ const { listId, files, mentionCount } = menus;
77
77
  const filesLabelId = `${listId}-files`;
78
- const filesLabelShown = query === '' || chatMatches.length > 0;
78
+ const filesLabelShown = query === '';
79
79
  return (
80
80
  <div className="border-b border-border px-1.5 py-1.5">
81
81
  {mentionCount > 0 && (
82
- <div id={listId} role="listbox" aria-label={t('composer.menu.mentions')}>
83
- {chatMatches.length > 0 && (
84
- <div role="group" aria-labelledby={mentionsLabelId}>
85
- <Menu.Label id={mentionsLabelId}>{t('composer.mentions.chats')}</Menu.Label>
86
- {chatMatches.map((chat, index) => (
87
- <MenuOption key={chat.id} menus={menus} index={index} className="items-center" onPick={() => menus.chooseChat(chat.id)}>
88
- <Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
89
- <span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
90
- </MenuOption>
91
- ))}
92
- </div>
93
- )}
94
- {files.length > 0 && (
95
- <div role="group" {...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}>
96
- {filesLabelShown && (
97
- <Menu.Label id={filesLabelId}>{query === '' ? t('composer.mentions.here') : t('composer.mentions.files')}</Menu.Label>
98
- )}
99
- {files.map((path, index) => {
100
- const { name, dir } = splitPath(path);
82
+ <div id={listId} role="listbox" {...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}>
83
+ {filesLabelShown && <Menu.Label id={filesLabelId}>{t('composer.mentions.here')}</Menu.Label>}
84
+ {files.map((path, index) => {
85
+ const { name, dir } = splitPath(path);
86
+ return (
87
+ <MenuOption key={path} menus={menus} index={index} className="items-center" onPick={() => menus.chooseMention(path)}>
88
+ <FileIcon path={path} size={14} />
89
+ <span className="truncate font-mono text-text">{name}</span>
90
+ {dir && <MiddleTruncate text={dir} tooltip={false} className="text-text-faint" />}
91
+ </MenuOption>
92
+ );
93
+ })}
94
+ </div>
95
+ )}
96
+ {mentionCount === 0 && (
97
+ <div className="px-2 py-1 text-xs text-text-faint">{query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}</div>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ /* The views, nodes and chats a `#` offers, what stands on this canvas first, each with its kind or what tells it apart. */
104
+ function ReferenceMenu({ menus }: { menus: Menus }) {
105
+ const { t } = useTranslation('agent-chat');
106
+ const { listId, referenceMatches } = menus;
107
+ const groups = (['canvas', 'views'] as const)
108
+ .map((group) => ({ group, items: referenceMatches.filter((item) => item.group === group) }))
109
+ .filter((entry) => entry.items.length > 0);
110
+ return (
111
+ <div className="border-b border-border px-1.5 py-1.5">
112
+ {referenceMatches.length > 0 ? (
113
+ <div id={listId} role="listbox" aria-label={t('composer.menu.references')}>
114
+ {groups.map(({ group, items }) => (
115
+ <div key={group} role="group" aria-labelledby={`${listId}-${group}`}>
116
+ <Menu.Label id={`${listId}-${group}`}>{t(`composer.references.${group}`)}</Menu.Label>
117
+ {items.map((item) => {
118
+ const index = referenceMatches.indexOf(item);
101
119
  return (
102
- <MenuOption
103
- key={path}
104
- menus={menus}
105
- index={chatMatches.length + index}
106
- className="items-center"
107
- onPick={() => menus.chooseMention(path)}
108
- >
109
- <FileIcon path={path} size={14} />
110
- <span className="truncate font-mono text-text">{name}</span>
111
- {dir && <MiddleTruncate text={dir} tooltip={false} className="text-text-faint" />}
120
+ <MenuOption key={item.id} menus={menus} index={index} className="items-center" onPick={() => menus.chooseReference(item)}>
121
+ <Icon icon={REFERENCE_ICONS[item.kind]} size={14} className="shrink-0 text-text-faint" />
122
+ <span className="truncate text-text">{item.name}</span>
123
+ <span className="ml-auto shrink-0 truncate pl-2 text-text-faint">
124
+ {item.detail ?? t(`composer.references.kinds.${item.kind}`)}
125
+ </span>
112
126
  </MenuOption>
113
127
  );
114
128
  })}
115
129
  </div>
116
- )}
130
+ ))}
117
131
  </div>
118
- )}
119
- {mentionCount === 0 && (
120
- <div className="px-2 py-1 text-xs text-text-faint">{query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}</div>
132
+ ) : (
133
+ <div className="px-2 py-1 text-xs text-text-faint">{t('composer.references.noMatch')}</div>
121
134
  )}
122
135
  </div>
123
136
  );
@@ -134,5 +147,8 @@ export function ComposerMenu({ menus }: { menus: Menus }) {
134
147
  if (menus.mentionMenuOpen && menus.mention !== null) {
135
148
  return <MentionMenu menus={menus} query={menus.mention.query} />;
136
149
  }
150
+ if (menus.referenceMenuOpen) {
151
+ return <ReferenceMenu menus={menus} />;
152
+ }
137
153
  return null;
138
154
  }
@@ -5,7 +5,6 @@ import { Icon, IconButton, Tooltip } from '@adecore/ui';
5
5
  import { useChatScope } from '../../../scope';
6
6
  import { formatBytes, isImageAttachment, uploadBytes } from '../../attachments';
7
7
  import { jumpToTimelineItem } from '../../timeline-scroll';
8
- import { ChatReferenceChip } from '../ChatReferenceChip';
9
8
  import { UploadThumb } from '../UploadThumb';
10
9
 
11
10
  const ROW = 'px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3';
@@ -43,17 +42,6 @@ export function DraftQuote({ chatId, quote, source, onRemove, onSourceGone }: Dr
43
42
  );
44
43
  }
45
44
 
46
- /* Other chats the message points at, beside the text rather than in it. */
47
- export function DraftChats({ chats, onRemove }: { chats: readonly string[]; onRemove(id: string): void }) {
48
- return (
49
- <div className={`flex flex-wrap gap-1.5 ${ROW}`}>
50
- {chats.map((id) => (
51
- <ChatReferenceChip key={id} chatId={id} onRemove={() => onRemove(id)} />
52
- ))}
53
- </div>
54
- );
55
- }
56
-
57
45
  /* The files the message carries: an image as a thumbnail, anything else by its name and size. */
58
46
  export function DraftAttachments({ attachments, onRemove }: { attachments: readonly ChatAttachmentUpload[]; onRemove(index: number): void }) {
59
47
  const { t } = useTranslation('agent-chat');