@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.
- package/dist/agents/AgentModelLabel.d.ts +10 -0
- package/dist/agents/AgentModelLabel.js +97 -0
- package/dist/chat/logic/visual-height.d.ts +1 -0
- package/dist/chat/logic/visual-height.js +1 -1
- package/dist/chat/ui/Composer.js +385 -360
- package/dist/chat/ui/HostMenuItems.d.ts +4 -0
- package/dist/chat/ui/HostMenuItems.js +15 -0
- package/dist/chat/ui/Pickers.d.ts +2 -1
- package/dist/chat/ui/Pickers.js +126 -123
- package/dist/chat/ui/PromptEditor.d.ts +19 -0
- package/dist/chat/ui/PromptEditor.js +115 -0
- package/dist/chat/ui/Timeline.d.ts +2 -1
- package/dist/chat/ui/Timeline.js +13 -1
- package/dist/chat/ui/VisualFrame.d.ts +8 -0
- package/dist/chat/ui/VisualFrame.js +217 -115
- package/dist/chat/ui/composer/ComposerControls.d.ts +3 -0
- package/dist/chat/ui/composer/ComposerControls.js +235 -38
- package/dist/chat/ui/composer/key-handler.d.ts +1 -1
- package/dist/chat/ui/composer/key-handler.js +3 -11
- package/dist/chat/ui/composer/prompt-picks.d.ts +4 -0
- package/dist/chat/ui/composer/prompt-picks.js +14 -0
- package/dist/chat/ui/composer/use-composer-menus.d.ts +4 -49
- package/dist/chat/ui/composer/use-composer-menus.js +65 -519
- package/dist/chat/ui/composer/use-prompt-menus.d.ts +73 -0
- package/dist/chat/ui/composer/use-prompt-menus.js +536 -0
- package/dist/chat/ui/index.d.ts +4 -0
- package/dist/chat/ui/index.js +3 -0
- package/dist/chat/ui/rows/GeneratedImageRow.js +8 -4
- package/dist/chat/ui/rows/VisualRow.js +184 -142
- package/dist/chat/ui/rows/row-rhythm.js +6 -4
- package/dist/host-hooks.d.ts +2 -1
- package/dist/host-hooks.js +6 -0
- package/dist/host.d.ts +27 -1
- package/dist/index.d.ts +1 -1
- package/dist/locales/en/agent-chat.d.ts +1 -0
- package/dist/locales/en/agent-chat.js +1 -0
- package/dist/locales/nl/agent-chat.d.ts +1 -0
- package/dist/locales/nl/agent-chat.js +1 -0
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.js +1 -0
- package/dist/theme.css +3 -0
- package/package.json +8 -8
- package/src/agents/AgentModelLabel.tsx +40 -0
- package/src/chat/logic/visual-height.ts +1 -1
- package/src/chat/ui/Composer.tsx +12 -3
- package/src/chat/ui/HostMenuItems.tsx +11 -0
- package/src/chat/ui/Pickers.tsx +4 -1
- package/src/chat/ui/PromptEditor.tsx +130 -0
- package/src/chat/ui/Timeline.tsx +22 -1
- package/src/chat/ui/VisualFrame.tsx +68 -21
- package/src/chat/ui/composer/ComposerControls.tsx +65 -15
- package/src/chat/ui/composer/key-handler.ts +3 -9
- package/src/chat/ui/composer/prompt-picks.ts +17 -0
- package/src/chat/ui/composer/use-composer-menus.ts +18 -275
- package/src/chat/ui/composer/use-prompt-menus.ts +341 -0
- package/src/chat/ui/index.ts +4 -0
- package/src/chat/ui/rows/GeneratedImageRow.tsx +9 -4
- package/src/chat/ui/rows/VisualRow.tsx +5 -1
- package/src/chat/ui/rows/row-rhythm.ts +6 -4
- package/src/host-hooks.ts +9 -1
- package/src/host.ts +45 -1
- package/src/index.ts +3 -0
- package/src/locales/en/agent-chat.ts +1 -0
- package/src/locales/nl/agent-chat.ts +1 -0
- package/src/providers/index.ts +1 -0
- 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.
|
package/src/chat/ui/Composer.tsx
CHANGED
|
@@ -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
|
+
}
|
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -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=
|
|
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
|
+
}
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
*
|
|
32
|
-
*
|
|
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
|
|
36
|
-
|
|
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 ??
|
|
110
|
+
const height = fill || width === 0 ? undefined : (reported ?? initialHeight(width));
|
|
63
111
|
|
|
64
112
|
useLayoutEffect(() => {
|
|
65
113
|
widthRef.current = width;
|
|
66
|
-
|
|
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
|
|
76
|
-
page:
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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={
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|