@adecore/agents-react 0.21.2 → 0.22.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/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/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 +30 -7
- package/dist/locales/nl/agent-chat.json +30 -7
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +19 -1
- package/package.json +24 -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/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 +30 -7
- package/src/locales/nl/agent-chat.json +30 -7
- package/src/state/chats.ts +31 -2
package/src/chat/ui/Composer.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
1
|
+
import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import { indentLess, indentMore, insertNewline } from '@codemirror/commands';
|
|
4
4
|
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
|
|
@@ -52,13 +52,15 @@ import { PROMPT_MAX_CHARS, pasteBecomesAttachment, pastedTextName, promptGuard,
|
|
|
52
52
|
import { withQuote } from '../quote';
|
|
53
53
|
import { rememberChatAccount, rememberChatPreferences, rememberChatSelection } from '../preferences';
|
|
54
54
|
import { STASH_SHORTCUT, stashDraft, type StashedPrompt, useStash } from '../stash';
|
|
55
|
-
import { pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
|
|
55
|
+
import { jumpToTimelineItem, pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
|
|
56
56
|
import { ChatActivity } from './ChatActivity';
|
|
57
57
|
import { ChatReferenceChip } from './ChatReferenceChip';
|
|
58
58
|
import { chipDecorations } from './composer/chips';
|
|
59
|
+
import { clampMenuIndex, composerMenuAria, optionIdOf } from './composer/menu-aria';
|
|
59
60
|
import { enterAction, inCode, inFenceBody, inOpenFence, listItemAt, recallDirection, tabSpaces } from './composer/keys';
|
|
60
61
|
import { ComposerInput, type ComposerInputHandle } from './ComposerInput';
|
|
61
|
-
import {
|
|
62
|
+
import { holdLiftsIn, type HoldRelease } from '../prompt-hold';
|
|
63
|
+
import { PromptComposer, type PromptHold } from './PromptComposer';
|
|
62
64
|
import { QuoteTakerContext } from './quote-selection';
|
|
63
65
|
import { LimitDock } from './LimitState';
|
|
64
66
|
import { ResumeCompactionDock } from './ResumeCompactionDock';
|
|
@@ -177,6 +179,59 @@ function splitPath(path: string): { name: string; dir: string } {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
// Keep the editor mounted while a pending request takes over, so its selection and draft survive.
|
|
182
|
+
/*
|
|
183
|
+
* Keeps a waiting prompt card behind the draft while the person types. Typing, pasting and dictating
|
|
184
|
+
* all change the draft, so `noteInput` is called from its change handler; the hold lifts after a pause.
|
|
185
|
+
*/
|
|
186
|
+
function usePromptHold(): PromptHold & { noteInput(): void; setComposing(composing: boolean): void } {
|
|
187
|
+
const typedAt = useRef<number | null>(null);
|
|
188
|
+
const composing = useRef(false);
|
|
189
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
190
|
+
const [state, setState] = useState<{ held: boolean; releasedBy: HoldRelease | null }>({ held: false, releasedBy: null });
|
|
191
|
+
|
|
192
|
+
const clearTimer = (): void => {
|
|
193
|
+
if (timer.current !== null) {
|
|
194
|
+
clearTimeout(timer.current);
|
|
195
|
+
timer.current = null;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const release = (reason: HoldRelease): void => {
|
|
200
|
+
clearTimer();
|
|
201
|
+
typedAt.current = null;
|
|
202
|
+
composing.current = false;
|
|
203
|
+
setState((current) => (current.held ? { held: false, releasedBy: reason } : current));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const schedule = (): void => {
|
|
207
|
+
clearTimer();
|
|
208
|
+
const wait = holdLiftsIn({ typedAt: typedAt.current, composing: composing.current }, Date.now());
|
|
209
|
+
if (wait !== null) {
|
|
210
|
+
timer.current = setTimeout(() => release('pause'), wait);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const noteInput = (): void => {
|
|
215
|
+
typedAt.current = Date.now();
|
|
216
|
+
setState({ held: true, releasedBy: null });
|
|
217
|
+
schedule();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const setComposing = (next: boolean): void => {
|
|
221
|
+
composing.current = next;
|
|
222
|
+
if (next) {
|
|
223
|
+
setState({ held: true, releasedBy: null });
|
|
224
|
+
} else {
|
|
225
|
+
typedAt.current = Date.now();
|
|
226
|
+
}
|
|
227
|
+
schedule();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
useEffect(() => clearTimer, []);
|
|
231
|
+
|
|
232
|
+
return { held: state.held, releasedBy: state.releasedBy, release, noteInput, setComposing };
|
|
233
|
+
}
|
|
234
|
+
|
|
180
235
|
export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
|
|
181
236
|
const { t } = useTranslation('agent-chat');
|
|
182
237
|
const sendDelivery = chatHost().useSendDelivery();
|
|
@@ -200,6 +255,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
200
255
|
const { isApplePlatform, notify } = chatHost();
|
|
201
256
|
const registerQuoteTaker = useContext(QuoteTakerContext);
|
|
202
257
|
const inputRef = useRef<ComposerInputHandle>(null);
|
|
258
|
+
const hold = usePromptHold();
|
|
203
259
|
// Taking a queued message back waits on the machine, and what was typed meanwhile is what it merges with.
|
|
204
260
|
const draftRef = useRef(draft);
|
|
205
261
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -238,6 +294,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
238
294
|
const queue = info.queue ?? [];
|
|
239
295
|
const text = draft.text;
|
|
240
296
|
const quote = draft.quote;
|
|
297
|
+
const quoteSource = draft.quoteSource;
|
|
241
298
|
// The CLI announces its session on the first message, so anything before that is still a blank chat.
|
|
242
299
|
const started = info.agentSessionId !== null || info.usage.turns > 0;
|
|
243
300
|
const accountChoice = useAccountChoice(info.provider, info.account);
|
|
@@ -270,8 +327,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
270
327
|
|
|
271
328
|
useEffect(
|
|
272
329
|
() =>
|
|
273
|
-
registerQuoteTaker?.((selected) => {
|
|
274
|
-
setDraft((current) => ({ ...current, quote: selected }));
|
|
330
|
+
registerQuoteTaker?.((selected, itemId) => {
|
|
331
|
+
setDraft((current) => ({ ...current, quote: selected, quoteSource: itemId }));
|
|
275
332
|
inputRef.current?.focus();
|
|
276
333
|
}),
|
|
277
334
|
[registerQuoteTaker]
|
|
@@ -383,6 +440,26 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
383
440
|
[chatId, chats, draft.chats, mention]
|
|
384
441
|
);
|
|
385
442
|
const mentionCount = chatMatches.length + files.length;
|
|
443
|
+
// One menu is open at a time, so they share an id.
|
|
444
|
+
const listId = useId();
|
|
445
|
+
const mentionsLabelId = `${listId}-chats`;
|
|
446
|
+
const filesLabelId = `${listId}-files`;
|
|
447
|
+
const filesLabelShown = mention !== null && (mention.query === '' || chatMatches.length > 0);
|
|
448
|
+
const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen;
|
|
449
|
+
const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionCount;
|
|
450
|
+
// The list can shrink under a stale index; every reader takes the one that still points at an option.
|
|
451
|
+
const activeIndex = clampMenuIndex(menuIndex, menuCount);
|
|
452
|
+
const inputAria = useMemo(
|
|
453
|
+
() =>
|
|
454
|
+
composerMenuAria({
|
|
455
|
+
label: t('composer.inputLabel'),
|
|
456
|
+
listId,
|
|
457
|
+
open: menuOpen,
|
|
458
|
+
activeIndex,
|
|
459
|
+
count: menuCount
|
|
460
|
+
}),
|
|
461
|
+
[t, listId, menuOpen, activeIndex, menuCount]
|
|
462
|
+
);
|
|
386
463
|
const editorExtensions = useMemo(
|
|
387
464
|
() => [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD],
|
|
388
465
|
[draft.mentions, draft.skills]
|
|
@@ -398,7 +475,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
398
475
|
};
|
|
399
476
|
|
|
400
477
|
const removeQuote = (): void => {
|
|
401
|
-
setDraft((current) => ({ ...current, quote: '' }));
|
|
478
|
+
setDraft((current) => ({ ...current, quote: '', quoteSource: null }));
|
|
402
479
|
inputRef.current?.focus();
|
|
403
480
|
};
|
|
404
481
|
|
|
@@ -618,7 +695,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
618
695
|
setNotice(`${rejected.name}: ${rejected.reason}`);
|
|
619
696
|
return;
|
|
620
697
|
}
|
|
621
|
-
|
|
698
|
+
hold.release('send');
|
|
699
|
+
const chosen = commands[activeIndex];
|
|
622
700
|
if (commandQuery !== null && chosen?.local && runCommand(chosen.name)) {
|
|
623
701
|
clearDraft();
|
|
624
702
|
return;
|
|
@@ -639,6 +717,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
639
717
|
mentions: presentMentions(trimmed, draft.mentions),
|
|
640
718
|
skills: presentSkills(trimmed, draft.skills),
|
|
641
719
|
chats: draft.chats,
|
|
720
|
+
quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
|
|
642
721
|
attachments: draft.attachments
|
|
643
722
|
});
|
|
644
723
|
clearDraft();
|
|
@@ -647,9 +726,20 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
647
726
|
setHistoryIndex(null);
|
|
648
727
|
};
|
|
649
728
|
|
|
650
|
-
/*
|
|
729
|
+
/*
|
|
730
|
+
* A stashed prompt comes back as text, mentions and skills; its files were never kept. Its quote
|
|
731
|
+
* keeps leading to its reply in the chat it came from, and is plain text in any other.
|
|
732
|
+
*/
|
|
651
733
|
const restoreStashed = (prompt: StashedPrompt): void => {
|
|
652
|
-
|
|
734
|
+
const stashedQuote = prompt.quote;
|
|
735
|
+
const here = stashedQuote !== undefined && stashedQuote.chatId === chatId;
|
|
736
|
+
setDraft((current) => ({
|
|
737
|
+
...current,
|
|
738
|
+
text: stashedQuote === undefined || here ? prompt.text : withQuote(stashedQuote.text, prompt.text),
|
|
739
|
+
mentions: [...prompt.mentions],
|
|
740
|
+
skills: [...prompt.skills],
|
|
741
|
+
...(here ? { quote: stashedQuote.text, quoteSource: stashedQuote.itemId } : {})
|
|
742
|
+
}));
|
|
653
743
|
setHistoryIndex(null);
|
|
654
744
|
inputRef.current?.focus();
|
|
655
745
|
};
|
|
@@ -682,7 +772,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
682
772
|
failed(e);
|
|
683
773
|
return;
|
|
684
774
|
}
|
|
685
|
-
const taken: Omit<ChatDraft, 'quote'> = {
|
|
775
|
+
const taken: Omit<ChatDraft, 'quote' | 'quoteSource'> = {
|
|
686
776
|
text: message.text,
|
|
687
777
|
mentions: message.mentions ?? [],
|
|
688
778
|
skills: message.skills ?? [],
|
|
@@ -726,7 +816,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
726
816
|
}
|
|
727
817
|
return;
|
|
728
818
|
}
|
|
729
|
-
if (stashDraft(draft)) {
|
|
819
|
+
if (stashDraft(draft, chatId)) {
|
|
730
820
|
clearDraft();
|
|
731
821
|
setMention(null);
|
|
732
822
|
setSkillQuery(null);
|
|
@@ -800,7 +890,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
800
890
|
return true;
|
|
801
891
|
}
|
|
802
892
|
if (e.key === 'Tab') {
|
|
803
|
-
const chosen = commands[
|
|
893
|
+
const chosen = commands[activeIndex]!;
|
|
804
894
|
if (chosen.skill) {
|
|
805
895
|
chooseSkill(chosen.name);
|
|
806
896
|
} else {
|
|
@@ -814,7 +904,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
814
904
|
return true;
|
|
815
905
|
}
|
|
816
906
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
817
|
-
chooseSkill((skillMatches[
|
|
907
|
+
chooseSkill((skillMatches[activeIndex] ?? skillMatches[0]!).name);
|
|
818
908
|
return true;
|
|
819
909
|
}
|
|
820
910
|
}
|
|
@@ -823,7 +913,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
823
913
|
return true;
|
|
824
914
|
}
|
|
825
915
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
826
|
-
chooseMentionAt(
|
|
916
|
+
chooseMentionAt(activeIndex);
|
|
827
917
|
return true;
|
|
828
918
|
}
|
|
829
919
|
}
|
|
@@ -996,6 +1086,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
996
1086
|
hasDraft={!isEmptyDraft(draft)}
|
|
997
1087
|
denyReason={capabilities?.denyReason === true}
|
|
998
1088
|
onAllAnswered={() => inputRef.current?.focus()}
|
|
1089
|
+
hold={hold}
|
|
999
1090
|
>
|
|
1000
1091
|
<LimitDock chatId={chatId} info={info} />
|
|
1001
1092
|
{compactionOffer && (
|
|
@@ -1006,11 +1097,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1006
1097
|
/>
|
|
1007
1098
|
)}
|
|
1008
1099
|
{commandMenuOpen && (
|
|
1009
|
-
<div className="border-b border-border px-1.5 py-1.5">
|
|
1100
|
+
<div id={listId} role="listbox" aria-label={t('composer.menu.commands')} className="border-b border-border px-1.5 py-1.5">
|
|
1010
1101
|
{commands.map((command, index) => (
|
|
1011
1102
|
<button
|
|
1012
1103
|
key={command.name}
|
|
1013
|
-
|
|
1104
|
+
id={optionIdOf(listId, index)}
|
|
1105
|
+
role="option"
|
|
1106
|
+
aria-selected={index === activeIndex}
|
|
1107
|
+
tabIndex={-1}
|
|
1108
|
+
data-active={index === activeIndex}
|
|
1014
1109
|
className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
|
|
1015
1110
|
onMouseEnter={() => setMenuIndex(index)}
|
|
1016
1111
|
onMouseDown={(e) => e.preventDefault()}
|
|
@@ -1036,11 +1131,15 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1036
1131
|
</div>
|
|
1037
1132
|
)}
|
|
1038
1133
|
{skillMenuOpen && (
|
|
1039
|
-
<div className="border-b border-border px-1.5 py-1.5">
|
|
1134
|
+
<div id={listId} role="listbox" aria-label={t('composer.menu.skills')} className="border-b border-border px-1.5 py-1.5">
|
|
1040
1135
|
{skillMatches.map((skill, index) => (
|
|
1041
1136
|
<button
|
|
1042
1137
|
key={skill.name}
|
|
1043
|
-
|
|
1138
|
+
id={optionIdOf(listId, index)}
|
|
1139
|
+
role="option"
|
|
1140
|
+
aria-selected={index === activeIndex}
|
|
1141
|
+
tabIndex={-1}
|
|
1142
|
+
data-active={index === activeIndex}
|
|
1044
1143
|
className="cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
|
|
1045
1144
|
onMouseEnter={() => setMenuIndex(index)}
|
|
1046
1145
|
onMouseDown={(e) => e.preventDefault()}
|
|
@@ -1057,45 +1156,70 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1057
1156
|
)}
|
|
1058
1157
|
{mentionMenuOpen && (
|
|
1059
1158
|
<div className="border-b border-border px-1.5 py-1.5">
|
|
1060
|
-
{
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1159
|
+
{mentionCount > 0 && (
|
|
1160
|
+
<div id={listId} role="listbox" aria-label={t('composer.menu.mentions')}>
|
|
1161
|
+
{chatMatches.length > 0 && (
|
|
1162
|
+
<div role="group" aria-labelledby={mentionsLabelId}>
|
|
1163
|
+
<Menu.Label id={mentionsLabelId}>{t('composer.mentions.chats')}</Menu.Label>
|
|
1164
|
+
{chatMatches.map((chat, index) => (
|
|
1165
|
+
<button
|
|
1166
|
+
key={chat.id}
|
|
1167
|
+
id={optionIdOf(listId, index)}
|
|
1168
|
+
role="option"
|
|
1169
|
+
aria-selected={index === activeIndex}
|
|
1170
|
+
tabIndex={-1}
|
|
1171
|
+
data-active={index === activeIndex}
|
|
1172
|
+
className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
|
|
1173
|
+
onMouseEnter={() => setMenuIndex(index)}
|
|
1174
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
1175
|
+
onClick={() => chooseChat(chat.id)}
|
|
1176
|
+
>
|
|
1177
|
+
<Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
|
|
1178
|
+
<span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
|
|
1179
|
+
</button>
|
|
1180
|
+
))}
|
|
1181
|
+
</div>
|
|
1182
|
+
)}
|
|
1183
|
+
{files.length > 0 && (
|
|
1184
|
+
<div
|
|
1185
|
+
role="group"
|
|
1186
|
+
{...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}
|
|
1187
|
+
>
|
|
1188
|
+
{filesLabelShown && (
|
|
1189
|
+
<Menu.Label id={filesLabelId}>
|
|
1190
|
+
{mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files')}
|
|
1191
|
+
</Menu.Label>
|
|
1192
|
+
)}
|
|
1193
|
+
{files.map((path, index) => {
|
|
1194
|
+
const { name, dir } = splitPath(path);
|
|
1195
|
+
return (
|
|
1196
|
+
<button
|
|
1197
|
+
key={path}
|
|
1198
|
+
id={optionIdOf(listId, chatMatches.length + index)}
|
|
1199
|
+
role="option"
|
|
1200
|
+
aria-selected={chatMatches.length + index === activeIndex}
|
|
1201
|
+
tabIndex={-1}
|
|
1202
|
+
data-active={chatMatches.length + index === activeIndex}
|
|
1203
|
+
className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
|
|
1204
|
+
onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
|
|
1205
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
1206
|
+
onClick={() => chooseMention(path)}
|
|
1207
|
+
>
|
|
1208
|
+
<FileIcon path={path} size={14} />
|
|
1209
|
+
<span className="truncate font-mono text-text">{name}</span>
|
|
1210
|
+
{dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
|
|
1211
|
+
</button>
|
|
1212
|
+
);
|
|
1213
|
+
})}
|
|
1214
|
+
</div>
|
|
1215
|
+
)}
|
|
1216
|
+
</div>
|
|
1076
1217
|
)}
|
|
1077
1218
|
{mentionCount === 0 && (
|
|
1078
1219
|
<div className="px-2 py-1 text-xs text-text-faint">
|
|
1079
1220
|
{mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}
|
|
1080
1221
|
</div>
|
|
1081
1222
|
)}
|
|
1082
|
-
{files.map((path, index) => {
|
|
1083
|
-
const { name, dir } = splitPath(path);
|
|
1084
|
-
return (
|
|
1085
|
-
<button
|
|
1086
|
-
key={path}
|
|
1087
|
-
data-active={chatMatches.length + index === menuIndex}
|
|
1088
|
-
className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
|
|
1089
|
-
onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
|
|
1090
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
1091
|
-
onClick={() => chooseMention(path)}
|
|
1092
|
-
>
|
|
1093
|
-
<FileIcon path={path} size={14} />
|
|
1094
|
-
<span className="truncate font-mono text-text">{name}</span>
|
|
1095
|
-
{dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
|
|
1096
|
-
</button>
|
|
1097
|
-
);
|
|
1098
|
-
})}
|
|
1099
1223
|
</div>
|
|
1100
1224
|
)}
|
|
1101
1225
|
{queue.length > 0 && (
|
|
@@ -1152,7 +1276,24 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1152
1276
|
)}
|
|
1153
1277
|
{quote !== '' && (
|
|
1154
1278
|
<div className="flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
|
|
1155
|
-
|
|
1279
|
+
{quoteSource === null ? (
|
|
1280
|
+
<span className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted">{quote}</span>
|
|
1281
|
+
) : (
|
|
1282
|
+
<Tooltip label={t('composer.quote.goToSource')}>
|
|
1283
|
+
<button
|
|
1284
|
+
type="button"
|
|
1285
|
+
className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-left text-xs text-text-muted hover:text-text"
|
|
1286
|
+
onClick={() =>
|
|
1287
|
+
jumpToTimelineItem(scope.keyOf(chatId), quoteSource, {
|
|
1288
|
+
flash: true,
|
|
1289
|
+
onMissing: () => setNotice(t('rows.user.quoteGone'))
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
1292
|
+
>
|
|
1293
|
+
{quote}
|
|
1294
|
+
</button>
|
|
1295
|
+
</Tooltip>
|
|
1296
|
+
)}
|
|
1156
1297
|
<IconButton icon={X} size="2xs" label={t('composer.quote.remove')} onClick={removeQuote} />
|
|
1157
1298
|
</div>
|
|
1158
1299
|
)}
|
|
@@ -1198,7 +1339,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1198
1339
|
disabled={!writable}
|
|
1199
1340
|
tabbable={focused}
|
|
1200
1341
|
extensions={editorExtensions}
|
|
1342
|
+
aria={inputAria}
|
|
1201
1343
|
onChange={(value, selection, state) => {
|
|
1344
|
+
hold.noteInput();
|
|
1202
1345
|
setText(value);
|
|
1203
1346
|
setMenuIndex(0);
|
|
1204
1347
|
trackTriggers(value, selection, state);
|
|
@@ -1210,7 +1353,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1210
1353
|
onBlur={() => {
|
|
1211
1354
|
setMention(null);
|
|
1212
1355
|
setSkillQuery(null);
|
|
1356
|
+
hold.release('blur');
|
|
1213
1357
|
}}
|
|
1358
|
+
onComposing={hold.setComposing}
|
|
1214
1359
|
onKeyDown={onKeyDown}
|
|
1215
1360
|
onPaste={onPaste}
|
|
1216
1361
|
/>
|
|
@@ -29,9 +29,13 @@ interface ComposerInputProps {
|
|
|
29
29
|
className?: string;
|
|
30
30
|
/* Keep the reference stable. A new one reconfigures the editor. */
|
|
31
31
|
extensions?: Extension;
|
|
32
|
+
/* Attributes for the editable element, such as its label and the option a menu points at; keep the reference stable. */
|
|
33
|
+
aria?: Readonly<Record<string, string>>;
|
|
32
34
|
onChange(text: string, selection: InputSelection, state: EditorState): void;
|
|
33
35
|
onSelectionChange?(text: string, selection: InputSelection, state: EditorState): void;
|
|
34
36
|
onBlur?(): void;
|
|
37
|
+
/* An IME composition starts or ends; the text it builds is not in the document until it ends. */
|
|
38
|
+
onComposing?(composing: boolean): void;
|
|
35
39
|
/* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
|
|
36
40
|
onKeyDown?(event: KeyboardEvent, view: EditorView): boolean;
|
|
37
41
|
/* Runs before the editor pastes the plain text itself; true means the paste is handled. */
|
|
@@ -40,6 +44,8 @@ interface ComposerInputProps {
|
|
|
40
44
|
|
|
41
45
|
const NO_EXTENSIONS: Extension = [];
|
|
42
46
|
|
|
47
|
+
const NO_ARIA: Readonly<Record<string, string>> = {};
|
|
48
|
+
|
|
43
49
|
/*
|
|
44
50
|
* A multi-line text field on CodeMirror, controlled by a plain string. The editor draws the text
|
|
45
51
|
* itself, so a decoration may change a font or add padding without walking the caret off its
|
|
@@ -54,6 +60,7 @@ export function ComposerInput({
|
|
|
54
60
|
className,
|
|
55
61
|
dictationToolbar,
|
|
56
62
|
extensions = NO_EXTENSIONS,
|
|
63
|
+
aria = NO_ARIA,
|
|
57
64
|
...handlers
|
|
58
65
|
}: ComposerInputProps) {
|
|
59
66
|
const hostRef = useRef<HTMLDivElement>(null);
|
|
@@ -94,7 +101,15 @@ export function ComposerInput({
|
|
|
94
101
|
Prec.highest(
|
|
95
102
|
EditorView.domEventHandlers({
|
|
96
103
|
keydown: (event, target) => latest.current.handlers.onKeyDown?.(event, target) ?? false,
|
|
97
|
-
paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false
|
|
104
|
+
paste: (event, target) => latest.current.handlers.onPaste?.(event, target) ?? false,
|
|
105
|
+
compositionstart: () => {
|
|
106
|
+
latest.current.handlers.onComposing?.(true);
|
|
107
|
+
return false;
|
|
108
|
+
},
|
|
109
|
+
compositionend: () => {
|
|
110
|
+
latest.current.handlers.onComposing?.(false);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
98
113
|
})
|
|
99
114
|
),
|
|
100
115
|
EditorView.updateListener.of((update) => {
|
|
@@ -128,13 +143,13 @@ export function ComposerInput({
|
|
|
128
143
|
viewRef.current?.dispatch({
|
|
129
144
|
effects: [
|
|
130
145
|
// Spellcheck stays on, as it was on the textarea this editor replaced.
|
|
131
|
-
compartments.attributes.reconfigure(EditorView.contentAttributes.of({ spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
|
|
146
|
+
compartments.attributes.reconfigure(EditorView.contentAttributes.of({ ...aria, spellcheck: 'true', tabindex: tabbable ? '0' : '-1' })),
|
|
132
147
|
compartments.editable.reconfigure([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
|
|
133
148
|
compartments.placeholder.reconfigure(placeholderText(placeholder)),
|
|
134
149
|
compartments.extensions.reconfigure(extensions)
|
|
135
150
|
]
|
|
136
151
|
});
|
|
137
|
-
}, [compartments, tabbable, disabled, placeholder, extensions]);
|
|
152
|
+
}, [compartments, tabbable, disabled, placeholder, extensions, aria]);
|
|
138
153
|
|
|
139
154
|
useLayoutEffect(() => {
|
|
140
155
|
const view = viewRef.current;
|
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
|
)}
|