@adecore/agents-react 0.37.3 → 0.39.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 (66) hide show
  1. package/dist/agents/AgentModelLabel.d.ts +10 -0
  2. package/dist/agents/AgentModelLabel.js +97 -0
  3. package/dist/chat/logic/visual-height.d.ts +1 -0
  4. package/dist/chat/logic/visual-height.js +1 -1
  5. package/dist/chat/ui/Composer.js +385 -360
  6. package/dist/chat/ui/HostMenuItems.d.ts +4 -0
  7. package/dist/chat/ui/HostMenuItems.js +15 -0
  8. package/dist/chat/ui/Pickers.d.ts +2 -1
  9. package/dist/chat/ui/Pickers.js +126 -123
  10. package/dist/chat/ui/PromptEditor.d.ts +19 -0
  11. package/dist/chat/ui/PromptEditor.js +115 -0
  12. package/dist/chat/ui/Timeline.d.ts +2 -1
  13. package/dist/chat/ui/Timeline.js +13 -1
  14. package/dist/chat/ui/VisualFrame.d.ts +8 -0
  15. package/dist/chat/ui/VisualFrame.js +217 -115
  16. package/dist/chat/ui/composer/ComposerControls.d.ts +3 -0
  17. package/dist/chat/ui/composer/ComposerControls.js +235 -38
  18. package/dist/chat/ui/composer/key-handler.d.ts +1 -1
  19. package/dist/chat/ui/composer/key-handler.js +3 -11
  20. package/dist/chat/ui/composer/prompt-picks.d.ts +4 -0
  21. package/dist/chat/ui/composer/prompt-picks.js +14 -0
  22. package/dist/chat/ui/composer/use-composer-menus.d.ts +4 -49
  23. package/dist/chat/ui/composer/use-composer-menus.js +65 -519
  24. package/dist/chat/ui/composer/use-prompt-menus.d.ts +73 -0
  25. package/dist/chat/ui/composer/use-prompt-menus.js +536 -0
  26. package/dist/chat/ui/index.d.ts +4 -0
  27. package/dist/chat/ui/index.js +3 -0
  28. package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
  29. package/dist/chat/ui/rows/VisualRow.js +184 -142
  30. package/dist/chat/ui/rows/row-rhythm.js +6 -4
  31. package/dist/host-hooks.d.ts +2 -1
  32. package/dist/host-hooks.js +6 -0
  33. package/dist/host.d.ts +27 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/locales/en/agent-chat.d.ts +1 -0
  36. package/dist/locales/en/agent-chat.js +1 -0
  37. package/dist/locales/nl/agent-chat.d.ts +1 -0
  38. package/dist/locales/nl/agent-chat.js +1 -0
  39. package/dist/providers/index.d.ts +1 -0
  40. package/dist/providers/index.js +1 -0
  41. package/dist/theme.css +3 -0
  42. package/package.json +8 -8
  43. package/src/agents/AgentModelLabel.tsx +40 -0
  44. package/src/chat/logic/visual-height.ts +1 -1
  45. package/src/chat/ui/Composer.tsx +12 -3
  46. package/src/chat/ui/HostMenuItems.tsx +11 -0
  47. package/src/chat/ui/Pickers.tsx +4 -1
  48. package/src/chat/ui/PromptEditor.tsx +130 -0
  49. package/src/chat/ui/Timeline.tsx +22 -1
  50. package/src/chat/ui/VisualFrame.tsx +68 -21
  51. package/src/chat/ui/composer/ComposerControls.tsx +65 -15
  52. package/src/chat/ui/composer/key-handler.ts +3 -9
  53. package/src/chat/ui/composer/prompt-picks.ts +17 -0
  54. package/src/chat/ui/composer/use-composer-menus.ts +18 -275
  55. package/src/chat/ui/composer/use-prompt-menus.ts +341 -0
  56. package/src/chat/ui/index.ts +4 -0
  57. package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
  58. package/src/chat/ui/rows/VisualRow.tsx +5 -1
  59. package/src/chat/ui/rows/row-rhythm.ts +6 -4
  60. package/src/host-hooks.ts +9 -1
  61. package/src/host.ts +45 -1
  62. package/src/index.ts +3 -0
  63. package/src/locales/en/agent-chat.ts +1 -0
  64. package/src/locales/nl/agent-chat.ts +1 -0
  65. package/src/providers/index.ts +1 -0
  66. package/src/theme.css +3 -0
@@ -0,0 +1,40 @@
1
+ import { useContext } from 'react';
2
+ import clsx from 'clsx';
3
+ import type { AgentKind, UsageProvider } from '@adecore/agent-contracts';
4
+ import { ChatScopeContext } from '../scope';
5
+ import { useProvidersStore } from '../state/providers';
6
+ import { AgentIcon } from './AgentIcon';
7
+ import { modelName } from './model-name';
8
+ import { ProviderLogo } from './ProviderLogo';
9
+
10
+ interface AgentModelLabelProps {
11
+ provider: AgentKind;
12
+ /* The model's slug; absent names the CLI itself. */
13
+ model?: string;
14
+ /* The machine whose catalog names the model; absent, the chat scope the label is drawn in. */
15
+ scopeId?: string;
16
+ size?: number;
17
+ className?: string;
18
+ }
19
+
20
+ /*
21
+ * Who answered or will answer, in one line: the CLI's mark and the model, named the way the machine's
22
+ * catalog names it, as a row of facts writes it ("made by", "runs as").
23
+ */
24
+ export function AgentModelLabel({ provider, model, scopeId, size = 14, className }: AgentModelLabelProps) {
25
+ // Outside a chat scope a host names the machine itself; with neither, the CLI still draws, by its kind.
26
+ const scope = useContext(ChatScopeContext);
27
+ const id = scopeId ?? scope?.id;
28
+ const catalog = useProvidersStore((s) => (id === undefined ? undefined : s.byScope[id]?.providers.find((entry) => entry.kind === provider)));
29
+ const name = model === undefined ? (catalog?.name ?? provider) : modelName(model, catalog?.models);
30
+ return (
31
+ <span className={clsx('flex min-w-0 items-center gap-1.5', className)}>
32
+ {provider === 'claude' || provider === 'codex' ? (
33
+ <ProviderLogo provider={provider as UsageProvider} size={size} />
34
+ ) : (
35
+ <AgentIcon kind={provider} size={size} className="shrink-0" />
36
+ )}
37
+ <span className="truncate">{name}</span>
38
+ </span>
39
+ );
40
+ }
@@ -1,7 +1,7 @@
1
1
  import { VISUAL_LIMITS, visualFrameHeight, type ChatVisual } from '@adecore/agent-contracts/visual';
2
2
 
3
3
  /* A frame before anything measured its page: room for a chart, never taller than the page may grow. */
4
- const DEFAULT_HEIGHT_PX = 240;
4
+ export const DEFAULT_HEIGHT_PX = 240;
5
5
  /* Plenty for the visuals of a long session; the oldest goes first. */
6
6
  const REMEMBERED_HEIGHTS = 500;
7
7
  // Stay within browser layout coordinates even if a page reports a bogus finite size.
@@ -44,7 +44,7 @@ import { usePromptHistory } from './composer/use-prompt-history';
44
44
  import { usePromptHold } from './composer/use-prompt-hold';
45
45
  import { surfacePartOf } from './composer/surface';
46
46
  import { useTakeBack } from './composer/use-take-back';
47
- import { useHostExtraPrompts, useHostSendDelivery } from '../../host-hooks';
47
+ import { useHostComposerSendActions, useHostExtraPrompts, useHostSendDelivery } from '../../host-hooks';
48
48
 
49
49
  interface ComposerProps {
50
50
  chatId: string;
@@ -121,6 +121,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
121
121
  // The stopped turn something went out for, since the chat takes a moment to answer and Continue must not stand there meanwhile.
122
122
  const [sentFor, setSentFor] = useState<string | null>(null);
123
123
  const extraPrompts = useHostExtraPrompts(scope.id, chatId);
124
+ const sendActions = useHostComposerSendActions(scope.id, chatId);
124
125
  const { submit, continueTurn, restoreStashed, toggleStash } = useComposerSubmit({
125
126
  chatId,
126
127
  writable,
@@ -189,7 +190,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
189
190
  history,
190
191
  noteKey: files.noteKey,
191
192
  toggleStash,
192
- setText: (next) => box.setText(next),
193
193
  submit
194
194
  });
195
195
 
@@ -336,7 +336,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
336
336
  onContinue:
337
337
  continuable !== null && continuable !== sentFor && empty && writable && pending.length === 0 && extraPrompts.length === 0
338
338
  ? continueTurn
339
- : null
339
+ : null,
340
+ actions: sendActions,
341
+ draft: {
342
+ scopeId: scope.id,
343
+ chatId,
344
+ text: withQuote(draft.quote, draft.text.trim()),
345
+ provider: info.provider,
346
+ selection: info.selection,
347
+ runtimeMode: info.runtimeMode
348
+ }
340
349
  }}
341
350
  dictationToolbar={setDictationToolbar}
342
351
  onFiles={(picked) => {
@@ -0,0 +1,11 @@
1
+ import { Icon, Menu } from '@adecore/ui';
2
+ import type { HostMenuAction } from '../../host';
3
+
4
+ /* The items a host adds to a menu of the chat's, in the order it gave them. */
5
+ export function HostMenuItems({ actions }: { actions: readonly HostMenuAction[] }) {
6
+ return actions.map((action) => (
7
+ <Menu.Item key={action.id} disabled={action.disabled} onClick={() => action.run()}>
8
+ {action.icon !== undefined && <Icon icon={action.icon} size={14} />} {action.label}
9
+ </Menu.Item>
10
+ ));
11
+ }
@@ -34,6 +34,8 @@ interface ModelPickerProps {
34
34
  side?: 'top' | 'bottom';
35
35
  /* `chip` names the provider beside the model on a secondary button, for a picker that sits in a card or a form. */
36
36
  trigger?: 'plain' | 'chip';
37
+ /* The chip's height: `sm` in a card, `md` beside the fields of a form. */
38
+ chipSize?: 'sm' | 'md';
37
39
  /* Where the focus goes when the popup closes; by default back to the trigger. */
38
40
  finalFocus?: RefObject<HTMLElement | null>;
39
41
  /* A note under the list, such as what picking an agent here does. */
@@ -55,6 +57,7 @@ export function ModelPicker({
55
57
  kbd = '/model',
56
58
  side = 'top',
57
59
  trigger = 'plain',
60
+ chipSize = 'sm',
58
61
  finalFocus,
59
62
  footer
60
63
  }: ModelPickerProps) {
@@ -150,7 +153,7 @@ export function ModelPicker({
150
153
  <Popover.Root open={open} onOpenChange={setOpen}>
151
154
  <Tooltip label={owner ? `${owner.name} · ${current}` : t('pickers.model.choose')} kbd={kbd ?? undefined}>
152
155
  {trigger === 'chip' ? (
153
- <Popover.Trigger render={<Button variant="secondary" size="sm" className="min-w-0 shrink gap-1.5" />}>
156
+ <Popover.Trigger render={<Button variant="secondary" size={chipSize} className="min-w-0 shrink gap-1.5" />}>
154
157
  {chip.mark === 'logo' ? <ProviderLogo provider={provider as UsageProvider} size={12} /> : <AgentIcon kind={provider} size={12} />}
155
158
  <span className="truncate">{chip.name}</span>
156
159
  <span className="max-w-32 truncate text-text-muted">{chip.model}</span>
@@ -0,0 +1,130 @@
1
+ import { useLayoutEffect, useRef, type SetStateAction } from 'react';
2
+ import clsx from 'clsx';
3
+ import { Surface } from '@adecore/ui';
4
+ import type { ChatSkill } from '@adecore/agent-contracts';
5
+ import type { Referables } from '../chat-references';
6
+ import { ComposerInput, type ComposerInputHandle } from './ComposerInput';
7
+ import { chipDecorations } from './composer/chips';
8
+ import { ComposerMenu } from './composer/ComposerMenu';
9
+ import { pickInMenu } from './composer/key-handler';
10
+ import { usePromptMenus, type PromptDraft } from './composer/use-prompt-menus';
11
+
12
+ interface PromptEditorProps {
13
+ value: PromptDraft;
14
+ onChange(next: PromptDraft): void;
15
+ /* The editor's accessible name; a label element cannot point at the editable element. */
16
+ label: string;
17
+ placeholder: string;
18
+ skills: readonly ChatSkill[];
19
+ /* What the CLI answers after `/`, besides `/goal` where `goals` is set. */
20
+ slashCommands?: readonly string[];
21
+ goals?: boolean;
22
+ /* Where `@` searches for files; null offers none. */
23
+ cwd: string | null;
24
+ referables: Referables;
25
+ providerName?: string;
26
+ disabled?: boolean;
27
+ className?: string;
28
+ }
29
+
30
+ const NO_COMMANDS: readonly string[] = [];
31
+
32
+ /*
33
+ * A prompt written now and sent later, as a form field: the composer's editor with its pickers and chips,
34
+ * without a chat behind it. The host hands in what the CLI it goes to has, and keeps what was picked.
35
+ */
36
+ export function PromptEditor({
37
+ value,
38
+ onChange,
39
+ label,
40
+ placeholder,
41
+ skills,
42
+ slashCommands = NO_COMMANDS,
43
+ goals = false,
44
+ cwd,
45
+ referables,
46
+ providerName,
47
+ disabled = false,
48
+ className
49
+ }: PromptEditorProps) {
50
+ const inputRef = useRef<ComposerInputHandle>(null);
51
+ // A pick writes twice in one event (the text, then what it picked), and the second must build on the first.
52
+ const latest = useRef(value);
53
+
54
+ useLayoutEffect(() => {
55
+ latest.current = value;
56
+ }, [value]);
57
+
58
+ const setDraft = (action: SetStateAction<PromptDraft>): void => {
59
+ const next = typeof action === 'function' ? action(latest.current) : action;
60
+ latest.current = next;
61
+ onChange(next);
62
+ };
63
+
64
+ const setText = (next: string, mentions = value.mentions, chosen = value.skills): void => {
65
+ setDraft((current) => ({ ...current, text: next, mentions, skills: chosen }));
66
+ };
67
+
68
+ const menus = usePromptMenus({
69
+ draft: { draft: value, setDraft, setText },
70
+ inputRef,
71
+ cwd,
72
+ skills,
73
+ slashCommands,
74
+ goals,
75
+ providerName,
76
+ referables,
77
+ label
78
+ });
79
+ const extensions = chipDecorations({ mentions: value.mentions, skills: value.skills, references: menus.referenceChips });
80
+
81
+ const onKeyDown = (e: KeyboardEvent): boolean => {
82
+ if (e.isComposing) {
83
+ return false;
84
+ }
85
+ // Escape closes the picker, and only that: the dialog around the field stays.
86
+ if (e.key === 'Escape' && menus.menuOpen) {
87
+ menus.close();
88
+ e.stopPropagation();
89
+ return true;
90
+ }
91
+ // Enter adds a line here rather than sending, so on a command it completes like Tab.
92
+ if (e.key === 'Enter' && menus.commandMenuOpen) {
93
+ menus.completeCommand(menus.commands[menus.activeIndex]!);
94
+ return true;
95
+ }
96
+ return pickInMenu(e, menus);
97
+ };
98
+
99
+ return (
100
+ <div className={clsx('relative', className)}>
101
+ {/* Over the form under the field, not inside it, so what a person types never moves while the picker opens and closes. */}
102
+ {menus.menuOpen && (
103
+ <Surface className="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-lg bg-clip-padding *:border-b-0">
104
+ <ComposerMenu menus={menus} />
105
+ </Surface>
106
+ )}
107
+ <div className="field h-auto overflow-hidden p-0 focus-ring-within">
108
+ <ComposerInput
109
+ ref={inputRef}
110
+ dictationToolbar={null}
111
+ className="composer-input prompt-editor select-text text-sm leading-normal text-text"
112
+ value={value.text}
113
+ placeholder={placeholder}
114
+ disabled={disabled}
115
+ tabbable
116
+ extensions={extensions}
117
+ aria={menus.inputAria}
118
+ onChange={(text, selection, state) => {
119
+ setText(text);
120
+ menus.setMenuIndex(0);
121
+ menus.trackTriggers(text, selection, state);
122
+ }}
123
+ onSelectionChange={menus.trackTriggers}
124
+ onBlur={menus.close}
125
+ onKeyDown={onKeyDown}
126
+ />
127
+ </div>
128
+ </div>
129
+ );
130
+ }
@@ -180,8 +180,21 @@ const ThreadRow = memo(function ThreadRow({ row, previous, index, top, measure,
180
180
  /*
181
181
  * `overlay` stands in the thread's place and ends where the composer starts, so the thread keeps
182
182
  * where it was underneath and the composer stays in reach for the prompts it answers.
183
+ * `onContentHeight` hears what the thread's rows and its padding take, without the composer, once
184
+ * the rows in view are laid out at their real heights, for a host that sizes its frame to the thread.
185
+ * Rows out of view still count at an estimate, so a frame up to the viewport's height gets it exact.
183
186
  */
184
- export function Timeline({ chatId, composer, overlay }: { chatId: string; composer?: ReactNode; overlay?: ReactNode }) {
187
+ export function Timeline({
188
+ chatId,
189
+ composer,
190
+ overlay,
191
+ onContentHeight
192
+ }: {
193
+ chatId: string;
194
+ composer?: ReactNode;
195
+ overlay?: ReactNode;
196
+ onContentHeight?: (height: number) => void;
197
+ }) {
185
198
  const host = useChatHost();
186
199
  const { fork } = host;
187
200
  const { t } = useTranslation('agent-chat');
@@ -406,6 +419,14 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
406
419
  setMeasuredThread(keyOf(chatId));
407
420
  }
408
421
  }, [initialLayout, empty, loadingHistory, frame.height, virtualRows, virtualizer, rows.length, keyOf, chatId]);
422
+ useLayoutEffect(() => {
423
+ const thread = threadRef.current;
424
+ if (onContentHeight === undefined || initialLayout || empty || loadingHistory || thread === null) {
425
+ return;
426
+ }
427
+ const style = getComputedStyle(thread);
428
+ onContentHeight(totalSize + (Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0));
429
+ }, [onContentHeight, initialLayout, empty, loadingHistory, totalSize]);
409
430
  useLayoutEffect(() => {
410
431
  const element = scrollRef.current;
411
432
  if (!followRef.current || element === null) {
@@ -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
  )}
@@ -1,14 +1,16 @@
1
- import { useRef, type Ref } from 'react';
1
+ import { useRef, useState, type Ref } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { ArrowUp, Play, Plus, Square } from 'lucide-react';
3
+ import clsx from 'clsx';
4
+ import { ArrowUp, ChevronDown, Play, Plus, Square } from 'lucide-react';
4
5
  import type { ChatInfo } from '@adecore/agent-contracts';
5
- import { Button, ErrorBoundary, Icon, Tooltip, type Shortcut } from '@adecore/ui';
6
- import { useChatHost } from '../../../host';
6
+ import { Button, ErrorBoundary, Icon, Menu, Tooltip, type Shortcut } from '@adecore/ui';
7
+ import { useChatHost, type ComposerSendAction, type ComposerSendDraft } from '../../../host';
7
8
  import { useChatScope } from '../../../scope';
8
9
  import { useProviders } from '../../../state/providers';
9
10
  import { useAccountChoice } from '../../account-choice';
10
11
  import type { StashedPrompt } from '../../stash';
11
12
  import { composerStopLabel } from '../../subagent-list';
13
+ import { HostMenuItems } from '../HostMenuItems';
12
14
  import { StashPicker } from '../Pickers';
13
15
  import { RunSettings } from '../RunSettings';
14
16
  import type { ChatControls } from './use-chat-controls';
@@ -22,6 +24,64 @@ interface SendProps {
22
24
  onSend(alternate: boolean): void;
23
25
  /* Set when a person stopped the last turn and the box is empty: Continue stands where the send button would. */
24
26
  onContinue: (() => void) | null;
27
+ /* The host's own, behind a chevron on the send button; empty draws the send button alone. */
28
+ actions: readonly ComposerSendAction[];
29
+ draft: ComposerSendDraft;
30
+ }
31
+
32
+ interface SendButtonProps {
33
+ send: SendProps;
34
+ /* The chevron only follows whether the box takes input: an action may want an empty draft, such as to reuse the last message. */
35
+ writable: boolean;
36
+ }
37
+
38
+ /* The send button, split with a chevron that opens the host's send actions when it has any. */
39
+ function SendButton({ send, writable }: SendButtonProps) {
40
+ const { t } = useTranslation('agent-chat');
41
+ const [box, setBox] = useState<HTMLDivElement | null>(null);
42
+ const split = send.actions.length > 0;
43
+ const button = (
44
+ <Tooltip label={send.label} kbd={send.kbd} name>
45
+ <button
46
+ className={clsx(
47
+ 'flex h-9 w-9 shrink-0 items-center justify-center bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100',
48
+ split ? 'focus-visible:-outline-offset-2' : 'rounded-full'
49
+ )}
50
+ disabled={send.disabled}
51
+ onClick={(event) => send.onSend(event.altKey)}
52
+ >
53
+ <Icon icon={ArrowUp} size={16} />
54
+ </button>
55
+ </Tooltip>
56
+ );
57
+ if (!split) {
58
+ return button;
59
+ }
60
+ const { draft } = send;
61
+ return (
62
+ // One round shape that clips both halves, the way a split button of the library draws its frame.
63
+ <div ref={setBox} className="flex h-9 shrink-0 overflow-clip rounded-full">
64
+ {button}
65
+ <Menu.Root>
66
+ <Tooltip label={t('composer.sendActions')} name>
67
+ <Menu.Trigger
68
+ render={
69
+ <button
70
+ type="button"
71
+ className='relative flex h-9 w-6 shrink-0 items-center justify-center bg-accent text-accent-text before:absolute before:inset-y-0 before:left-0 before:my-auto before:h-4 before:w-px before:bg-current/30 before:content-[""] hover:brightness-90 focus-visible:-outline-offset-2 disabled:opacity-50 disabled:hover:brightness-100'
72
+ />
73
+ }
74
+ disabled={!writable}
75
+ >
76
+ <Icon icon={ChevronDown} size={14} />
77
+ </Menu.Trigger>
78
+ </Tooltip>
79
+ <Menu.Popup anchor={box} side="top" sideOffset={8} align="end" className="min-w-48">
80
+ <HostMenuItems actions={send.actions.map((action) => ({ ...action, run: () => action.run(draft) }))} />
81
+ </Menu.Popup>
82
+ </Menu.Root>
83
+ </div>
84
+ );
25
85
  }
26
86
 
27
87
  interface ComposerControlsProps {
@@ -140,17 +200,7 @@ export function ComposerControls({
140
200
  </Button>
141
201
  </Tooltip>
142
202
  )}
143
- {send.onContinue === null && send.visible && (
144
- <Tooltip label={send.label} kbd={send.kbd} name>
145
- <button
146
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
147
- disabled={send.disabled}
148
- onClick={(event) => send.onSend(event.altKey)}
149
- >
150
- <Icon icon={ArrowUp} size={16} />
151
- </button>
152
- </Tooltip>
153
- )}
203
+ {send.onContinue === null && send.visible && <SendButton send={send} writable={writable} />}
154
204
  </div>
155
205
  </div>
156
206
  );
@@ -26,7 +26,6 @@ interface KeyHandlerInput {
26
26
  history: PromptHistory;
27
27
  noteKey(e: KeyboardEvent): void;
28
28
  toggleStash(): void;
29
- setText(next: string): void;
30
29
  submit(how: ComposerSend): void;
31
30
  }
32
31
 
@@ -50,19 +49,14 @@ function indentInFence(e: KeyboardEvent, view: EditorView): boolean {
50
49
  }
51
50
 
52
51
  /* The keys an open picker takes: the arrows walk it, and Tab (or Enter, outside the commands) picks. */
53
- function pickInMenu(e: KeyboardEvent, { menus, setText }: KeyHandlerInput): boolean {
52
+ export function pickInMenu(e: KeyboardEvent, menus: ComposerMenus): boolean {
54
53
  const { activeIndex } = menus;
55
54
  if (menus.commandMenuOpen) {
56
55
  if (menus.moveInMenu(e.key, menus.commands.length)) {
57
56
  return true;
58
57
  }
59
58
  if (e.key === 'Tab') {
60
- const chosen = menus.commands[activeIndex]!;
61
- if (chosen.skill) {
62
- menus.chooseSkill(chosen.name);
63
- } else {
64
- setText(`/${chosen.name} `);
65
- }
59
+ menus.completeCommand(menus.commands[activeIndex]!);
66
60
  return true;
67
61
  }
68
62
  }
@@ -167,7 +161,7 @@ export function composerKeyHandler(input: KeyHandlerInput): (e: KeyboardEvent, v
167
161
  input.toggleStash();
168
162
  return true;
169
163
  }
170
- if (pickInMenu(e, input)) {
164
+ if (pickInMenu(e, input.menus)) {
171
165
  return true;
172
166
  }
173
167
  if (e.key === 'Tab' && !e.altKey && !e.metaKey && !e.ctrlKey && indentInFence(e, view)) {
@@ -0,0 +1,17 @@
1
+ import type { ChatSendExtras } from '../../chat-client';
2
+ import { referenceTokens, type Referables } from '../../chat-references';
3
+ import { presentMentions, presentReferences, presentSkills } from '../../mentions';
4
+ import type { PromptDraft } from './use-prompt-menus';
5
+
6
+ /* What a prompt still holds of what was picked in it, as a send of the composer carries it. */
7
+ export function promptPicks(value: PromptDraft, referables: Referables): Pick<ChatSendExtras, 'mentions' | 'skills' | 'chats' | 'references'> {
8
+ const text = value.text.trim();
9
+ const tokens = referenceTokens([...value.chats, ...value.references], referables.items, value.referenceNames);
10
+ return {
11
+ mentions: presentMentions(text, value.mentions),
12
+ skills: presentSkills(text, value.skills),
13
+ chats: presentReferences(text, value.chats, tokens),
14
+ // A machine that resolves only chats would drop the rest.
15
+ references: referables.chatsOnly ? [] : presentReferences(text, value.references, tokens)
16
+ };
17
+ }