@adecore/agents-react 0.21.1 → 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/agents/status-look.js +2 -3
- package/dist/chat/chat-client.d.ts +3 -2
- package/dist/chat/chat-client.js +4 -2
- package/dist/chat/drafts.d.ts +2 -1
- package/dist/chat/drafts.js +21 -17
- 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/picker-entries.d.ts +10 -0
- package/dist/chat/logic/picker-entries.js +19 -0
- package/dist/chat/logic/resume-compaction.js +2 -11
- package/dist/chat/logic/scrubber.js +9 -18
- package/dist/chat/logic/timeline.d.ts +5 -0
- package/dist/chat/logic/timeline.js +25 -11
- package/dist/chat/logic/tool-catalog.js +3 -4
- package/dist/chat/mentions.js +5 -6
- 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/runtime-modes.js +2 -4
- package/dist/chat/stash.d.ts +8 -2
- package/dist/chat/stash.js +22 -8
- package/dist/chat/subagent-list.d.ts +7 -1
- package/dist/chat/subagent-list.js +13 -1
- package/dist/chat/thumbnails.js +3 -4
- package/dist/chat/timeline-scroll.d.ts +6 -2
- package/dist/chat/timeline-scroll.js +45 -46
- package/dist/chat/ui/BookmarkSubmenu.js +1 -5
- package/dist/chat/ui/ChatActivity.js +22 -8
- package/dist/chat/ui/CodeBlock.js +2 -3
- package/dist/chat/ui/Composer.js +160 -88
- package/dist/chat/ui/ComposerInput.d.ts +3 -1
- package/dist/chat/ui/ComposerInput.js +13 -4
- package/dist/chat/ui/Markdown.js +11 -11
- package/dist/chat/ui/Pickers.js +31 -27
- package/dist/chat/ui/PromptComposer.d.ts +8 -1
- package/dist/chat/ui/PromptComposer.js +18 -6
- package/dist/chat/ui/QuoteButton.js +4 -5
- package/dist/chat/ui/RunSettings.js +8 -2
- package/dist/chat/ui/Scrubber.js +2 -3
- package/dist/chat/ui/SubagentControls.d.ts +4 -2
- package/dist/chat/ui/SubagentControls.js +13 -11
- 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 +39 -21
- 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/composer/menu-aria.d.ts +10 -0
- package/dist/chat/ui/composer/menu-aria.js +18 -0
- package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
- 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 +26 -8
- package/dist/chat/ui/rows/Rows.js +1 -1
- package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
- package/dist/chat/ui/rows/WorkRows.js +22 -17
- package/dist/chat/visual-viewport.d.ts +1 -1
- package/dist/chat/visual-viewport.js +9 -8
- 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/port-transport.js +4 -8
- package/dist/state/chats.d.ts +2 -1
- package/dist/state/chats.js +29 -15
- 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 +24 -4
- package/src/agents/model-requirement.ts +36 -0
- package/src/agents/status-look.ts +2 -3
- package/src/chat/chat-client.ts +7 -3
- package/src/chat/drafts.ts +33 -21
- 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/picker-entries.ts +25 -0
- package/src/chat/logic/resume-compaction.ts +2 -11
- package/src/chat/logic/scrubber.ts +13 -17
- package/src/chat/logic/timeline.ts +39 -13
- package/src/chat/logic/tool-catalog.ts +7 -6
- package/src/chat/mentions.ts +5 -6
- package/src/chat/prompt-hold.ts +27 -0
- package/src/chat/quote.ts +20 -0
- package/src/chat/runtime-modes.ts +2 -4
- package/src/chat/stash.ts +32 -8
- package/src/chat/subagent-list.ts +19 -5
- package/src/chat/thumbnails.ts +3 -4
- package/src/chat/timeline-scroll.ts +58 -53
- package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
- package/src/chat/ui/ChatActivity.tsx +35 -9
- package/src/chat/ui/CodeBlock.tsx +2 -3
- package/src/chat/ui/Composer.tsx +263 -138
- package/src/chat/ui/ComposerInput.tsx +18 -3
- package/src/chat/ui/Markdown.tsx +13 -17
- package/src/chat/ui/Pickers.tsx +56 -39
- package/src/chat/ui/PromptComposer.tsx +37 -8
- package/src/chat/ui/QuoteButton.tsx +5 -5
- package/src/chat/ui/RunSettings.tsx +9 -2
- package/src/chat/ui/Scrubber.tsx +2 -3
- package/src/chat/ui/SubagentControls.tsx +23 -22
- package/src/chat/ui/SubagentInfo.tsx +24 -42
- package/src/chat/ui/SubagentStopButton.tsx +2 -1
- package/src/chat/ui/SubagentTimeline.tsx +1 -2
- package/src/chat/ui/Timeline.tsx +44 -24
- 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/composer/menu-aria.ts +29 -0
- package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
- package/src/chat/ui/quote-selection.ts +6 -4
- 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 +47 -9
- package/src/chat/ui/rows/Rows.tsx +3 -1
- package/src/chat/ui/rows/WorkRows.tsx +101 -70
- package/src/chat/visual-viewport.ts +10 -8
- package/src/host.ts +4 -3
- package/src/locales/en/agent-chat.json +30 -7
- package/src/locales/nl/agent-chat.json +30 -7
- package/src/port-transport.ts +4 -8
- package/src/state/chats.ts +42 -16
- 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
|
@@ -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/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
|
@@ -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[];
|
|
@@ -43,11 +41,9 @@ interface ModelPickerProps {
|
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
/*
|
|
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.
|
|
44
|
+
* Which model answers: a search field over one group per provider, with the legacy models behind an
|
|
45
|
+
* expander. Before the first message the list carries every installed CLI, so picking another
|
|
46
|
+
* provider's model is also how a chat picks its provider; with one CLI the group headers drop.
|
|
51
47
|
*/
|
|
52
48
|
export function ModelPicker({
|
|
53
49
|
providers,
|
|
@@ -75,18 +71,13 @@ export function ModelPicker({
|
|
|
75
71
|
const grouped = providers.length > 1;
|
|
76
72
|
const trimmed = query.trim().toLowerCase();
|
|
77
73
|
|
|
78
|
-
const entries = useMemo(()
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (legacy.length === 0) {
|
|
86
|
-
return live;
|
|
87
|
-
}
|
|
88
|
-
return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
|
|
89
|
-
}, [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] : [])));
|
|
90
81
|
|
|
91
82
|
/* Closing forgets the search, so the next open (a click or `/model`) starts on the whole list. */
|
|
92
83
|
const setOpen = (next: boolean): void => {
|
|
@@ -112,6 +103,9 @@ export function ModelPicker({
|
|
|
112
103
|
inputRef.current?.focus();
|
|
113
104
|
return;
|
|
114
105
|
}
|
|
106
|
+
if (requirementOf(entry) !== null) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
115
109
|
onChange(entry.provider.kind, entry.model.slug);
|
|
116
110
|
setOpen(false);
|
|
117
111
|
};
|
|
@@ -186,6 +180,9 @@ export function ModelPicker({
|
|
|
186
180
|
}
|
|
187
181
|
const previous = entries[i - 1];
|
|
188
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;
|
|
189
186
|
const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
|
|
190
187
|
return (
|
|
191
188
|
<div key={`${entry.provider.kind}/${entry.model.slug}`}>
|
|
@@ -198,21 +195,43 @@ export function ModelPicker({
|
|
|
198
195
|
<button
|
|
199
196
|
role="option"
|
|
200
197
|
aria-selected={chosen}
|
|
198
|
+
aria-disabled={requirement !== null || undefined}
|
|
201
199
|
data-active={i === index}
|
|
202
|
-
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'}`}
|
|
203
201
|
onMouseEnter={() => setIndex(i)}
|
|
204
202
|
onClick={() => choose(entry)}
|
|
205
203
|
>
|
|
206
|
-
<
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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 })}
|
|
211
220
|
</span>
|
|
212
221
|
)}
|
|
213
|
-
<span className="grow" />
|
|
214
|
-
{entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
|
|
215
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
|
+
)}
|
|
216
235
|
</div>
|
|
217
236
|
);
|
|
218
237
|
})}
|
|
@@ -224,10 +243,8 @@ export function ModelPicker({
|
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
/*
|
|
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.
|
|
246
|
+
* Prompts put aside with Cmd+S, on a shelf the whole app shares, since a stashed prompt usually moves
|
|
247
|
+
* to another chat. A draft's files are named on the row but not kept: their bytes never go to storage.
|
|
231
248
|
*/
|
|
232
249
|
export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): void }) {
|
|
233
250
|
const { t } = useTranslation('agent-chat');
|
|
@@ -251,7 +268,7 @@ export function StashPicker({ onRestore }: { onRestore(prompt: StashedPrompt): v
|
|
|
251
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"
|
|
252
269
|
onClick={() => onRestore(prompt)}
|
|
253
270
|
>
|
|
254
|
-
<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>
|
|
255
272
|
{prompt.attachments.length > 0 && (
|
|
256
273
|
<span className="text-text-faint">{t('pickers.stash.files', { count: prompt.attachments.length })}</span>
|
|
257
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
|
}
|
|
@@ -21,9 +22,8 @@ function lastLineOf(range: Range): DOMRect | null {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/*
|
|
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.
|
|
25
|
+
* A button under a selection in an answer that hands it to the composer. It shows once the pointer is
|
|
26
|
+
* up: quoting on the selection alone grew the composer mid-drag and moved the thread under the pointer.
|
|
27
27
|
*/
|
|
28
28
|
export function QuoteButton({
|
|
29
29
|
thread,
|
|
@@ -53,7 +53,7 @@ export function QuoteButton({
|
|
|
53
53
|
const zoom = box.offsetWidth > 0 ? bounds.width / box.offsetWidth : 1;
|
|
54
54
|
const x = Math.min(Math.max((line.right - bounds.left) / zoom, EDGE_PX), box.offsetWidth - EDGE_PX);
|
|
55
55
|
const y = (line.bottom - bounds.top) / zoom + GAP_PX;
|
|
56
|
-
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) });
|
|
57
57
|
};
|
|
58
58
|
const press = (): void => {
|
|
59
59
|
pressed = true;
|
|
@@ -92,7 +92,7 @@ export function QuoteButton({
|
|
|
92
92
|
onMouseDown={(e) => e.preventDefault()}
|
|
93
93
|
onClick={() => {
|
|
94
94
|
document.getSelection()?.removeAllRanges();
|
|
95
|
-
taker.current?.(offer.quote);
|
|
95
|
+
taker.current?.(offer.quote, offer.itemId);
|
|
96
96
|
setOffer(null);
|
|
97
97
|
}}
|
|
98
98
|
>
|
|
@@ -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) {
|
|
@@ -23,31 +21,18 @@ export function SubagentTitleCrumb({ chatId, className, children }: { chatId: st
|
|
|
23
21
|
);
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
/*
|
|
27
|
-
|
|
28
|
-
* to the main agent. A bar that does not draw the title itself passes it, and it opens the way.
|
|
29
|
-
* Nothing while the main agent itself is on screen.
|
|
30
|
-
*/
|
|
31
|
-
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 }) {
|
|
32
26
|
const { t } = useTranslation('agent-chat');
|
|
33
27
|
const { trail, show } = useSubagentTrail(chatId);
|
|
34
|
-
const subagents = useOpenableSubagents(chatId);
|
|
35
28
|
if (trail.length === 0) {
|
|
36
29
|
return null;
|
|
37
30
|
}
|
|
38
|
-
const steps = breadcrumbOf(trail);
|
|
39
|
-
// Only a row of the chat's own thread can be stopped; a grandchild belongs to the sub-agent that opened it.
|
|
40
|
-
const shown = trail.length === 1 ? subagents.find((item) => item.toolUseId === trail[0]!.toolUseId) : undefined;
|
|
41
31
|
return (
|
|
42
|
-
<nav aria-label={t('subagents.title')} className={clsx('flex min-w-0 items-center gap-1
|
|
43
|
-
{
|
|
44
|
-
<button type="button" className={clsx(CRUMB_LINK, 'min-w-0 shrink')} onClick={() => show(MAIN_AGENT)}>
|
|
45
|
-
{title}
|
|
46
|
-
</button>
|
|
47
|
-
)}
|
|
48
|
-
{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) => (
|
|
49
34
|
<Fragment key={step.depth}>
|
|
50
|
-
<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" />}
|
|
51
36
|
{step.current ? (
|
|
52
37
|
<span aria-current="page" className="min-w-0 truncate text-text">
|
|
53
38
|
{step.label}
|
|
@@ -59,8 +44,24 @@ export function SubagentBreadcrumb({ chatId, title, className }: { chatId: strin
|
|
|
59
44
|
)}
|
|
60
45
|
</Fragment>
|
|
61
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">
|
|
62
63
|
{shown !== undefined && <SubagentStopButton chatId={chatId} item={shown} size="sm" />}
|
|
63
64
|
<IconButton icon={X} size="sm" label={t('subagents.backToMain')} onClick={() => show(MAIN_AGENT)} />
|
|
64
|
-
</
|
|
65
|
+
</div>
|
|
65
66
|
);
|
|
66
67
|
}
|