@adecore/agents-react 0.21.2 → 0.22.1
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/model-requirement.d.ts +6 -0
- package/dist/agents/model-requirement.js +26 -0
- package/dist/chat/chat-client.d.ts +2 -1
- package/dist/chat/chat-client.js +2 -0
- package/dist/chat/drafts.d.ts +2 -1
- package/dist/chat/drafts.js +15 -5
- package/dist/chat/logic/picker-entries.d.ts +10 -0
- package/dist/chat/logic/picker-entries.js +19 -0
- package/dist/chat/logic/timeline.d.ts +5 -0
- package/dist/chat/logic/timeline.js +10 -1
- package/dist/chat/prompt-hold.d.ts +8 -0
- package/dist/chat/prompt-hold.js +16 -0
- package/dist/chat/quote.d.ts +4 -0
- package/dist/chat/quote.js +19 -0
- package/dist/chat/stash.d.ts +8 -2
- package/dist/chat/stash.js +21 -5
- package/dist/chat/subagent-list.d.ts +7 -1
- package/dist/chat/subagent-list.js +13 -1
- package/dist/chat/timeline-scroll.d.ts +6 -2
- package/dist/chat/timeline-scroll.js +12 -7
- package/dist/chat/ui/ChatActivity.js +22 -8
- package/dist/chat/ui/Composer.js +103 -19
- package/dist/chat/ui/ComposerInput.d.ts +3 -1
- package/dist/chat/ui/ComposerInput.js +13 -4
- package/dist/chat/ui/Pickers.js +26 -18
- package/dist/chat/ui/PromptComposer.d.ts +8 -1
- package/dist/chat/ui/PromptComposer.js +18 -6
- package/dist/chat/ui/QuoteButton.js +2 -2
- package/dist/chat/ui/SubagentControls.d.ts +4 -2
- package/dist/chat/ui/SubagentControls.js +12 -8
- package/dist/chat/ui/SubagentInfo.d.ts +1 -1
- package/dist/chat/ui/SubagentInfo.js +9 -15
- package/dist/chat/ui/SubagentStopButton.js +2 -1
- package/dist/chat/ui/Timeline.js +24 -3
- package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
- package/dist/chat/ui/composer/menu-aria.js +18 -0
- package/dist/chat/ui/intelligent-ui/UiBlockFrame.js +3 -3
- package/dist/chat/ui/intelligent-ui/UiFallbackPart.js +1 -1
- package/dist/chat/ui/intelligent-ui/chart-data.d.ts +22 -2
- package/dist/chat/ui/intelligent-ui/chart-data.js +83 -19
- package/dist/chat/ui/intelligent-ui/renderers/chart.d.ts +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/chart.js +99 -35
- package/dist/chat/ui/intelligent-ui/renderers/choices.d.ts +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/choices.js +8 -2
- package/dist/chat/ui/intelligent-ui/renderers/content.js +5 -3
- package/dist/chat/ui/intelligent-ui/renderers/inputs.js +9 -4
- package/dist/chat/ui/intelligent-ui/renderers/links.js +1 -1
- package/dist/chat/ui/intelligent-ui/renderers/text.js +5 -2
- package/dist/chat/ui/intelligent-ui/table-data.js +4 -4
- package/dist/chat/ui/intelligent-ui/use-truncated.d.ts +1 -0
- package/dist/chat/ui/intelligent-ui/use-truncated.js +28 -0
- package/dist/chat/ui/quote-selection.d.ts +5 -2
- package/dist/chat/ui/quote-selection.js +3 -2
- package/dist/chat/ui/rows/MessageRows.js +24 -5
- package/dist/chat/ui/rows/Rows.js +1 -1
- package/dist/chat/ui/rows/WorkRows.d.ts +3 -1
- package/dist/chat/ui/rows/WorkRows.js +4 -4
- package/dist/host.d.ts +1 -0
- package/dist/host.js +1 -0
- package/dist/locales/en/agent-chat.json +39 -10
- package/dist/locales/nl/agent-chat.json +39 -10
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +19 -1
- package/package.json +29 -4
- package/src/agents/model-requirement.ts +36 -0
- package/src/chat/chat-client.ts +5 -0
- package/src/chat/drafts.ts +27 -7
- package/src/chat/logic/picker-entries.ts +25 -0
- package/src/chat/logic/timeline.ts +17 -2
- package/src/chat/prompt-hold.ts +27 -0
- package/src/chat/quote.ts +20 -0
- package/src/chat/stash.ts +31 -5
- package/src/chat/subagent-list.ts +19 -5
- package/src/chat/timeline-scroll.ts +24 -10
- package/src/chat/ui/ChatActivity.tsx +35 -9
- package/src/chat/ui/Composer.tsx +197 -52
- package/src/chat/ui/ComposerInput.tsx +18 -3
- package/src/chat/ui/Pickers.tsx +51 -30
- package/src/chat/ui/PromptComposer.tsx +37 -8
- package/src/chat/ui/QuoteButton.tsx +3 -2
- package/src/chat/ui/SubagentControls.tsx +22 -19
- package/src/chat/ui/SubagentInfo.tsx +24 -42
- package/src/chat/ui/SubagentStopButton.tsx +2 -1
- package/src/chat/ui/Timeline.tsx +26 -4
- package/src/chat/ui/composer/menu-aria.ts +29 -0
- package/src/chat/ui/intelligent-ui/UiBlockFrame.tsx +5 -5
- package/src/chat/ui/intelligent-ui/UiFallbackPart.tsx +6 -1
- package/src/chat/ui/intelligent-ui/chart-data.ts +106 -20
- package/src/chat/ui/intelligent-ui/renderers/chart.tsx +237 -72
- package/src/chat/ui/intelligent-ui/renderers/choices.tsx +9 -4
- package/src/chat/ui/intelligent-ui/renderers/content.tsx +31 -15
- package/src/chat/ui/intelligent-ui/renderers/inputs.tsx +16 -5
- package/src/chat/ui/intelligent-ui/renderers/links.tsx +3 -3
- package/src/chat/ui/intelligent-ui/renderers/text.tsx +18 -8
- package/src/chat/ui/intelligent-ui/table-data.ts +4 -4
- package/src/chat/ui/intelligent-ui/use-truncated.ts +33 -0
- package/src/chat/ui/quote-selection.ts +6 -4
- package/src/chat/ui/rows/MessageRows.tsx +44 -5
- package/src/chat/ui/rows/Rows.tsx +3 -1
- package/src/chat/ui/rows/WorkRows.tsx +15 -1
- package/src/host.ts +3 -0
- package/src/locales/en/agent-chat.json +39 -10
- package/src/locales/nl/agent-chat.json +39 -10
- package/src/state/chats.ts +31 -2
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
1
|
+
import { useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { Bookmark, ChevronDown, ChevronRight, Search, Trash2 } from 'lucide-react';
|
|
4
|
-
import type { AgentKind,
|
|
4
|
+
import type { AgentKind, ModelSelection, ProviderInfo, UsageProvider } from '@adecore/agent-contracts';
|
|
5
5
|
import { AgentIcon } from '../../agents/AgentIcon';
|
|
6
6
|
import { ProviderLogo } from '../../agents/ProviderLogo';
|
|
7
7
|
import { agentChipOf, modelName } from '../../agents/model-name';
|
|
8
|
+
import { modelRequirementOf } from '../../agents/model-requirement';
|
|
9
|
+
import { chatHost } from '../../host';
|
|
10
|
+
import { ChatScopeContext } from '../../scope';
|
|
11
|
+
import { useReportedModels } from '../../state/chats';
|
|
12
|
+
import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
|
|
8
13
|
import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
|
|
9
14
|
import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
|
|
10
15
|
|
|
@@ -14,13 +19,6 @@ const triggerClass =
|
|
|
14
19
|
const chipClass =
|
|
15
20
|
'flex h-6 min-w-0 shrink items-center gap-1.5 whitespace-nowrap rounded-md bg-surface-hover px-2 text-xs text-text hover:bg-surface-active data-[popup-open]:bg-surface-active';
|
|
16
21
|
|
|
17
|
-
/* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
|
|
18
|
-
type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
|
|
19
|
-
|
|
20
|
-
function matches(model: ModelInfo, query: string): boolean {
|
|
21
|
-
return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
22
|
interface ModelPickerProps {
|
|
25
23
|
/* Whose models may be picked: every installed chat CLI before the first message, the chat's own after it. */
|
|
26
24
|
providers: ProviderInfo[];
|
|
@@ -73,18 +71,13 @@ export function ModelPicker({
|
|
|
73
71
|
const grouped = providers.length > 1;
|
|
74
72
|
const trimmed = query.trim().toLowerCase();
|
|
75
73
|
|
|
76
|
-
const entries = useMemo(()
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (legacy.length === 0) {
|
|
84
|
-
return live;
|
|
85
|
-
}
|
|
86
|
-
return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
|
|
87
|
-
}, [providers, trimmed, legacyOpen]);
|
|
74
|
+
const entries = useMemo(() => pickerEntriesOf(providers, trimmed, legacyOpen), [providers, trimmed, legacyOpen]);
|
|
75
|
+
const scope = useContext(ChatScopeContext);
|
|
76
|
+
const machine = chatHost().useMachineLabel(scope?.id ?? '');
|
|
77
|
+
const reported = useReportedModels(provider);
|
|
78
|
+
const requirementOf = (entry: PickerEntry & { kind: 'model' }) => modelRequirementOf(entry.model, entry.provider, reported);
|
|
79
|
+
// A CLI that is too old blocks all its models; one way to its settings under the group says it once.
|
|
80
|
+
const blockedKinds = new Set(entries.flatMap((entry) => (entry.kind === 'model' && requirementOf(entry) !== null ? [entry.provider.kind] : [])));
|
|
88
81
|
|
|
89
82
|
/* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
|
|
90
83
|
const setOpen = (next: boolean): void => {
|
|
@@ -110,6 +103,9 @@ export function ModelPicker({
|
|
|
110
103
|
inputRef.current?.focus();
|
|
111
104
|
return;
|
|
112
105
|
}
|
|
106
|
+
if (requirementOf(entry) !== null) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
113
109
|
onChange(entry.provider.kind, entry.model.slug);
|
|
114
110
|
setOpen(false);
|
|
115
111
|
};
|
|
@@ -184,6 +180,9 @@ export function ModelPicker({
|
|
|
184
180
|
}
|
|
185
181
|
const previous = entries[i - 1];
|
|
186
182
|
const first = grouped && (previous === undefined || previous.kind !== 'model' || previous.provider.kind !== entry.provider.kind);
|
|
183
|
+
const requirement = requirementOf(entry);
|
|
184
|
+
const next = entries[i + 1];
|
|
185
|
+
const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
|
|
187
186
|
const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
|
|
188
187
|
return (
|
|
189
188
|
<div key={`${entry.provider.kind}/${entry.model.slug}`}>
|
|
@@ -196,21 +195,43 @@ export function ModelPicker({
|
|
|
196
195
|
<button
|
|
197
196
|
role="option"
|
|
198
197
|
aria-selected={chosen}
|
|
198
|
+
aria-disabled={requirement !== null || undefined}
|
|
199
199
|
data-active={i === index}
|
|
200
|
-
className=
|
|
200
|
+
className={`cursor-row flex w-full flex-col items-stretch gap-0.5 rounded-md px-2.5 py-1.5 text-left text-sm ${requirement === null ? 'text-text-muted' : 'text-text-faint'}`}
|
|
201
201
|
onMouseEnter={() => setIndex(i)}
|
|
202
202
|
onClick={() => choose(entry)}
|
|
203
203
|
>
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
<span className="flex w-full items-center gap-2">
|
|
205
|
+
<Menu.Check kind="radio" checked={chosen} />
|
|
206
|
+
<span className="min-w-0 truncate">{entry.model.name}</span>
|
|
207
|
+
{entry.model.badge && (
|
|
208
|
+
<span className="rounded bg-accent-soft px-1 text-[11px] leading-5 font-medium uppercase text-accent">
|
|
209
|
+
{entry.model.badge}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
<span className="grow" />
|
|
213
|
+
{entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
|
|
214
|
+
</span>
|
|
215
|
+
{requirement !== null && (
|
|
216
|
+
<span className="pl-6 text-xs text-text-faint">
|
|
217
|
+
{machine === null
|
|
218
|
+
? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
|
|
219
|
+
: t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine })}
|
|
209
220
|
</span>
|
|
210
221
|
)}
|
|
211
|
-
<span className="grow" />
|
|
212
|
-
{entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
|
|
213
222
|
</button>
|
|
223
|
+
{lastOfGroup && blockedKinds.has(entry.provider.kind) && (
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
className="cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted"
|
|
227
|
+
onClick={() => {
|
|
228
|
+
setOpen(false);
|
|
229
|
+
chatHost().openSettings('providers');
|
|
230
|
+
}}
|
|
231
|
+
>
|
|
232
|
+
{t('pickers.model.providerSettings')}
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
214
235
|
</div>
|
|
215
236
|
);
|
|
216
237
|
})}
|
|
@@ -247,7 +268,7 @@ export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): v
|
|
|
247
268
|
className="flex min-w-0 grow flex-col gap-0.5 rounded-md px-2 py-1.5 text-left text-xs text-text-muted hover:bg-surface-hover hover:text-text"
|
|
248
269
|
onClick={() => onRestore(prompt)}
|
|
249
270
|
>
|
|
250
|
-
<span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || t('pickers.stash.noText')}</span>
|
|
271
|
+
<span className="line-clamp-2 whitespace-pre-wrap">{prompt.text || prompt.quote?.text || t('pickers.stash.noText')}</span>
|
|
251
272
|
{prompt.attachments.length > 0 && (
|
|
252
273
|
<span className="text-text-faint">{t('pickers.stash.files', { count: prompt.attachments.length })}</span>
|
|
253
274
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { ArrowDown, Hand, MessageCircleQuestionMark } from 'lucide-react';
|
|
4
|
+
import type { HoldRelease } from '../prompt-hold';
|
|
4
5
|
import { focusPromptHeading, focusPromptStart } from '../../prompts/logic/focus';
|
|
5
6
|
import { orderPrompts, type PendingPrompt } from '../../prompts/logic/prompts';
|
|
6
7
|
import { answerPrompt, isBlockingSubject, promptCreatedAt, promptIdOf, type PromptSubject } from '../../prompts/logic/subjects';
|
|
@@ -10,7 +11,14 @@ import { PromptView } from '../../prompts/ui/PromptView';
|
|
|
10
11
|
import { chatHost } from '../../host';
|
|
11
12
|
import { useChatScope } from '../../scope';
|
|
12
13
|
import { useChatActions } from '../actions';
|
|
13
|
-
import { Icon } from '@adecore/ui';
|
|
14
|
+
import { Button, Icon } from '@adecore/ui';
|
|
15
|
+
|
|
16
|
+
/* Keeps a prompt card behind the draft while the person types; `released` says what lifted the hold last. */
|
|
17
|
+
export interface PromptHold {
|
|
18
|
+
held: boolean;
|
|
19
|
+
releasedBy: HoldRelease | null;
|
|
20
|
+
release(reason: HoldRelease): void;
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
/* The one being read stays in front; otherwise blocking before optional, oldest first, as everywhere else. */
|
|
16
24
|
function pickSubject(waiting: readonly PromptSubject[], activeId: string | null): PromptSubject | null {
|
|
@@ -26,6 +34,7 @@ export function PromptComposer({
|
|
|
26
34
|
hasDraft,
|
|
27
35
|
denyReason,
|
|
28
36
|
onAllAnswered,
|
|
37
|
+
hold,
|
|
29
38
|
children
|
|
30
39
|
}: {
|
|
31
40
|
chatId: string;
|
|
@@ -38,6 +47,8 @@ export function PromptComposer({
|
|
|
38
47
|
denyReason: boolean;
|
|
39
48
|
/* Takes the keyboard back to the composer once the last prompt was answered from its card. */
|
|
40
49
|
onAllAnswered?: () => void;
|
|
50
|
+
/* Without it a card takes the composer's place at once. */
|
|
51
|
+
hold?: PromptHold;
|
|
41
52
|
children: ReactNode;
|
|
42
53
|
}) {
|
|
43
54
|
const { t } = useTranslation('agent-chat');
|
|
@@ -56,10 +67,23 @@ export function PromptComposer({
|
|
|
56
67
|
const session = usePromptSession({ prompts: subjects, idOf: promptIdOf, pick: pickSubject, disabled });
|
|
57
68
|
const ref = useRef<HTMLDivElement>(null);
|
|
58
69
|
const { active, activeId: activeKey } = session;
|
|
59
|
-
const
|
|
70
|
+
const asksApproval = active?.kind === 'chat' ? active.item.kind === 'approval' : active?.prompt.asks === 'approval';
|
|
71
|
+
const held = !!active && (hold?.held ?? false);
|
|
72
|
+
const expanded = !!active && !held;
|
|
73
|
+
// The card that waited behind the draft, so a blur that released it is not mistaken for one released long before.
|
|
74
|
+
const heldKey = useRef<string | null>(null);
|
|
60
75
|
const refocus = useFocusAfterAnswer(activeKey, ref, () => onAllAnswered?.());
|
|
61
76
|
|
|
62
77
|
useEffect(() => {
|
|
78
|
+
if (held) {
|
|
79
|
+
heldKey.current = activeKey;
|
|
80
|
+
}
|
|
81
|
+
}, [held, activeKey]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (hold?.releasedBy === 'blur' && heldKey.current === activeKey) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
63
87
|
if (focused && expanded && !elsewhere && !ref.current?.querySelector('.prompt-card')?.contains(document.activeElement)) {
|
|
64
88
|
// Keys meant for the draft may still be coming, and on a button the next Space would answer the card.
|
|
65
89
|
if (hasDraft) {
|
|
@@ -68,7 +92,7 @@ export function PromptComposer({
|
|
|
68
92
|
focusPromptStart(ref.current);
|
|
69
93
|
}
|
|
70
94
|
}
|
|
71
|
-
}, [activeKey, expanded, focused, elsewhere, hasDraft]);
|
|
95
|
+
}, [activeKey, expanded, focused, elsewhere, hasDraft, hold?.releasedBy]);
|
|
72
96
|
|
|
73
97
|
return (
|
|
74
98
|
<div ref={ref} onPointerDownCapture={session.onPointerDownCapture} onClickCapture={session.onClickCapture}>
|
|
@@ -78,11 +102,7 @@ export function PromptComposer({
|
|
|
78
102
|
className="flex w-full items-center gap-2 px-3.5 py-3 text-left text-sm text-text-muted hover:text-text"
|
|
79
103
|
onClick={onShowElsewhere}
|
|
80
104
|
>
|
|
81
|
-
<Icon
|
|
82
|
-
icon={(active.kind === 'chat' ? active.item.kind === 'approval' : active.prompt.asks === 'approval') ? Hand : MessageCircleQuestionMark}
|
|
83
|
-
size={16}
|
|
84
|
-
className="shrink-0 text-status-needs-you"
|
|
85
|
-
/>
|
|
105
|
+
<Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
|
|
86
106
|
<span className="grow">{t('composer.waitingBelow')}</span>
|
|
87
107
|
<Icon icon={ArrowDown} size={16} className="shrink-0" />
|
|
88
108
|
</button>
|
|
@@ -113,6 +133,15 @@ export function PromptComposer({
|
|
|
113
133
|
/>
|
|
114
134
|
</div>
|
|
115
135
|
)}
|
|
136
|
+
{held && active && (
|
|
137
|
+
<div role="status" className="flex items-center gap-2 border-b border-border px-3.5 py-2.5">
|
|
138
|
+
<Icon icon={asksApproval ? Hand : MessageCircleQuestionMark} size={16} className="shrink-0 text-status-needs-you" />
|
|
139
|
+
<span className="grow text-sm text-text-muted">{t(asksApproval ? 'composer.held.approval' : 'composer.held.question')}</span>
|
|
140
|
+
<Button size="sm" onMouseDown={(e) => e.preventDefault()} onClick={() => hold?.release('show')}>
|
|
141
|
+
{t('composer.held.show')}
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
116
145
|
<div hidden={expanded}>{children}</div>
|
|
117
146
|
</div>
|
|
118
147
|
);
|
|
@@ -10,6 +10,7 @@ const EDGE_PX = 40;
|
|
|
10
10
|
|
|
11
11
|
interface Offer {
|
|
12
12
|
quote: string;
|
|
13
|
+
itemId: string | null;
|
|
13
14
|
x: number;
|
|
14
15
|
y: number;
|
|
15
16
|
}
|
|
@@ -52,7 +53,7 @@ export function QuoteButton({
|
|
|
52
53
|
const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
|
|
53
54
|
const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
|
|
54
55
|
const y = (line.bottom - bounds.top) / zoom + GAP_PX;
|
|
55
|
-
setOffer({ quote, x: Math.round(x), y: Math.round(y) });
|
|
56
|
+
setOffer({ quote: quote.text, itemId: quote.itemId, x: Math.round(x), y: Math.round(y) });
|
|
56
57
|
};
|
|
57
58
|
const press = (): void => {
|
|
58
59
|
pressed = true;
|
|
@@ -91,7 +92,7 @@ export function QuoteButton({
|
|
|
91
92
|
onMouseDown={(e) => e.preventDefault()}
|
|
92
93
|
onClick={() => {
|
|
93
94
|
document.getSelection()?.removeAllRanges();
|
|
94
|
-
taker.current?.(offer.quote);
|
|
95
|
+
taker.current?.(offer.quote, offer.itemId);
|
|
95
96
|
setOffer(null);
|
|
96
97
|
}}
|
|
97
98
|
>
|
|
@@ -21,31 +21,18 @@ export function SubagentTitleCrumb({ chatId, className, children }: { chatId: st
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
/*
|
|
25
|
-
|
|
26
|
-
* to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
|
|
27
|
-
* Nothing while the main agent itself is on screen.
|
|
28
|
-
*/
|
|
29
|
-
export function SubagentBreadcrumb({ chatId, title, className }: { chatId: string; title?: string; className?: string }) {
|
|
24
|
+
/* The way down to the sub-agent on screen, each step but the last a way back up. Nothing while the main agent is on screen. */
|
|
25
|
+
export function SubagentBreadcrumb({ chatId, className }: { chatId: string; className?: string }) {
|
|
30
26
|
const { t } = useTranslation('agent-chat');
|
|
31
27
|
const { trail, show } = useSubagentTrail(chatId);
|
|
32
|
-
const subagents = useOpenableSubagents(chatId);
|
|
33
28
|
if (trail.length === 0) {
|
|
34
29
|
return null;
|
|
35
30
|
}
|
|
36
|
-
const steps = breadcrumbOf(trail);
|
|
37
|
-
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
38
|
-
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
|
|
39
31
|
return (
|
|
40
|
-
<nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1
|
|
41
|
-
{
|
|
42
|
-
<button type="button" className={clsx(CRUMB_LINK, 'min-w-0 shrink')} onClick={() => show(MAIN_AGENT)}>
|
|
43
|
-
{title}
|
|
44
|
-
</button>
|
|
45
|
-
)}
|
|
46
|
-
{steps.map((step) => (
|
|
32
|
+
<nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1', className)}>
|
|
33
|
+
{breadcrumbOf(trail).map((step) => (
|
|
47
34
|
<Fragment key={step.depth}>
|
|
48
|
-
<Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />
|
|
35
|
+
{step.depth > 1 && <Icon icon={ChevronRight} size={12} className="shrink-0 text-text-faint" />}
|
|
49
36
|
{step.current ? (
|
|
50
37
|
<span aria-current="page" className="min-w-0 truncate text-text">
|
|
51
38
|
{step.label}
|
|
@@ -57,8 +44,24 @@ export function SubagentBreadcrumb({ chatId, title, className }: { chatId: strin
|
|
|
57
44
|
)}
|
|
58
45
|
</Fragment>
|
|
59
46
|
))}
|
|
47
|
+
</nav>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Stops the sub-agent on screen where that is on offer, and closes it straight back to the main agent. */
|
|
52
|
+
export function SubagentActions({ chatId }: { chatId: string }) {
|
|
53
|
+
const { t } = useTranslation('agent-chat');
|
|
54
|
+
const { trail, show } = useSubagentTrail(chatId);
|
|
55
|
+
const subagents = useOpenableSubagents(chatId);
|
|
56
|
+
if (trail.length === 0) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
60
|
+
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
60
63
|
{shown !== undefined && <SubagentStopButton chatId={chatId} item={shown} size="sm" />}
|
|
61
64
|
<IconButton icon={X} size="sm" label={t('subagents.backToMain')} onClick={() => show(MAIN_AGENT)} />
|
|
62
|
-
</
|
|
65
|
+
</div>
|
|
63
66
|
);
|
|
64
67
|
}
|
|
@@ -1,20 +1,18 @@
|
|
|
1
|
-
import { Fragment
|
|
2
|
-
import clsx from 'clsx';
|
|
1
|
+
import { Fragment } from 'react';
|
|
3
2
|
import { useTranslation } from 'react-i18next';
|
|
4
|
-
import { ChevronDown } from 'lucide-react';
|
|
5
3
|
import { entryTimeOf, statusWordOf, subagentFacts, taskIdOf } from '../subagent-list';
|
|
6
4
|
import { useSubagentItem } from '../subagent-view';
|
|
7
5
|
import { StatusIcon } from './ChatActivity';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { SubagentActions, SubagentBreadcrumb } from './SubagentControls';
|
|
7
|
+
import { useNow } from '@adecore/ui';
|
|
10
8
|
import { useModelName } from '../../agents/model-name';
|
|
11
9
|
import { useChatRow } from '../../state/chats';
|
|
12
10
|
import { chatHost } from '../../host';
|
|
13
11
|
import { useChatScope } from '../../scope';
|
|
14
12
|
|
|
15
13
|
/*
|
|
16
|
-
* What a sub-agent is, over its conversation: its state and time, the model it runs on
|
|
17
|
-
* spent, and the
|
|
14
|
+
* What a sub-agent is, over its conversation: the way down to it, its state and time, the model it runs on
|
|
15
|
+
* and what it spent, and the way out. Only the way down and out for a conversation whose row the thread lost.
|
|
18
16
|
*/
|
|
19
17
|
export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
|
|
20
18
|
const { t } = useTranslation('agent-chat');
|
|
@@ -24,43 +22,27 @@ export function SubagentInfo({ chatId, toolUseId }: { chatId: string; toolUseId:
|
|
|
24
22
|
const task = chatHost().tasks.useTask(id, item === null ? null : taskIdOf(item));
|
|
25
23
|
const modelName = useModelName(provider, item?.model ?? '');
|
|
26
24
|
const now = useNow(1000, item?.status === 'running');
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
const word = statusWordOf(item, task);
|
|
32
|
-
const facts = subagentFacts(item, item.model === undefined ? null : modelName);
|
|
25
|
+
const word = item === null ? null : statusWordOf(item, task);
|
|
26
|
+
const facts = item === null ? [] : subagentFacts(item, item.model === undefined ? null : modelName);
|
|
33
27
|
return (
|
|
34
|
-
<section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-
|
|
35
|
-
<div className="
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
<span className="min-w-0 truncate">{fact}</span>
|
|
44
|
-
</Fragment>
|
|
45
|
-
))}
|
|
46
|
-
<span className="grow" />
|
|
47
|
-
{item.prompt && (
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
className="flex shrink-0 items-center gap-1.5 text-text-muted hover:text-text"
|
|
51
|
-
aria-expanded={open}
|
|
52
|
-
onClick={() => setOpen((current) => !current)}
|
|
53
|
-
>
|
|
54
|
-
<Icon icon={ChevronDown} size={12} className={clsx('transition-transform', open && 'rotate-180')} />
|
|
55
|
-
{open ? t('subagents.info.hideTask') : t('subagents.info.showTask')}
|
|
56
|
-
</button>
|
|
57
|
-
)}
|
|
58
|
-
</div>
|
|
59
|
-
{open && item.prompt && (
|
|
60
|
-
<div className="max-h-60 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 text-xs select-text">
|
|
61
|
-
<Markdown text={item.prompt} />
|
|
62
|
-
</div>
|
|
28
|
+
<section aria-label={t('subagents.info.label')} className="shrink-0 border-b border-border px-4 py-1.5">
|
|
29
|
+
<div className="flex min-w-0 items-center gap-2 text-xs text-text-faint">
|
|
30
|
+
<SubagentBreadcrumb chatId={chatId} className="max-w-1/3 shrink-0 font-medium" />
|
|
31
|
+
{item !== null && word !== null && (
|
|
32
|
+
<>
|
|
33
|
+
<StatusIcon word={word} />
|
|
34
|
+
<span className="sr-only">{t(`common.status.${word}`)}</span>
|
|
35
|
+
<span className="shrink-0 text-text-muted tabular-nums">{entryTimeOf(item, task, now)}</span>
|
|
36
|
+
</>
|
|
63
37
|
)}
|
|
38
|
+
{facts.map((fact) => (
|
|
39
|
+
<Fragment key={fact}>
|
|
40
|
+
<span aria-hidden="true">·</span>
|
|
41
|
+
<span className="min-w-0 truncate">{fact}</span>
|
|
42
|
+
</Fragment>
|
|
43
|
+
))}
|
|
44
|
+
<span className="grow" />
|
|
45
|
+
<SubagentActions chatId={chatId} />
|
|
64
46
|
</div>
|
|
65
47
|
</section>
|
|
66
48
|
);
|
|
@@ -30,7 +30,8 @@ export function SubagentStopButton({
|
|
|
30
30
|
}
|
|
31
31
|
const send = (): void => {
|
|
32
32
|
void actions.stopSubagent(chatId, item.toolUseId).catch((e: unknown) => {
|
|
33
|
-
const
|
|
33
|
+
const stale = e instanceof Error && 'code' in e && e.code === 'subagent-stale';
|
|
34
|
+
const description = stale ? t('subagents.stale') : e instanceof Error ? e.message : t('subagents.noAnswer');
|
|
34
35
|
chatHost().notify({ kind: 'error', title: t('subagents.stopFailed'), description });
|
|
35
36
|
});
|
|
36
37
|
};
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
registerTurnJumper,
|
|
17
17
|
setTimelineAtEnd,
|
|
18
18
|
wantsEarlier,
|
|
19
|
+
type ItemJumpOptions,
|
|
19
20
|
type ReadingAnchor,
|
|
20
21
|
type TurnTarget
|
|
21
22
|
} from '../timeline-scroll';
|
|
@@ -66,6 +67,8 @@ import { useModelName } from '../../agents/model-name';
|
|
|
66
67
|
import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@adecore/ui';
|
|
67
68
|
|
|
68
69
|
const ESTIMATED_ROW_PX = 56;
|
|
70
|
+
// How long a jump waits for a thread that is still loading before it calls the item missing.
|
|
71
|
+
const JUMP_THREAD_WAIT_MS = 8000;
|
|
69
72
|
|
|
70
73
|
/* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
|
|
71
74
|
function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
|
|
@@ -222,7 +225,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
222
225
|
const marks = useMemo(() => bookmarkRows(rows, bookmarks ?? [], items ?? {}), [rows, bookmarks, items]);
|
|
223
226
|
const ticks = useMemo(() => ticksOf(rows, marks), [rows, marks]);
|
|
224
227
|
// A message a jump is on its way to; it may first have to open the turn its reply folded into.
|
|
225
|
-
const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string } | null>(null);
|
|
228
|
+
const [seeking, setSeeking] = useState<{ itemId: string; block?: UiReplyTarget; key: string; options?: ItemJumpOptions } | null>(null);
|
|
226
229
|
const [landing, setLanding] = useState<{ target: UiReplyTarget; key: string } | null>(null);
|
|
227
230
|
// A turn a jump is on its way to, which waits for the page its messages are on to come in.
|
|
228
231
|
const [revealing, setRevealing] = useState<{ turnId: string; target: TurnTarget } | null>(null);
|
|
@@ -451,9 +454,16 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
451
454
|
if (index !== -1) {
|
|
452
455
|
setSeeking(null);
|
|
453
456
|
showRowAtTop(virtualizer, followRef, index);
|
|
457
|
+
if (seeking.options?.flash) {
|
|
458
|
+
useTimelineFlash.getState().flash(seeking.key, seeking.itemId, false);
|
|
459
|
+
}
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
// The thread has not loaded yet, so nothing says the item is missing; the effect below bounds the wait.
|
|
463
|
+
if (items === undefined) {
|
|
454
464
|
return;
|
|
455
465
|
}
|
|
456
|
-
const item = items
|
|
466
|
+
const item = items[seeking.itemId];
|
|
457
467
|
if (item === undefined && cursor !== null) {
|
|
458
468
|
loadEarlier();
|
|
459
469
|
return;
|
|
@@ -464,12 +474,24 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
464
474
|
return;
|
|
465
475
|
}
|
|
466
476
|
setSeeking(null);
|
|
477
|
+
seeking.options?.onMissing?.();
|
|
467
478
|
}, [seeking, rows, items, turns, virtualizer, cursor, loadEarlier, keyOf, chatId]);
|
|
479
|
+
const waitingForThread = seeking !== null && items === undefined;
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (!waitingForThread) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const timer = window.setTimeout(() => {
|
|
485
|
+
setSeeking(null);
|
|
486
|
+
seeking?.options?.onMissing?.();
|
|
487
|
+
}, JUMP_THREAD_WAIT_MS);
|
|
488
|
+
return () => window.clearTimeout(timer);
|
|
489
|
+
}, [waitingForThread, seeking]);
|
|
468
490
|
useEffect(
|
|
469
491
|
() =>
|
|
470
|
-
registerItemJumper(keyOf(chatId), (itemId) => {
|
|
492
|
+
registerItemJumper(keyOf(chatId), (itemId, options) => {
|
|
471
493
|
setLanding(null);
|
|
472
|
-
setSeeking({ itemId, key: keyOf(chatId) });
|
|
494
|
+
setSeeking({ itemId, key: keyOf(chatId), options });
|
|
473
495
|
}),
|
|
474
496
|
[keyOf, chatId]
|
|
475
497
|
);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ComposerMenuAriaInput {
|
|
2
|
+
label: string;
|
|
3
|
+
listId: string;
|
|
4
|
+
open: boolean;
|
|
5
|
+
/* Counts across every group of the menu, the way the keyboard moves through it. */
|
|
6
|
+
activeIndex: number;
|
|
7
|
+
count: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* The index that still points at an option once the list has shrunk under a stale one. */
|
|
11
|
+
export function clampMenuIndex(index: number, count: number): number {
|
|
12
|
+
return Math.min(index, Math.max(count - 1, 0));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function optionIdOf(listId: string, index: number): string {
|
|
16
|
+
return `${listId}-${index}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* The attributes the editor's content element takes while a suggestion menu stands beside it. The
|
|
21
|
+
* element keeps its `textbox` role; ARIA 1.2 lets it name the option the arrow keys are on.
|
|
22
|
+
*/
|
|
23
|
+
export function composerMenuAria({ label, listId, open, activeIndex, count }: ComposerMenuAriaInput): Record<string, string> {
|
|
24
|
+
return {
|
|
25
|
+
'aria-label': label,
|
|
26
|
+
'aria-autocomplete': 'list',
|
|
27
|
+
...(open && count > 0 ? { 'aria-controls': listId, 'aria-activedescendant': optionIdOf(listId, activeIndex) } : {})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -2,14 +2,14 @@ import { useId, useLayoutEffect, useRef, useState, type ReactNode, type RefObjec
|
|
|
2
2
|
import clsx from 'clsx';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import type { UiViewNode } from '@adecore/intelligent-ui';
|
|
5
|
-
import { Lock, Radio, TriangleAlert
|
|
5
|
+
import { Info, Lock, Radio, TriangleAlert } from 'lucide-react';
|
|
6
6
|
import { Icon, Skeleton, Spinner, Tooltip, useTickingText } from '@adecore/ui';
|
|
7
7
|
import { formatAgo, formatMoment } from '@adecore/ui/format';
|
|
8
8
|
import { uiBlockHead, uiReasonText } from './node-text';
|
|
9
9
|
import { UiHeadContext, type UiLiveStatus } from './render-context';
|
|
10
10
|
import { useLater } from './use-later';
|
|
11
11
|
|
|
12
|
-
/* Something the compiler
|
|
12
|
+
/* Something the compiler noted about a block: a prop it dropped, or a part it could only draw as text. */
|
|
13
13
|
export interface UiBlockFix {
|
|
14
14
|
code: string;
|
|
15
15
|
message: string;
|
|
@@ -82,7 +82,7 @@ function LivePart({ live }: { live: UiLiveStatus }) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/*
|
|
85
|
-
* The line under a block, only when it has something to say: live, answered,
|
|
85
|
+
* The line under a block, only when it has something to say: live, answered, noted or drawn as
|
|
86
86
|
* text, in that order. A hairline of the soft border runs the full width of the card over it.
|
|
87
87
|
*/
|
|
88
88
|
function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAsText = null }: UiBlockFooterProps) {
|
|
@@ -111,8 +111,8 @@ function UiBlockFooter({ live = null, answered = null, fixes = NO_FIXES, shownAs
|
|
|
111
111
|
className="flex items-center gap-1.5 rounded-sm hover:text-text-muted"
|
|
112
112
|
onClick={() => setFixesOpen(!fixesOpen)}
|
|
113
113
|
>
|
|
114
|
-
<Icon icon={
|
|
115
|
-
{t('blocks.
|
|
114
|
+
<Icon icon={Info} size={12} />
|
|
115
|
+
{t('blocks.notes', { count: fixes.length })}
|
|
116
116
|
</button>
|
|
117
117
|
)}
|
|
118
118
|
{shownAsText !== null && (
|
|
@@ -15,7 +15,12 @@ export function UiFallbackPart({ fallback, problem }: { fallback: string; proble
|
|
|
15
15
|
<span className="text-xs text-text-faint">
|
|
16
16
|
{problem.kind === 'unknown' ? t('blocks.unknownComponent', { component: problem.component }) : t('blocks.renderFailed')}
|
|
17
17
|
</span>
|
|
18
|
-
{fallback !== '' &&
|
|
18
|
+
{fallback !== '' && (
|
|
19
|
+
// A fallback is often data without a space in it, such as the JSON of a chart.
|
|
20
|
+
<div className="min-w-0 wrap-anywhere">
|
|
21
|
+
<Markdown text={fallback} fileLinks={false} />
|
|
22
|
+
</div>
|
|
23
|
+
)}
|
|
19
24
|
</div>
|
|
20
25
|
);
|
|
21
26
|
}
|