@adecore/agents-react 0.21.1 → 0.21.2
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/status-look.js +2 -3
- package/dist/chat/chat-client.d.ts +1 -1
- package/dist/chat/chat-client.js +2 -2
- package/dist/chat/drafts.js +6 -12
- package/dist/chat/logic/ansi.js +5 -1
- package/dist/chat/logic/fork.js +8 -9
- package/dist/chat/logic/limit.js +17 -14
- package/dist/chat/logic/resume-compaction.js +2 -11
- package/dist/chat/logic/scrubber.js +9 -18
- package/dist/chat/logic/timeline.js +15 -10
- package/dist/chat/logic/tool-catalog.js +3 -4
- package/dist/chat/mentions.js +5 -6
- package/dist/chat/runtime-modes.js +2 -4
- package/dist/chat/stash.js +1 -3
- package/dist/chat/thumbnails.js +3 -4
- package/dist/chat/timeline-scroll.js +39 -45
- package/dist/chat/ui/BookmarkSubmenu.js +1 -5
- package/dist/chat/ui/CodeBlock.js +2 -3
- package/dist/chat/ui/Composer.js +57 -69
- package/dist/chat/ui/Markdown.js +11 -11
- package/dist/chat/ui/Pickers.js +5 -9
- package/dist/chat/ui/QuoteButton.js +2 -3
- package/dist/chat/ui/RunSettings.js +8 -2
- package/dist/chat/ui/Scrubber.js +2 -3
- package/dist/chat/ui/SubagentControls.js +1 -3
- package/dist/chat/ui/Timeline.js +15 -18
- package/dist/chat/ui/TimelineMenu.js +3 -9
- package/dist/chat/ui/UnifiedDiff.js +3 -5
- package/dist/chat/ui/VisualFrame.js +3 -4
- package/dist/chat/ui/composer/keys.js +19 -11
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
- package/dist/chat/ui/rows/MessageRows.js +2 -3
- package/dist/chat/ui/rows/WorkRows.d.ts +4 -3
- package/dist/chat/ui/rows/WorkRows.js +19 -14
- package/dist/chat/visual-viewport.d.ts +1 -1
- package/dist/chat/visual-viewport.js +9 -8
- package/dist/port-transport.js +4 -8
- package/dist/state/chats.js +10 -14
- package/dist/state/usage.js +44 -49
- package/dist/storage.js +7 -7
- package/dist/theme.css +6 -14
- package/dist/usage/LimitsList.js +1 -2
- package/dist/usage/UsageBreakdown.js +1 -3
- package/dist/usage/UsageDialog.js +2 -4
- package/dist/usage/format.js +6 -6
- package/dist/usage/money.js +1 -5
- package/dist/usage/summary.js +2 -4
- package/package.json +4 -4
- package/src/agents/status-look.ts +2 -3
- package/src/chat/chat-client.ts +2 -3
- package/src/chat/drafts.ts +6 -14
- package/src/chat/logic/ansi.ts +5 -1
- package/src/chat/logic/fork.ts +8 -9
- package/src/chat/logic/limit.ts +17 -16
- package/src/chat/logic/resume-compaction.ts +2 -11
- package/src/chat/logic/scrubber.ts +13 -17
- package/src/chat/logic/timeline.ts +22 -11
- package/src/chat/logic/tool-catalog.ts +7 -6
- package/src/chat/mentions.ts +5 -6
- package/src/chat/runtime-modes.ts +2 -4
- package/src/chat/stash.ts +1 -3
- package/src/chat/thumbnails.ts +3 -4
- package/src/chat/timeline-scroll.ts +43 -52
- package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
- package/src/chat/ui/CodeBlock.tsx +2 -3
- package/src/chat/ui/Composer.tsx +66 -86
- package/src/chat/ui/Markdown.tsx +13 -17
- package/src/chat/ui/Pickers.tsx +5 -9
- package/src/chat/ui/QuoteButton.tsx +2 -3
- package/src/chat/ui/RunSettings.tsx +9 -2
- package/src/chat/ui/Scrubber.tsx +2 -3
- package/src/chat/ui/SubagentControls.tsx +1 -3
- package/src/chat/ui/SubagentTimeline.tsx +1 -2
- package/src/chat/ui/Timeline.tsx +18 -20
- package/src/chat/ui/TimelineMenu.tsx +3 -9
- package/src/chat/ui/UnifiedDiff.tsx +3 -5
- package/src/chat/ui/VisualFrame.tsx +3 -4
- package/src/chat/ui/composer/keys.ts +21 -11
- package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
- package/src/chat/ui/rehype-chips.ts +1 -1
- package/src/chat/ui/rehype-fade.ts +1 -1
- package/src/chat/ui/remark-html-as-text.ts +1 -1
- package/src/chat/ui/rows/MessageRows.tsx +3 -4
- package/src/chat/ui/rows/WorkRows.tsx +86 -69
- package/src/chat/visual-viewport.ts +10 -8
- package/src/host.ts +1 -3
- package/src/port-transport.ts +4 -8
- package/src/state/chats.ts +11 -14
- package/src/state/usage.ts +49 -60
- package/src/storage.ts +7 -7
- package/src/theme.css +6 -14
- package/src/usage/LimitsList.tsx +2 -4
- package/src/usage/UsageBreakdown.tsx +2 -5
- package/src/usage/UsageDialog.tsx +2 -4
- package/src/usage/format.ts +6 -6
- package/src/usage/money.ts +1 -5
- package/src/usage/summary.ts +2 -4
package/src/chat/ui/Composer.tsx
CHANGED
|
@@ -98,8 +98,7 @@ const PROMPT_HISTORY = 50;
|
|
|
98
98
|
// Commands the composer handles itself; the CLI's own ones are sent through as text.
|
|
99
99
|
const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
|
|
100
100
|
|
|
101
|
-
/*
|
|
102
|
-
file's text, or the drag's own text, at the drop point on top of that. */
|
|
101
|
+
/* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
|
|
103
102
|
const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
|
|
104
103
|
drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
|
|
105
104
|
});
|
|
@@ -158,6 +157,20 @@ function hintedPlaceholder(lead: string, joiner: string, hints: ReadonlyArray<{
|
|
|
158
157
|
return root;
|
|
159
158
|
}
|
|
160
159
|
|
|
160
|
+
function sendLabelKey(busy: boolean, canSteer: boolean, delivery: 'queue' | 'steer'): string {
|
|
161
|
+
if (!busy) {
|
|
162
|
+
return 'composer.send';
|
|
163
|
+
}
|
|
164
|
+
if (!canSteer) {
|
|
165
|
+
return 'composer.queueButton';
|
|
166
|
+
}
|
|
167
|
+
return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boolean {
|
|
171
|
+
return left?.start === right?.start && left?.query === right?.query;
|
|
172
|
+
}
|
|
173
|
+
|
|
161
174
|
function splitPath(path: string): { name: string; dir: string } {
|
|
162
175
|
const slash = path.lastIndexOf('/');
|
|
163
176
|
return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
|
|
@@ -196,7 +209,6 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
196
209
|
const providers = useProviders((s) => s.providers);
|
|
197
210
|
const { approvals, questions } = usePendingRequests(chatId);
|
|
198
211
|
const order = useChatRow(chatId, (row) => row?.order);
|
|
199
|
-
// The structure, which a delta leaves alone. The prompts and the requests are items of their own.
|
|
200
212
|
const items = useChatRow(chatId, (row) => row?.structure);
|
|
201
213
|
const chats = chatHost().useReferableChats();
|
|
202
214
|
const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
|
|
@@ -208,7 +220,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
208
220
|
const capabilities = provider?.capabilities;
|
|
209
221
|
const compaction = capabilities?.compaction;
|
|
210
222
|
const dismissedTurnId = useResumeCompactionDismissal(chatId);
|
|
211
|
-
|
|
223
|
+
// The cheap half of the offer, so a chat that can never make one keeps no timer.
|
|
212
224
|
const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
|
|
213
225
|
const now = useNow(60_000, couldOfferCompaction);
|
|
214
226
|
const compactionOffer = !couldOfferCompaction
|
|
@@ -228,13 +240,9 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
228
240
|
const quote = draft.quote;
|
|
229
241
|
// The CLI announces its session on the first message, so anything before that is still a blank chat.
|
|
230
242
|
const started = info.agentSessionId !== null || info.usage.turns > 0;
|
|
231
|
-
/*
|
|
232
|
-
* A chat that has started keeps its provider (the CLI holds the thread), and one opened for a
|
|
233
|
-
* named CLI was never going to change it. Either way the models stay switchable. The CLI takes
|
|
234
|
-
* the new one on the restart the next send does anyway. With one provider in the list the
|
|
235
|
-
* picker drops its group headers on its own, so it reads as that CLI's own catalog.
|
|
236
|
-
*/
|
|
237
243
|
const accountChoice = useAccountChoice(info.provider, info.account);
|
|
244
|
+
// A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
|
|
245
|
+
// named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
|
|
238
246
|
const pickable =
|
|
239
247
|
started || providerFixed
|
|
240
248
|
? providers.filter((entry) => entry.kind === info.provider)
|
|
@@ -284,11 +292,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
284
292
|
}
|
|
285
293
|
let stale = false;
|
|
286
294
|
const search = (): void => {
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
295
|
+
const searchFiles = chatHost().searchFiles;
|
|
296
|
+
if (searchFiles === null) {
|
|
289
297
|
return;
|
|
290
298
|
}
|
|
291
|
-
|
|
299
|
+
searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
|
|
292
300
|
.then((files) => {
|
|
293
301
|
if (!stale) {
|
|
294
302
|
setSearched({ query: mention.query, files });
|
|
@@ -394,15 +402,28 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
394
402
|
inputRef.current?.focus();
|
|
395
403
|
};
|
|
396
404
|
|
|
397
|
-
const
|
|
405
|
+
const showError = (error: unknown): void => {
|
|
406
|
+
setNotice(error instanceof Error ? error.message : String(error));
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/* One configuration at a time; a second pick while one is on its way is dropped. */
|
|
410
|
+
const runConfiguration = (work: () => Promise<void>): void => {
|
|
398
411
|
if (configuring.current) {
|
|
399
412
|
return;
|
|
400
413
|
}
|
|
401
414
|
configuring.current = true;
|
|
402
415
|
setConfigurationPending(true);
|
|
403
|
-
void
|
|
404
|
-
.
|
|
405
|
-
.
|
|
416
|
+
void work()
|
|
417
|
+
.catch(showError)
|
|
418
|
+
.finally(() => {
|
|
419
|
+
configuring.current = false;
|
|
420
|
+
setConfigurationPending(false);
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
|
|
425
|
+
runConfiguration(() =>
|
|
426
|
+
actions.configure(chatId, patch).then(() => {
|
|
406
427
|
if (patch.selection) {
|
|
407
428
|
rememberChatSelection(info.provider, patch.selection);
|
|
408
429
|
}
|
|
@@ -413,11 +434,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
413
434
|
rememberChatAccount(scope.id, info.provider, patch.account);
|
|
414
435
|
}
|
|
415
436
|
})
|
|
416
|
-
|
|
417
|
-
.finally(() => {
|
|
418
|
-
configuring.current = false;
|
|
419
|
-
setConfigurationPending(false);
|
|
420
|
-
});
|
|
437
|
+
);
|
|
421
438
|
};
|
|
422
439
|
|
|
423
440
|
const chooseAccount = (account: string): void => configure({ account });
|
|
@@ -426,18 +443,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
426
443
|
const chooseModel = (provider: AgentKind, slug: string): void => {
|
|
427
444
|
const selection: ModelSelection = { model: slug, options: {} };
|
|
428
445
|
if (provider !== info.provider) {
|
|
429
|
-
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
configuring.current = true;
|
|
433
|
-
setConfigurationPending(true);
|
|
434
|
-
void onRetarget(provider, selection)
|
|
435
|
-
.then(() => rememberChatSelection(provider, selection))
|
|
436
|
-
.catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
|
|
437
|
-
.finally(() => {
|
|
438
|
-
configuring.current = false;
|
|
439
|
-
setConfigurationPending(false);
|
|
440
|
-
});
|
|
446
|
+
runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
|
|
441
447
|
return;
|
|
442
448
|
}
|
|
443
449
|
configure({ selection });
|
|
@@ -445,14 +451,10 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
445
451
|
|
|
446
452
|
const stop = (shiftKey: boolean): void => {
|
|
447
453
|
if (composerStopOf(shiftKey) === 'turn') {
|
|
448
|
-
void actions.stopTurn(chatId, false).catch(
|
|
454
|
+
void actions.stopTurn(chatId, false).catch(showError);
|
|
449
455
|
return;
|
|
450
456
|
}
|
|
451
|
-
chatHost().confirm.stopSubagents(
|
|
452
|
-
scope.id,
|
|
453
|
-
chatId,
|
|
454
|
-
() => void actions.stopTurn(chatId, true).catch((error: unknown) => setNotice(error instanceof Error ? error.message : String(error)))
|
|
455
|
-
);
|
|
457
|
+
chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
|
|
456
458
|
};
|
|
457
459
|
|
|
458
460
|
/* The host decides whether a turn is in the way; only its refusal asks the person first. */
|
|
@@ -493,18 +495,16 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
493
495
|
}
|
|
494
496
|
};
|
|
495
497
|
|
|
496
|
-
const same = (a: MentionQuery | null, b: MentionQuery | null): boolean => a?.start === b?.start && a?.query === b?.query;
|
|
497
|
-
|
|
498
498
|
/* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
|
|
499
499
|
const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
|
|
500
500
|
const empty = selection.from === selection.to;
|
|
501
501
|
const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
|
|
502
502
|
const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
|
|
503
503
|
const skill = caret === null ? null : findSkillQuery(value, caret);
|
|
504
|
-
setSkillQuery((current) => (
|
|
504
|
+
setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
|
|
505
505
|
// A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
|
|
506
506
|
const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
|
|
507
|
-
setMention((current) => (
|
|
507
|
+
setMention((current) => (sameQuery(current, next) ? current : next));
|
|
508
508
|
};
|
|
509
509
|
|
|
510
510
|
// The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
|
|
@@ -558,8 +558,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
558
558
|
moveCaret(result.caret);
|
|
559
559
|
};
|
|
560
560
|
|
|
561
|
-
/* Paths dragged in from a list of files.
|
|
562
|
-
at the end of the prompt and join the draft's mentions, which is what `chat.send` carries. */
|
|
561
|
+
/* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
|
|
563
562
|
const addMentions = (paths: string[]): void => {
|
|
564
563
|
const fresh = paths.filter((path) => path !== '');
|
|
565
564
|
if (fresh.length === 0 || capabilities?.mentions === false) {
|
|
@@ -752,6 +751,19 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
752
751
|
return true;
|
|
753
752
|
};
|
|
754
753
|
|
|
754
|
+
/* The arrows walk an open picker round; true when the key was one of them. */
|
|
755
|
+
const moveInMenu = (key: string, count: number): boolean => {
|
|
756
|
+
if (key === 'ArrowDown') {
|
|
757
|
+
setMenuIndex((i) => (i + 1) % count);
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
if (key === 'ArrowUp') {
|
|
761
|
+
setMenuIndex((i) => (i - 1 + count) % count);
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
return false;
|
|
765
|
+
};
|
|
766
|
+
|
|
755
767
|
/* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
|
|
756
768
|
const onKeyDown = (e: KeyboardEvent, view: EditorView): boolean => {
|
|
757
769
|
// Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
|
|
@@ -766,8 +778,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
766
778
|
setText('', [], []);
|
|
767
779
|
return true;
|
|
768
780
|
}
|
|
769
|
-
// The
|
|
770
|
-
// panels and the palette stay the app's, untouched: Mod+Shift+Enter would otherwise send as well.
|
|
781
|
+
// The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
|
|
771
782
|
if (chatHost().isAppShortcut(e)) {
|
|
772
783
|
return false;
|
|
773
784
|
}
|
|
@@ -785,12 +796,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
785
796
|
return true;
|
|
786
797
|
}
|
|
787
798
|
if (commandMenuOpen) {
|
|
788
|
-
if (e.key
|
|
789
|
-
setMenuIndex((i) => (i + 1) % commands.length);
|
|
790
|
-
return true;
|
|
791
|
-
}
|
|
792
|
-
if (e.key === 'ArrowUp') {
|
|
793
|
-
setMenuIndex((i) => (i - 1 + commands.length) % commands.length);
|
|
799
|
+
if (moveInMenu(e.key, commands.length)) {
|
|
794
800
|
return true;
|
|
795
801
|
}
|
|
796
802
|
if (e.key === 'Tab') {
|
|
@@ -804,12 +810,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
804
810
|
}
|
|
805
811
|
}
|
|
806
812
|
if (skillMenuOpen) {
|
|
807
|
-
if (e.key
|
|
808
|
-
setMenuIndex((i) => (i + 1) % skillMatches.length);
|
|
809
|
-
return true;
|
|
810
|
-
}
|
|
811
|
-
if (e.key === 'ArrowUp') {
|
|
812
|
-
setMenuIndex((i) => (i - 1 + skillMatches.length) % skillMatches.length);
|
|
813
|
+
if (moveInMenu(e.key, skillMatches.length)) {
|
|
813
814
|
return true;
|
|
814
815
|
}
|
|
815
816
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
@@ -818,12 +819,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
818
819
|
}
|
|
819
820
|
}
|
|
820
821
|
if (mentionMenuOpen && mentionCount > 0) {
|
|
821
|
-
if (e.key
|
|
822
|
-
setMenuIndex((i) => (i + 1) % mentionCount);
|
|
823
|
-
return true;
|
|
824
|
-
}
|
|
825
|
-
if (e.key === 'ArrowUp') {
|
|
826
|
-
setMenuIndex((i) => (i - 1 + mentionCount) % mentionCount);
|
|
822
|
+
if (moveInMenu(e.key, mentionCount)) {
|
|
827
823
|
return true;
|
|
828
824
|
}
|
|
829
825
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
@@ -946,9 +942,8 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
946
942
|
|
|
947
943
|
return (
|
|
948
944
|
<div className="chat-composer-content pointer-events-none relative z-10 w-full">
|
|
949
|
-
{/*
|
|
950
|
-
|
|
951
|
-
{/* A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
|
|
945
|
+
{/* Over the composer rather than in the thread, since the composer's height is the one it always clears.
|
|
946
|
+
A read-only composer sits under something that stands in the thread's place, whose end is not this one. */}
|
|
952
947
|
{!atEnd && !readOnly && (
|
|
953
948
|
<div className="absolute inset-x-0 bottom-full mb-2 flex justify-center">
|
|
954
949
|
<Tooltip label={t('composer.jumpToEnd')} name>
|
|
@@ -969,8 +964,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
969
964
|
? 'border-accent bg-[color-mix(in_srgb,var(--accent-soft)_60%,var(--surface-raised))]'
|
|
970
965
|
: 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'
|
|
971
966
|
)}
|
|
972
|
-
/* The whole card takes a dropped file
|
|
973
|
-
of its own leaves it alone. */
|
|
967
|
+
/* The whole card takes a dropped file, so a layout around it that takes drops of its own leaves it alone. */
|
|
974
968
|
data-takes-drop="all"
|
|
975
969
|
onDragOver={(e) => {
|
|
976
970
|
if (writable && (e.dataTransfer.types.includes('Files') || carriesMentions(e.dataTransfer.types))) {
|
|
@@ -1300,21 +1294,7 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
|
|
|
1300
1294
|
</Tooltip>
|
|
1301
1295
|
)}
|
|
1302
1296
|
{(!busy || !isEmptyDraft(draft)) && (
|
|
1303
|
-
<Tooltip
|
|
1304
|
-
label={
|
|
1305
|
-
busy
|
|
1306
|
-
? t(
|
|
1307
|
-
canSteer
|
|
1308
|
-
? sendDelivery === 'steer'
|
|
1309
|
-
? 'composer.steerButton'
|
|
1310
|
-
: 'composer.queueWithSteerButton'
|
|
1311
|
-
: 'composer.queueButton'
|
|
1312
|
-
)
|
|
1313
|
-
: t('composer.send')
|
|
1314
|
-
}
|
|
1315
|
-
kbd={KEY_SHORTCUTS.modEnter}
|
|
1316
|
-
name
|
|
1317
|
-
>
|
|
1297
|
+
<Tooltip label={t(sendLabelKey(busy, canSteer, sendDelivery))} kbd={KEY_SHORTCUTS.modEnter} name>
|
|
1318
1298
|
<button
|
|
1319
1299
|
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"
|
|
1320
1300
|
disabled={isEmptyDraft(draft) || !writable || guard.tooLong}
|
package/src/chat/ui/Markdown.tsx
CHANGED
|
@@ -67,17 +67,14 @@ function languageOf(className: string | undefined): string {
|
|
|
67
67
|
const INLINE_CODE = 'rounded-sm bg-surface-sunken px-1 py-px font-mono text-code';
|
|
68
68
|
|
|
69
69
|
/*
|
|
70
|
-
* A path an agent wrote down
|
|
71
|
-
*
|
|
72
|
-
* be clicked. `data-file-path` is what the thread's own right-click menu reads, so a link answers to
|
|
73
|
-
* both ways of opening it.
|
|
70
|
+
* A path an agent wrote down. It keeps the shape of the code chip it would otherwise be, so a sentence
|
|
71
|
+
* full of them does not shift; `data-file-path` is what the thread's right-click menu reads.
|
|
74
72
|
*/
|
|
75
73
|
function FileLink({ target, className, children }: { target: FileRef; className?: string; children: ReactNode }) {
|
|
76
74
|
const cwd = useFileLinkCwd();
|
|
77
75
|
const scopeId = useFileLinkScopeId();
|
|
78
76
|
return (
|
|
79
|
-
//
|
|
80
|
-
// selectable on its own.
|
|
77
|
+
// A button is not selectable on its own, and a thread is copied as often as it is clicked.
|
|
81
78
|
<button
|
|
82
79
|
type="button"
|
|
83
80
|
className={clsx('cursor-pointer select-text', className)}
|
|
@@ -108,8 +105,10 @@ function InlineCode({ text }: { text: string }) {
|
|
|
108
105
|
);
|
|
109
106
|
}
|
|
110
107
|
|
|
111
|
-
/*
|
|
112
|
-
|
|
108
|
+
/*
|
|
109
|
+
* The path a link target spells, without the one scheme that still means a file on the host's machine.
|
|
110
|
+
* A href nobody encoded is left as it is, since decoding throws on a stray percent.
|
|
111
|
+
*/
|
|
113
112
|
function hrefPath(href: string): string {
|
|
114
113
|
const bare = href.replace(/^file:\/\//, '');
|
|
115
114
|
try {
|
|
@@ -129,6 +128,10 @@ function MarkdownLink({ href, children }: { href?: string; children?: ReactNode
|
|
|
129
128
|
</FileLink>
|
|
130
129
|
);
|
|
131
130
|
}
|
|
131
|
+
return <ExternalLink href={href}>{children}</ExternalLink>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ExternalLink({ href, children }: { href?: string; children?: ReactNode }) {
|
|
132
135
|
return (
|
|
133
136
|
<a href={href} target="_blank" rel="noreferrer">
|
|
134
137
|
{children}
|
|
@@ -173,13 +176,7 @@ const plainComponents = {
|
|
|
173
176
|
</MarkdownCode>
|
|
174
177
|
);
|
|
175
178
|
},
|
|
176
|
-
a
|
|
177
|
-
return (
|
|
178
|
-
<a href={href} target="_blank" rel="noreferrer">
|
|
179
|
-
{children}
|
|
180
|
-
</a>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
179
|
+
a: ExternalLink
|
|
183
180
|
};
|
|
184
181
|
|
|
185
182
|
// A reply sits under the heading of its message, an `h3`, so its own headings start one level below
|
|
@@ -271,8 +268,7 @@ const MESSAGE_PLUGINS = [remarkGfm, remarkHtmlAsText, remarkBreaks];
|
|
|
271
268
|
const FADE_PLUGINS = [rehypeFadeWords];
|
|
272
269
|
const NO_PLUGINS: typeof FADE_PLUGINS = [];
|
|
273
270
|
|
|
274
|
-
/* One block of a reply. It renders no element of its own, so prose
|
|
275
|
-
direct children and keeps its first and last margins. */
|
|
271
|
+
/* One block of a reply. It renders no element of its own, so prose keeps its first and last margins. */
|
|
276
272
|
const ReplyBlock = memo(function ReplyBlock({ text, fade, open, reply }: { text: string; fade: boolean; open: boolean; reply?: ShellCodeBlockReply }) {
|
|
277
273
|
return (
|
|
278
274
|
// A context rather than a second set of components. A component that changed would mount the
|
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -43,11 +43,9 @@ interface ModelPickerProps {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/*
|
|
46
|
-
* Which model answers:
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* another provider's model is also how a chat picks its provider. A chat bound to one CLI passes
|
|
50
|
-
* only that CLI, which drops the group headers and leaves its own catalog to choose from.
|
|
46
|
+
* Which model answers: a search field over one group per provider, with the legacy models behind an
|
|
47
|
+
* expander. Before the first message the list carries every installed CLI, so picking another
|
|
48
|
+
* provider's model is also how a chat picks its provider; with one CLI the group headers drop.
|
|
51
49
|
*/
|
|
52
50
|
export function ModelPicker({
|
|
53
51
|
providers,
|
|
@@ -224,10 +222,8 @@ export function ModelPicker({
|
|
|
224
222
|
}
|
|
225
223
|
|
|
226
224
|
/*
|
|
227
|
-
* Prompts put aside with Cmd+S
|
|
228
|
-
*
|
|
229
|
-
* place. Restoring drops the text, the mentions and the skills into this composer; the files a
|
|
230
|
-
* draft held are named on the row but not kept, because their bytes never go to storage.
|
|
225
|
+
* Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
|
|
226
|
+
* to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
|
|
231
227
|
*/
|
|
232
228
|
export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): void }) {
|
|
233
229
|
const { t } = useTranslation('agent-chat');
|
|
@@ -21,9 +21,8 @@ function lastLineOf(range: Range): DOMRect | null {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/*
|
|
24
|
-
* A button under a selection in an answer that hands it to the composer.
|
|
25
|
-
*
|
|
26
|
-
* pointer. It only shows once the pointer is up, so a drag never has it jump along.
|
|
24
|
+
* A button under a selection in an answer that hands it to the composer. It shows once the pointer is
|
|
25
|
+
* up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
|
|
27
26
|
*/
|
|
28
27
|
export function QuoteButton({
|
|
29
28
|
thread,
|
|
@@ -103,6 +103,13 @@ function modelValue(provider: AgentKind, slug: string): string {
|
|
|
103
103
|
return `${provider}/${slug}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/* Each provider's current or legacy models; a provider with none of them is left out. */
|
|
107
|
+
function modelGroupsOf(providers: ProviderInfo[], legacy: boolean): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
|
|
108
|
+
return providers
|
|
109
|
+
.map((entry) => ({ entry, models: entry.models.filter((row) => (row.legacy === true) === legacy) }))
|
|
110
|
+
.filter((group) => group.models.length > 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
106
113
|
/*
|
|
107
114
|
* Everything that decides how the next turn runs, behind one pill: the model, its own knobs and the
|
|
108
115
|
* permission mode. Model and effective permissions stay visible as the composer narrows.
|
|
@@ -142,8 +149,8 @@ export function RunSettings({
|
|
|
142
149
|
summary.push({ id: CONTEXT_OPTION, label: formatTokens(usage.contextWindow ?? 0), ring: true });
|
|
143
150
|
}
|
|
144
151
|
const grouped = providers.length > 1;
|
|
145
|
-
const live = providers
|
|
146
|
-
const legacy = providers
|
|
152
|
+
const live = modelGroupsOf(providers, false);
|
|
153
|
+
const legacy = modelGroupsOf(providers, true);
|
|
147
154
|
const chosenLegacy = owner?.models.find((row) => row.legacy && row.slug === selection.model);
|
|
148
155
|
const chosenModel = modelValue(provider, selection.model);
|
|
149
156
|
const shownMode = effectiveRuntimeMode ?? runtimeMode;
|
package/src/chat/ui/Scrubber.tsx
CHANGED
|
@@ -70,9 +70,8 @@ const NOTHING_FOUND: readonly boolean[] = [];
|
|
|
70
70
|
|
|
71
71
|
/*
|
|
72
72
|
* The strip at the left edge of a chat view: a tick per message of the person, per wake by tasks and
|
|
73
|
-
* per other message
|
|
74
|
-
*
|
|
75
|
-
* open, a short mark at the strip's right edge stands beside every tick with a hit under it.
|
|
73
|
+
* per other bookmarked message, the ones on screen bright. One hover card follows the pointer, so a
|
|
74
|
+
* thousand ticks are a thousand spans and not a thousand popups. An open find marks every tick it hits.
|
|
76
75
|
*/
|
|
77
76
|
export const Scrubber = memo(function Scrubber({
|
|
78
77
|
ticks,
|
|
@@ -8,9 +8,7 @@ import { Icon, IconButton } from '@adecore/ui';
|
|
|
8
8
|
|
|
9
9
|
const CRUMB_LINK = 'truncate text-text-muted hover:text-text';
|
|
10
10
|
|
|
11
|
-
/*
|
|
12
|
-
* The chat's own title where the bar draws it. While a sub-agent stands in the chat's place it is the first crumb of the way down, so pressing it goes back to the main agent.
|
|
13
|
-
*/
|
|
11
|
+
/* The chat's own title in the bar; while a sub-agent stands in the chat's place, pressing it goes back to the main agent. */
|
|
14
12
|
export function SubagentTitleCrumb({ chatId, className, children }: { chatId: string; className?: string; children: ReactNode }) {
|
|
15
13
|
const { trail, show } = useSubagentTrail(chatId);
|
|
16
14
|
if (trail.length === 0) {
|
|
@@ -136,8 +136,7 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
|
|
|
136
136
|
loadEarlier();
|
|
137
137
|
}}
|
|
138
138
|
>
|
|
139
|
-
{/* The
|
|
140
|
-
it belongs to is not passed on and the fork item stays out. */}
|
|
139
|
+
{/* The chat's own menu; this transcript is read-only, so no chat is passed and the fork item stays out. */}
|
|
141
140
|
<ContextMenu.Root>
|
|
142
141
|
<ContextMenu.Trigger
|
|
143
142
|
ref={threadRef}
|
package/src/chat/ui/Timeline.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
1
|
+
import { type CSSProperties, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
|
+
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
5
|
import { LoaderCircle } from 'lucide-react';
|
|
6
6
|
import type { ChatBookmark } from '@adecore/agent-contracts';
|
|
@@ -67,6 +67,12 @@ import { ContextMenu, EmptyState, ErrorBoundary, Icon, SectionLabel } from '@ade
|
|
|
67
67
|
|
|
68
68
|
const ESTIMATED_ROW_PX = 56;
|
|
69
69
|
|
|
70
|
+
/* A jump puts its row at the top and stops following the end, so the reader stays where they landed. */
|
|
71
|
+
function showRowAtTop(virtualizer: Virtualizer<HTMLDivElement, Element>, followRef: RefObject<boolean>, index: number): void {
|
|
72
|
+
followRef.current = false;
|
|
73
|
+
virtualizer.scrollToIndex(index, { align: 'start' });
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
/*
|
|
71
77
|
* A thread before its first message: which model answers, what the lines into this node let it read,
|
|
72
78
|
* and the folder it works in, so a first question can lean on all three.
|
|
@@ -288,7 +294,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
288
294
|
[chatId]
|
|
289
295
|
);
|
|
290
296
|
|
|
291
|
-
//
|
|
297
|
+
// Nothing here builds with the React Compiler, so its memoization rule has nothing to break.
|
|
292
298
|
// oxlint-disable-next-line react/incompatible-library
|
|
293
299
|
const virtualizer = useVirtualizer({
|
|
294
300
|
count: rows.length,
|
|
@@ -307,11 +313,9 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
307
313
|
});
|
|
308
314
|
const tail = lastReplyLength ?? rows.length;
|
|
309
315
|
/*
|
|
310
|
-
*
|
|
311
|
-
* that
|
|
312
|
-
*
|
|
313
|
-
* The scroller's own bottom is a fact rather than an estimate, and the total size changing is
|
|
314
|
-
* what says a row was measured again, so this runs for every one of those.
|
|
316
|
+
* Rows are measured as they render, so the end moves while it is drawn (a diff that highlights, an
|
|
317
|
+
* image that loads). `scrollToIndex` aims at where a row was when asked and fell short; the
|
|
318
|
+
* scroller's own bottom is a fact, and a new total size says a row was measured again.
|
|
315
319
|
*/
|
|
316
320
|
const totalSize = virtualizer.getTotalSize();
|
|
317
321
|
useLayoutEffect(() => {
|
|
@@ -361,8 +365,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
361
365
|
|
|
362
366
|
/*
|
|
363
367
|
* Where each message starts, from the virtualizer's measurements (an estimate for a row it never
|
|
364
|
-
* drew), so reading the scroll position costs no layout.
|
|
365
|
-
* every scroll already.
|
|
368
|
+
* drew), so reading the scroll position costs no layout.
|
|
366
369
|
*/
|
|
367
370
|
const measurements = virtualizer.measurementsCache;
|
|
368
371
|
const starts = ticks.map((tick) => measurements[tick.rowIndex]?.start ?? tick.rowIndex * ESTIMATED_ROW_PX);
|
|
@@ -418,8 +421,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
418
421
|
setSeeking({ itemId: tick.bookmark.itemId, key: keyOf(chatId) });
|
|
419
422
|
return;
|
|
420
423
|
}
|
|
421
|
-
followRef.
|
|
422
|
-
virtualizer.scrollToIndex(tick.rowIndex, { align: 'start' });
|
|
424
|
+
showRowAtTop(virtualizer, followRef, tick.rowIndex);
|
|
423
425
|
};
|
|
424
426
|
|
|
425
427
|
useLayoutEffect(() => {
|
|
@@ -439,8 +441,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
439
441
|
} else {
|
|
440
442
|
setSeeking(null);
|
|
441
443
|
if (jump.kind === 'row') {
|
|
442
|
-
followRef.
|
|
443
|
-
virtualizer.scrollToIndex(jump.index, { align: 'start' });
|
|
444
|
+
showRowAtTop(virtualizer, followRef, jump.index);
|
|
444
445
|
setLanding({ target: seeking.block, key: seeking.key });
|
|
445
446
|
}
|
|
446
447
|
}
|
|
@@ -449,8 +450,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
449
450
|
const index = rows.findIndex((row) => row.id === seeking.itemId);
|
|
450
451
|
if (index !== -1) {
|
|
451
452
|
setSeeking(null);
|
|
452
|
-
followRef
|
|
453
|
-
virtualizer.scrollToIndex(index, { align: 'start' });
|
|
453
|
+
showRowAtTop(virtualizer, followRef, index);
|
|
454
454
|
return;
|
|
455
455
|
}
|
|
456
456
|
const item = items?.[seeking.itemId];
|
|
@@ -505,8 +505,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
505
505
|
const found = changes !== -1 ? changes : rows.findIndex((row) => row.id === opening?.id || row.id === `start-${turnId}` || row.id === `fold-${turnId}`);
|
|
506
506
|
if (found !== -1) {
|
|
507
507
|
setRevealing(null);
|
|
508
|
-
followRef
|
|
509
|
-
virtualizer.scrollToIndex(found, { align: 'start' });
|
|
508
|
+
showRowAtTop(virtualizer, followRef, found);
|
|
510
509
|
useTimelineFlash.getState().flash(keyOf(chatId), rows[found]!.id, changes !== -1);
|
|
511
510
|
return;
|
|
512
511
|
}
|
|
@@ -564,8 +563,7 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
|
|
|
564
563
|
return;
|
|
565
564
|
}
|
|
566
565
|
subagents.add(found.branch.id);
|
|
567
|
-
followRef.
|
|
568
|
-
virtualizer.scrollToIndex(found.index, { align: 'start' });
|
|
566
|
+
showRowAtTop(virtualizer, followRef, found.index);
|
|
569
567
|
};
|
|
570
568
|
|
|
571
569
|
return (
|
|
@@ -11,11 +11,7 @@ import { useChatRow } from '../../state/chats';
|
|
|
11
11
|
import { openFileLink, useFileLinkCwd, useFileLinkScopeId } from './file-links';
|
|
12
12
|
import { ContextMenu, copyText, DisabledReason, EDIT_SHORTCUTS, Icon, Kbd, selectAllWithin } from '@adecore/ui';
|
|
13
13
|
|
|
14
|
-
/*
|
|
15
|
-
* The menu behind a right-click in a thread. Copy is the reason it exists. Everything in a thread
|
|
16
|
-
* is text a person may want out of it, and a whole message, a code block or the markdown an answer
|
|
17
|
-
* was written in are each a different amount of that.
|
|
18
|
-
*/
|
|
14
|
+
/* The menu behind a right-click in a thread, mostly for copying: a whole message, a code block or an answer's markdown. */
|
|
19
15
|
export function TimelineMenuPopup({
|
|
20
16
|
target,
|
|
21
17
|
thread,
|
|
@@ -88,10 +84,8 @@ export function TimelineMenuPopup({
|
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
/*
|
|
91
|
-
* Place a bookmark on a message, or rename and remove the one it has
|
|
92
|
-
*
|
|
93
|
-
* bookmark's line over the message and so has to be on screen; the strip scrolls there, the thread
|
|
94
|
-
* was clicked on it.
|
|
87
|
+
* Place a bookmark on a message, or rename and remove the one it has, from the thread and from the strip.
|
|
88
|
+
* `onName` runs when a row opens the name field, which sits over the message and so has to be on screen.
|
|
95
89
|
*/
|
|
96
90
|
export function BookmarkMenuItems({ chatId, itemId, onName }: { chatId: string; itemId: string; onName?: () => void }) {
|
|
97
91
|
const { t } = useTranslation('agent-chat');
|
|
@@ -7,9 +7,8 @@ import { useDiffTheme } from './diff-theme';
|
|
|
7
7
|
import { fullFileDiff, type DiffContents } from './full-diff';
|
|
8
8
|
|
|
9
9
|
/*
|
|
10
|
-
* A patch the CLI reported itself
|
|
11
|
-
*
|
|
12
|
-
* A diff without hunks at all is shown as its own lines, so nothing is ever swallowed.
|
|
10
|
+
* A patch the CLI reported itself. The renderer needs a file header, which a CLI that only sends hunks
|
|
11
|
+
* gets from the path we know; a diff without hunks is shown as its own lines, so nothing is swallowed.
|
|
13
12
|
*/
|
|
14
13
|
function asPatch(change: ChatFileChange): string | null {
|
|
15
14
|
if (!/^@@/m.test(change.diff)) {
|
|
@@ -23,8 +22,7 @@ function asPatch(change: ChatFileChange): string | null {
|
|
|
23
22
|
return `--- ${from}\n+++ ${to}\n${change.diff}`;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
/* Lets a diff with a view of its own run to
|
|
27
|
-
there instead of under its last line; the lines themselves keep their height. */
|
|
25
|
+
/* Lets a diff with a view of its own run to its bottom, so a short one's scrollbar sits there and not under its last line. */
|
|
28
26
|
const FILL_CSS = `
|
|
29
27
|
:host { display: flex; flex-direction: column; flex-grow: 1; }
|
|
30
28
|
pre { flex-grow: 1; align-content: start; }
|