@adecore/agents-react 0.31.10 → 0.31.12
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/chat/logic/picker-entries.d.ts +7 -0
- package/dist/chat/logic/picker-entries.js +22 -4
- package/dist/chat/ui/ModelMarks.d.ts +4 -0
- package/dist/chat/ui/ModelMarks.js +9 -0
- package/dist/chat/ui/Pickers.js +3 -2
- package/dist/chat/ui/RunSettings.js +71 -4
- package/dist/chat/ui/rows/WorkRows.js +4 -2
- package/dist/locales/en/agent-chat.d.ts +2 -0
- package/dist/locales/en/agent-chat.js +2 -0
- package/dist/locales/nl/agent-chat.d.ts +2 -0
- package/dist/locales/nl/agent-chat.js +2 -0
- package/dist/prompts/ui/ApprovalBody.js +3 -1
- package/dist/state/chats.d.ts +1 -0
- package/dist/state/chats.js +4 -0
- package/package.json +8 -8
- package/src/chat/logic/picker-entries.ts +26 -4
- package/src/chat/ui/ModelMarks.tsx +25 -0
- package/src/chat/ui/Pickers.tsx +3 -6
- package/src/chat/ui/RunSettings.tsx +195 -66
- package/src/chat/ui/rows/WorkRows.tsx +10 -2
- package/src/locales/en/agent-chat.ts +2 -0
- package/src/locales/nl/agent-chat.ts +2 -0
- package/src/prompts/ui/ApprovalBody.tsx +3 -1
- package/src/state/chats.ts +5 -0
|
@@ -17,4 +17,11 @@ export interface PickerCurrent {
|
|
|
17
17
|
provider: AgentKind;
|
|
18
18
|
model: string;
|
|
19
19
|
}
|
|
20
|
+
export declare function modelsByVendor(models: ModelInfo[]): ModelInfo[];
|
|
21
|
+
export declare function shortlistOf(kind: AgentKind, models: ModelInfo[], current: PickerCurrent | null): ModelInfo[] | null;
|
|
22
|
+
export declare function wantsModelSearch(providers: readonly ProviderInfo[]): boolean;
|
|
23
|
+
export declare function searchModels(providers: readonly ProviderInfo[], query: string): Array<{
|
|
24
|
+
entry: ProviderInfo;
|
|
25
|
+
models: ModelInfo[];
|
|
26
|
+
}>;
|
|
20
27
|
export declare function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean, expanded?: ReadonlySet<AgentKind>, current?: PickerCurrent | null): PickerEntry[];
|
|
@@ -10,7 +10,7 @@ function matches(model, query) {
|
|
|
10
10
|
* By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
|
|
11
11
|
* it came in. A model with no maker goes last, so the ones that have one read as groups.
|
|
12
12
|
*/
|
|
13
|
-
function
|
|
13
|
+
export function modelsByVendor(models) {
|
|
14
14
|
if (!models.some((model) => model.vendor !== undefined)) {
|
|
15
15
|
return models;
|
|
16
16
|
}
|
|
@@ -22,6 +22,24 @@ function byVendor(models) {
|
|
|
22
22
|
a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }));
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
+
/* The models a long list shows before it is opened: the default and the one that is picked. Null when the list is short enough to show whole. */
|
|
26
|
+
export function shortlistOf(kind, models, current) {
|
|
27
|
+
if (models.length <= COMPACT_MODEL_LIMIT) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return modelsByVendor(models.filter((model) => model.isDefault || (current?.provider === kind && current.model === model.slug)));
|
|
31
|
+
}
|
|
32
|
+
/* Whether the providers offer enough models together that a search is quicker than reading the list. */
|
|
33
|
+
export function wantsModelSearch(providers) {
|
|
34
|
+
return providers.reduce((count, entry) => count + entry.models.length, 0) > COMPACT_MODEL_LIMIT;
|
|
35
|
+
}
|
|
36
|
+
/* Every model of every provider that a search finds, legacy ones included, by maker; a provider with none is left out. */
|
|
37
|
+
export function searchModels(providers, query) {
|
|
38
|
+
const needle = query.trim().toLowerCase();
|
|
39
|
+
return providers
|
|
40
|
+
.map((entry) => ({ entry, models: modelsByVendor(entry.models.filter((model) => matches(model, needle))) }))
|
|
41
|
+
.filter((group) => group.models.length > 0);
|
|
42
|
+
}
|
|
25
43
|
const NONE = new Set();
|
|
26
44
|
/*
|
|
27
45
|
* The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
|
|
@@ -30,20 +48,20 @@ const NONE = new Set();
|
|
|
30
48
|
* that opens the rest, grouped by maker.
|
|
31
49
|
*/
|
|
32
50
|
export function pickerEntriesOf(providers, query, legacyOpen, expanded = NONE, current = null) {
|
|
33
|
-
const rowsOf = (entry, models) =>
|
|
51
|
+
const rowsOf = (entry, models) => modelsByVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
|
|
34
52
|
if (query !== '') {
|
|
35
53
|
return providers.flatMap((entry) => rowsOf(entry, entry.models.filter((model) => matches(model, query))));
|
|
36
54
|
}
|
|
37
55
|
const legacy = providers.flatMap((entry) => entry.models.filter((model) => model.legacy).map((model) => ({ kind: 'model', provider: entry, model })));
|
|
38
56
|
const live = providers.flatMap((entry) => {
|
|
39
57
|
const models = entry.models.filter((model) => !model.legacy);
|
|
40
|
-
|
|
58
|
+
const shortlist = shortlistOf(entry.kind, models, current);
|
|
59
|
+
if (shortlist === null) {
|
|
41
60
|
return rowsOf(entry, models);
|
|
42
61
|
}
|
|
43
62
|
if (expanded.has(entry.kind)) {
|
|
44
63
|
return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
|
|
45
64
|
}
|
|
46
|
-
const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
|
|
47
65
|
return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
|
|
48
66
|
});
|
|
49
67
|
if (legacy.length === 0) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { Wrench } from 'lucide-react';
|
|
4
|
+
import { Icon, Pill, Tooltip } from '@adecore/ui';
|
|
5
|
+
/* What a model row says beside the name: its badge, and a mark when the provider says the model can call tools. */
|
|
6
|
+
export function ModelMarks({ model }) {
|
|
7
|
+
const { t } = useTranslation('agent-chat');
|
|
8
|
+
return (_jsxs(_Fragment, { children: [model.badge && (_jsx(Pill, { tone: "accent", shape: "tag", size: "sm", children: model.badge.charAt(0).toLocaleUpperCase() + model.badge.slice(1) })), model.tools === true && (_jsx(Tooltip, { label: t('pickers.model.tools'), children: _jsx("span", { className: "flex shrink-0 text-text-faint", "aria-label": t('pickers.model.tools'), children: _jsx(Icon, { icon: Wrench, size: 12 }) }) }))] }));
|
|
9
|
+
}
|
package/dist/chat/ui/Pickers.js
CHANGED
|
@@ -9,9 +9,10 @@ import { modelRequirementOf } from '../../agents/model-requirement.js';
|
|
|
9
9
|
import { useChatHost } from '../../host.js';
|
|
10
10
|
import { ChatScopeContext } from '../../scope.js';
|
|
11
11
|
import { useReportedModels } from '../../state/chats.js';
|
|
12
|
+
import { ModelMarks } from './ModelMarks.js';
|
|
12
13
|
import { pickerEntriesOf } from '../logic/picker-entries.js';
|
|
13
14
|
import { forgetStashed, STASH_SHORTCUT, useStash } from '../stash.js';
|
|
14
|
-
import { Icon, IconButton, Menu,
|
|
15
|
+
import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
|
|
15
16
|
const triggerClass = 'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
|
|
16
17
|
const chipClass = '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';
|
|
17
18
|
/*
|
|
@@ -122,7 +123,7 @@ export function ModelPicker({ providers, provider, selection, open, onOpenChange
|
|
|
122
123
|
const next = entries[i + 1];
|
|
123
124
|
const lastOfGroup = next === undefined || next.kind !== 'model' || next.provider.kind !== entry.provider.kind;
|
|
124
125
|
const chosen = entry.provider.kind === provider && entry.model.slug === selection.model;
|
|
125
|
-
return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), vendorFirst && _jsx(Menu.Label, { children: entry.model.vendor }), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, 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'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }),
|
|
126
|
+
return (_jsxs("div", { children: [first && (_jsxs(Menu.Label, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.provider.kind, size: 12 }), entry.provider.name] })), vendorFirst && _jsx(Menu.Label, { children: entry.model.vendor }), _jsxs("button", { role: "option", "aria-selected": chosen, "aria-disabled": requirement !== null || undefined, "data-active": i === index, 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'}`, onMouseEnter: () => setIndex(i), onClick: () => choose(entry), children: [_jsxs("span", { className: "flex w-full items-center gap-2", children: [_jsx(Menu.Check, { kind: "radio", checked: chosen }), _jsx("span", { className: "min-w-0 truncate", children: entry.model.name }), _jsx(ModelMarks, { model: entry.model }), _jsx("span", { className: "grow" }), entry.model.legacy && _jsx("span", { className: "text-xs text-text-faint", children: t('pickers.model.legacy') })] }), requirement !== null && (_jsx("span", { className: "pl-6 text-xs text-text-faint", children: machine === null
|
|
126
127
|
? t('pickers.model.requiresHere', { cli: requirement.cli, version: requirement.minVersion })
|
|
127
128
|
: t('pickers.model.requires', { cli: requirement.cli, version: requirement.minVersion, machine }) }))] }), lastOfGroup && blockedKinds.has(entry.provider.kind) && (_jsx("button", { type: "button", className: "cursor-row flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-xs text-text-muted", onClick: () => {
|
|
128
129
|
setOpen(false);
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, useEffect, useRef, useState } from 'react';
|
|
2
3
|
import { useTranslation } from 'react-i18next';
|
|
3
4
|
import clsx from 'clsx';
|
|
4
|
-
import { Activity, TriangleAlert, ChevronDown, ChevronRight, ChevronUp, FilePen, Gauge, Hand, Settings, Shield, ShieldAlert, SlidersHorizontal } from 'lucide-react';
|
|
5
|
+
import { Activity, TriangleAlert, ChevronDown, ChevronRight, ChevronUp, FilePen, Gauge, Hand, Search, Settings, Shield, ShieldAlert, SlidersHorizontal } from 'lucide-react';
|
|
5
6
|
import { AccountDot } from '../../agents/AccountDot.js';
|
|
6
7
|
import { limitsOfAccount, sessionWindow } from '../../agents/account-limits.js';
|
|
7
8
|
import { canContinueOn } from '../../agents/accounts.js';
|
|
8
9
|
import { AgentIcon } from '../../agents/AgentIcon.js';
|
|
9
10
|
import { modelName, shortModelName } from '../../agents/model-name.js';
|
|
11
|
+
import { ModelMarks } from './ModelMarks.js';
|
|
12
|
+
import { modelsByVendor, searchModels, shortlistOf, wantsModelSearch } from '../logic/picker-entries.js';
|
|
10
13
|
import { CONTEXT_OPTION, contextFraction, contextSegments, orderOptions } from '../logic/context-usage.js';
|
|
11
14
|
import { RUNTIME_MODES } from '../runtime-modes.js';
|
|
12
15
|
import { formatClock, formatWeekdayClock, isSameDay, formatPercent, formatTokens } from '@adecore/ui/format';
|
|
13
|
-
import { Icon, Menu,
|
|
16
|
+
import { Icon, Menu, Tooltip, useNow } from '@adecore/ui';
|
|
14
17
|
import { useChatHost } from '../../host.js';
|
|
15
18
|
import { useUsageLimits } from '../../usage/limits.js';
|
|
16
19
|
const MINUTE_MS = 60_000;
|
|
@@ -59,6 +62,8 @@ function modelGroupsOf(providers, legacy) {
|
|
|
59
62
|
*/
|
|
60
63
|
export function RunSettings({ disabled = false, providers, provider, selection, model, runtimeMode, effectiveRuntimeMode, permissionMode, open, onOpenChange, onModel, onOption, onMode, usage, compactDisabled, onCompact, account }) {
|
|
61
64
|
const { t } = useTranslation('agent-chat');
|
|
65
|
+
const [query, setQuery] = useState('');
|
|
66
|
+
const searchRef = useRef(null);
|
|
62
67
|
const owner = providers.find((entry) => entry.kind === provider);
|
|
63
68
|
const current = modelName(selection.model, owner?.models);
|
|
64
69
|
const short = shortModelName(current, owner?.models);
|
|
@@ -87,8 +92,70 @@ export function RunSettings({ disabled = false, providers, provider, selection,
|
|
|
87
92
|
const slash = value.indexOf('/');
|
|
88
93
|
onModel(value.slice(0, slash), value.slice(slash + 1));
|
|
89
94
|
};
|
|
90
|
-
const
|
|
91
|
-
|
|
95
|
+
const searchable = wantsModelSearch(providers);
|
|
96
|
+
const found = query.trim() === '' ? null : searchModels(providers, query);
|
|
97
|
+
// The menu moves the focus to its popup as it opens, so the search field takes it a frame later.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!open || !searchable) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const frame = requestAnimationFrame(() => searchRef.current?.focus());
|
|
103
|
+
return () => cancelAnimationFrame(frame);
|
|
104
|
+
}, [open, searchable]);
|
|
105
|
+
const changeOpen = (next) => {
|
|
106
|
+
if (!next) {
|
|
107
|
+
setQuery('');
|
|
108
|
+
}
|
|
109
|
+
onOpenChange(next);
|
|
110
|
+
};
|
|
111
|
+
// An item the arrow keys moved to has the focus; typing there still goes to the search.
|
|
112
|
+
const onPopupKey = (event) => {
|
|
113
|
+
if (!searchable || event.target === searchRef.current || event.metaKey || event.ctrlKey || event.altKey) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const typed = event.key.length === 1 && event.key !== ' ';
|
|
117
|
+
if (!typed && event.key !== 'Backspace') {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
event.stopPropagation();
|
|
122
|
+
setQuery((now) => (typed ? now + event.key : now.slice(0, -1)));
|
|
123
|
+
searchRef.current?.focus();
|
|
124
|
+
};
|
|
125
|
+
// The menu reads letters as a jump to an item and arrows as moving through them; the field keeps what it types.
|
|
126
|
+
const onSearchKey = (event) => {
|
|
127
|
+
if (event.key === 'Escape' && query !== '') {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
setQuery('');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (event.key === 'Enter') {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
const first = found?.[0];
|
|
136
|
+
if (first !== undefined) {
|
|
137
|
+
onModel(first.entry.kind, first.models[0].slug);
|
|
138
|
+
changeOpen(false);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (event.key !== 'Escape' && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Tab') {
|
|
143
|
+
event.stopPropagation();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const modelRow = (entry, row) => (_jsxs(Menu.RadioItem, { value: modelValue(entry.kind, row.slug), closeOnClick: true, children: [_jsx(AgentIcon, { kind: entry.kind, size: 14, className: "shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: row.name }), _jsx(ModelMarks, { model: row })] }, row.slug));
|
|
147
|
+
// Every model of one provider, under a label per maker where the provider names them.
|
|
148
|
+
const byMaker = (entry, models) => modelsByVendor(models).map((row, i, sorted) => (_jsxs(Fragment, { children: [row.vendor !== undefined && sorted[i - 1]?.vendor !== row.vendor && _jsx(Menu.GroupLabel, { children: row.vendor }), modelRow(entry, row)] }, row.slug)));
|
|
149
|
+
// A provider with many models shows the default and the chosen one, with all of them one submenu away.
|
|
150
|
+
const providerModels = (entry, models) => {
|
|
151
|
+
const shortlist = shortlistOf(entry.kind, models, { provider, model: selection.model });
|
|
152
|
+
if (shortlist === null) {
|
|
153
|
+
return models.map((row) => modelRow(entry, row));
|
|
154
|
+
}
|
|
155
|
+
return (_jsxs(_Fragment, { children: [shortlist.map((row) => modelRow(entry, row)), _jsxs(Menu.SubmenuRoot, { children: [_jsx(Menu.SubmenuTrigger, { className: "text-text-muted", children: t('pickers.model.allGroup', { count: models.length }) }), _jsx(Menu.Popup, { className: "w-80", children: _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: byMaker(entry, models) }) })] })] }));
|
|
156
|
+
};
|
|
157
|
+
const modelGroups = (groups) => groups.map(({ entry, models }) => (_jsxs(Menu.Group, { children: [grouped && (_jsxs(Menu.GroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.kind, size: 12 }), entry.name] })), providerModels(entry, models)] }, entry.kind)));
|
|
158
|
+
return (_jsxs(Menu.Root, { open: open, onOpenChange: changeOpen, highlightItemOnHover: !searchable, children: [_jsx(Tooltip, { label: permissionHint ?? t('pickers.settings.title'), kbd: "/model", children: _jsxs(Menu.Trigger, { disabled: disabled, className: clsx('flex h-8 min-w-0 items-center gap-2 rounded-full border pr-3 pl-2.5 text-xs whitespace-nowrap text-text-muted @max-xl/composer:gap-1.5 @max-sm/composer:pr-2.5', open ? 'border-accent bg-accent-soft' : 'border-border hover:bg-surface-hover'), children: [_jsx(AgentIcon, { kind: provider, size: 14, className: "shrink-0" }), _jsxs("span", { className: "flex min-w-0 items-center gap-2 overflow-hidden @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "truncate font-medium text-text @max-xl/composer:hidden", children: current }), _jsx("span", { className: "hidden truncate font-medium text-text @max-xl/composer:inline", children: short }), account !== null && (_jsxs("span", { className: "flex min-w-0 items-center gap-2 @max-xl/composer:gap-1.5", children: [_jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsx(AccountDot, { color: account.choice.current?.account.color }), _jsx("span", { className: "truncate @max-xl/composer:hidden", children: account.choice.current === null ? account.choice.currentId : account.choice.nameOf(account.choice.current) })] })), summary.map((entry) => (_jsxs("span", { className: clsx('flex shrink-0 items-center gap-2 @max-xl/composer:gap-1.5', !entry.ring && '@max-xl/composer:hidden'), children: [_jsx("span", { className: "text-text-faint", children: "\u00B7" }), entry.ring && _jsx(ContextRing, { usage: usage }), _jsx("span", { className: clsx(entry.ring && '@max-sm/composer:hidden'), children: entry.label })] }, entry.id))), _jsx("span", { className: "shrink-0 text-text-faint", children: "\u00B7" }), _jsxs("span", { className: clsx('flex shrink-0 items-center gap-1', fullAccess && 'text-status-needs-you'), children: [_jsx(Icon, { icon: MODE_ICONS[shownMode], size: 12, className: "shrink-0" }), _jsx("span", { children: effectiveRuntimeMode === undefined && permissionMode !== undefined ? permissionMode : t(`modes.${shownMode}.short`) }), permissionFallback && _jsx(Icon, { icon: TriangleAlert, size: 12, className: "shrink-0 text-status-needs-you" })] })] }), _jsx(Icon, { icon: open ? ChevronUp : ChevronDown, size: 12, className: "shrink-0 @max-sm/composer:hidden" })] }) }), _jsxs(Menu.Popup, { side: "top", sideOffset: 8, align: "start", className: "w-80", onKeyDownCapture: onPopupKey, children: [providers.length === 0 && _jsx("div", { className: "px-2.5 py-3 text-xs text-text-faint", children: t('pickers.model.noProvider') }), searchable && (_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-2.5", children: [_jsx(Icon, { icon: Search, size: 14, className: "shrink-0 text-text-faint" }), _jsx("input", { ref: searchRef, className: "h-8 w-full bg-transparent text-sm text-text outline-none placeholder:text-text-faint", placeholder: t('pickers.model.search'), "aria-label": t('pickers.model.search'), spellCheck: false, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: onSearchKey })] })), found !== null && found.length === 0 && _jsx("div", { className: "px-2.5 py-3 text-xs text-text-faint", children: t('pickers.model.noMatch') }), found !== null && found.length > 0 && (_jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: found.map(({ entry, models }) => (_jsxs(Menu.Group, { children: [grouped && (_jsxs(Menu.GroupLabel, { className: "flex items-center gap-1.5", children: [_jsx(AgentIcon, { kind: entry.kind, size: 12 }), entry.name] })), byMaker(entry, models)] }, entry.kind))) })), found === null && (_jsxs(_Fragment, { children: [_jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(live) }), legacy.length > 0 && (_jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) }) }), chosenLegacy && _jsx(Menu.Hint, { className: "truncate", children: chosenLegacy.name })] }), _jsx(Menu.Popup, { className: "min-w-56", children: _jsx(Menu.RadioGroup, { value: chosenModel, onValueChange: chooseModel, children: modelGroups(legacy) }) })] })), _jsx(Menu.Separator, {}), account !== null && _jsx(AccountSubmenu, { provider: provider, account: account }), _jsxs(Menu.SubmenuRoot, { children: [_jsxs(Menu.SubmenuTrigger, { className: "text-text-muted", children: [_jsx(Icon, { icon: MODE_ICONS[runtimeMode], size: 14, className: clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint') }), _jsx("span", { className: "min-w-0 grow truncate", children: t('pickers.mode.label') }), _jsx(Menu.Hint, { className: "truncate", children: t(`modes.${runtimeMode}.label`) })] }), _jsxs(Menu.Popup, { className: "w-72", children: [permissionHint !== null && _jsx("p", { className: "px-2.5 py-2 text-xs text-text-muted", children: permissionHint }), _jsx(Menu.RadioGroup, { value: runtimeMode, onValueChange: (mode) => onMode(mode), children: RUNTIME_MODES.map((mode) => (_jsxs(Menu.RadioItem, { value: mode, className: "items-start", children: [_jsx("span", { className: "grid h-lh w-4 shrink-0 place-items-center", children: _jsx(Icon, { icon: MODE_ICONS[mode], size: 14, className: mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint' }) }), _jsxs("span", { className: "flex min-w-0 grow flex-col", children: [t(`modes.${mode}.label`), _jsx("span", { className: "text-xs text-text-faint", children: t(`modes.${mode}.hint`) })] })] }, mode))) })] })] }), options.map((option) => (_jsx(OptionRow, { option: option, selection: selection, onChange: (value) => onOption(option.id, value) }, option.id))), (hasContextOption || standaloneContext) && (_jsxs(_Fragment, { children: [_jsx(Menu.Separator, {}), _jsx(ContextUsage, { usage: usage, disabled: compactDisabled, onCompact: onCompact })] }))] }))] })] }));
|
|
92
159
|
}
|
|
93
160
|
/* Red where a window is nearly spent, amber where it is worth knowing, as the usage bars have it. */
|
|
94
161
|
function sessionTone(used) {
|
|
@@ -11,7 +11,7 @@ import { useChatHost } from '../../../host.js';
|
|
|
11
11
|
import { lazyNamed } from '../../../lazy.js';
|
|
12
12
|
import { useChatActions } from '../../actions.js';
|
|
13
13
|
import { ChatScopeContext, useChatScope } from '../../../scope.js';
|
|
14
|
-
import { useCurrentItem } from '../../../state/chats.js';
|
|
14
|
+
import { useAwaitsApproval, useCurrentItem } from '../../../state/chats.js';
|
|
15
15
|
import { useTimelineFlash } from '../../timeline-flash.js';
|
|
16
16
|
import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
|
|
17
17
|
import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
|
|
@@ -147,7 +147,9 @@ export function WorkLiveRow({ chatId, tool: derived }) {
|
|
|
147
147
|
const shell = shellCommand(tool.name, tool.input);
|
|
148
148
|
const { commandLooks } = useChatHost();
|
|
149
149
|
const run = shell === null ? null : matchCommandLook(shell, commandLooks);
|
|
150
|
-
|
|
150
|
+
const awaiting = useAwaitsApproval(chatId, tool.toolUseId);
|
|
151
|
+
const { t } = useTranslation('agent-chat');
|
|
152
|
+
return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: run === null ? toolIcon(tool.name) : _jsx(run.look.Mark, { size: 12 }), label: run === null ? tool.name : run.match.label, brand: run?.look.brand, look: run === null ? undefined : { run, chatId }, detail: summary || tool.progress?.description || '', command: summary !== '' && summary === shellCommand(tool.name, tool.input), path: summary !== '' && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, trailing: awaiting ? (_jsx("span", { className: "shrink-0 text-xs text-text-faint", children: t('work.awaitingApproval') })) : (_jsx(RunningFor, { startedAt: toolStartedAt(tool) })), live: true }), open && opens && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
|
|
151
153
|
}
|
|
152
154
|
/* A run of tool calls as one line ("Read 4 files"); opening it lists every call. A run of one look wears its mark and its brand. */
|
|
153
155
|
export function WorkGroupRow({ tools, summary, look, expanded, onToggle }) {
|
|
@@ -183,6 +183,7 @@ declare const _default: {
|
|
|
183
183
|
loadingDiff: string;
|
|
184
184
|
diffUnavailable: string;
|
|
185
185
|
runningFor: string;
|
|
186
|
+
awaitingApproval: string;
|
|
186
187
|
forksAfter_one: string;
|
|
187
188
|
forksAfter_other: string;
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ declare const _default: {
|
|
|
439
440
|
legacyGroup_one: string;
|
|
440
441
|
legacyGroup_other: string;
|
|
441
442
|
legacy: string;
|
|
443
|
+
tools: string;
|
|
442
444
|
requires: string;
|
|
443
445
|
requiresHere: string;
|
|
444
446
|
providerSettings: string;
|
|
@@ -183,6 +183,7 @@ export default {
|
|
|
183
183
|
loadingDiff: 'Loading diff',
|
|
184
184
|
diffUnavailable: 'Could not load this diff',
|
|
185
185
|
runningFor: 'running for {{elapsed}}',
|
|
186
|
+
awaitingApproval: 'waiting for you',
|
|
186
187
|
forksAfter_one: 'A fork goes on after this turn',
|
|
187
188
|
forksAfter_other: '{{count}} forks go on after this turn',
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ export default {
|
|
|
439
440
|
legacyGroup_one: 'Legacy models ({{count}})',
|
|
440
441
|
legacyGroup_other: 'Legacy models ({{count}})',
|
|
441
442
|
legacy: 'Legacy',
|
|
443
|
+
tools: 'Can use tools',
|
|
442
444
|
requires: 'Requires {{cli}} {{version}} or later on {{machine}}',
|
|
443
445
|
requiresHere: 'Requires {{cli}} {{version}} or later',
|
|
444
446
|
providerSettings: 'Provider settings'
|
|
@@ -183,6 +183,7 @@ declare const _default: {
|
|
|
183
183
|
loadingDiff: string;
|
|
184
184
|
diffUnavailable: string;
|
|
185
185
|
runningFor: string;
|
|
186
|
+
awaitingApproval: string;
|
|
186
187
|
forksAfter_one: string;
|
|
187
188
|
forksAfter_other: string;
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ declare const _default: {
|
|
|
439
440
|
legacyGroup_one: string;
|
|
440
441
|
legacyGroup_other: string;
|
|
441
442
|
legacy: string;
|
|
443
|
+
tools: string;
|
|
442
444
|
requires: string;
|
|
443
445
|
requiresHere: string;
|
|
444
446
|
providerSettings: string;
|
|
@@ -183,6 +183,7 @@ export default {
|
|
|
183
183
|
loadingDiff: 'Diff laden',
|
|
184
184
|
diffUnavailable: 'Deze diff kon niet worden geladen',
|
|
185
185
|
runningFor: 'loopt al {{elapsed}}',
|
|
186
|
+
awaitingApproval: 'wacht op jou',
|
|
186
187
|
forksAfter_one: 'Een fork gaat verder na deze turn',
|
|
187
188
|
forksAfter_other: '{{count}} forks gaan verder na deze turn',
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ export default {
|
|
|
439
440
|
legacyGroup_one: 'Legacy-modellen ({{count}})',
|
|
440
441
|
legacyGroup_other: 'Legacy-modellen ({{count}})',
|
|
441
442
|
legacy: 'Legacy',
|
|
443
|
+
tools: 'Kan tools gebruiken',
|
|
442
444
|
requires: 'Vereist {{cli}} {{version}} of nieuwer op {{machine}}',
|
|
443
445
|
requiresHere: 'Vereist {{cli}} {{version}} of nieuwer',
|
|
444
446
|
providerSettings: 'Providerinstellingen'
|
|
@@ -22,7 +22,9 @@ function ApprovalDetails({ item }) {
|
|
|
22
22
|
const editorPreview = host.code.editor !== undefined;
|
|
23
23
|
const maxHeight = expanded ? 480 : 176;
|
|
24
24
|
const input = typeof item.input === 'object' && item.input !== null ? item.input : {};
|
|
25
|
-
|
|
25
|
+
// Some agents describe a command by the command itself, which the box under it already shows.
|
|
26
|
+
const describes = item.description && item.description.trim() !== (typeof input.command === 'string' ? input.command.trim() : null);
|
|
27
|
+
return (_jsxs(_Fragment, { children: [describes && _jsx("p", { className: "text-sm text-text-muted", children: item.description }), patches.length + edits.length > 1 && (_jsx("p", { className: "text-xs text-text-muted", children: t('approval.allChanges', { count: patches.length + edits.length }) })), patches.length + edits.length > 0 ? (_jsxs("div", { className: "overflow-hidden rounded-xl bg-surface-sunken", children: [_jsx("div", { className: clsx(editorPreview ? 'overflow-hidden' : 'overflow-auto', !editorPreview && !expanded && 'max-h-44'), children: _jsx(ApprovalReview, { entries: entries, maxHeight: maxHeight }, item.requestId) }), _jsx("div", { className: "flex items-center border-t border-border p-1", children: _jsx(Button, { size: "sm", "aria-expanded": expanded, onClick: () => setExpanded(!expanded), children: expanded ? t('approval.collapseDiff') : t('approval.viewDiff') }) })] })) : typeof input.command === 'string' ? (_jsx(CommandBox, { command: input.command, cwd: typeof input.cwd === 'string' ? input.cwd : undefined })) : (_jsxs("details", { children: [_jsx("summary", { className: "flex h-7 cursor-pointer items-center text-xs text-text-muted", children: t('approval.details') }), _jsx(TextMenu, { children: _jsx("pre", { className: "max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-surface-sunken p-3 font-mono text-code text-text select-text", children: JSON.stringify(item.input, null, 2) }) })] }))] }));
|
|
26
28
|
}
|
|
27
29
|
/* A chat's permission request: the diff, the command or the raw input, and the reason a Deny may carry. */
|
|
28
30
|
export function ApprovalBody({ item, draft, onDraft, denyReason, locked }) {
|
package/dist/state/chats.d.ts
CHANGED
|
@@ -46,5 +46,6 @@ export declare const useChats: import("zustand").UseBoundStore<import("zustand")
|
|
|
46
46
|
export declare function chatSink(keyOf: (chatId: string) => string): ChatSink;
|
|
47
47
|
export declare function useChatRow<T>(chatId: string, select: (row: ChatState | undefined) => T): T;
|
|
48
48
|
export declare function useReportedModels(provider: AgentKind): ReadonlySet<string>;
|
|
49
|
+
export declare function useAwaitsApproval(chatId: string, toolUseId: string): boolean;
|
|
49
50
|
export declare function useCurrentItem<T extends ChatItem>(chatId: string, derived: T): T;
|
|
50
51
|
export {};
|
package/dist/state/chats.js
CHANGED
|
@@ -243,6 +243,10 @@ export function useReportedModels(provider) {
|
|
|
243
243
|
});
|
|
244
244
|
return useMemo(() => new Set(joined === '' ? [] : joined.split('\n')), [joined]);
|
|
245
245
|
}
|
|
246
|
+
/* Whether a call still waits on the person's answer to an approval of it, when its clock is not running yet. */
|
|
247
|
+
export function useAwaitsApproval(chatId, toolUseId) {
|
|
248
|
+
return useChatRow(chatId, (row) => waitingRequestsOf(row).some((request) => request.kind === 'approval' && request.toolUseId === toolUseId));
|
|
249
|
+
}
|
|
246
250
|
/* The item as the thread holds it now, for a row derived from the structure, which a delta leaves alone. */
|
|
247
251
|
export function useCurrentItem(chatId, derived) {
|
|
248
252
|
return useChatRow(chatId, (row) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/agents-react",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.12",
|
|
4
4
|
"description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
"test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@adecore/agent-contracts": "0.31.
|
|
71
|
-
"@adecore/editor": "0.31.
|
|
72
|
-
"@adecore/editor-react": "0.31.
|
|
73
|
-
"@adecore/intelligent-ui": "0.31.
|
|
74
|
-
"@adecore/merge": "0.31.
|
|
70
|
+
"@adecore/agent-contracts": "0.31.12",
|
|
71
|
+
"@adecore/editor": "0.31.12",
|
|
72
|
+
"@adecore/editor-react": "0.31.12",
|
|
73
|
+
"@adecore/intelligent-ui": "0.31.12",
|
|
74
|
+
"@adecore/merge": "0.31.12",
|
|
75
75
|
"@codemirror/commands": "^6.11.1",
|
|
76
76
|
"@codemirror/language": "^6.12.4",
|
|
77
77
|
"@codemirror/state": "^6.7.6",
|
|
@@ -94,14 +94,14 @@
|
|
|
94
94
|
"zustand": "^5.0.15"
|
|
95
95
|
},
|
|
96
96
|
"peerDependencies": {
|
|
97
|
-
"@adecore/ui": "^0.31.
|
|
97
|
+
"@adecore/ui": "^0.31.12",
|
|
98
98
|
"i18next": "^26.4.2",
|
|
99
99
|
"react": "^19.3.0",
|
|
100
100
|
"react-dom": "^19.3.0",
|
|
101
101
|
"react-i18next": "^17.0.15"
|
|
102
102
|
},
|
|
103
103
|
"devDependencies": {
|
|
104
|
-
"@adecore/ui": "0.31.
|
|
104
|
+
"@adecore/ui": "0.31.12",
|
|
105
105
|
"@types/bun": "^1.4.2",
|
|
106
106
|
"@types/react": "^19.3.0",
|
|
107
107
|
"@types/react-dom": "^19.3.0",
|
|
@@ -25,7 +25,7 @@ function matches(model: ModelInfo, query: string): boolean {
|
|
|
25
25
|
* By maker, then by name, when the provider says who made its models; a list with no makers keeps the order
|
|
26
26
|
* it came in. A model with no maker goes last, so the ones that have one read as groups.
|
|
27
27
|
*/
|
|
28
|
-
function
|
|
28
|
+
export function modelsByVendor(models: ModelInfo[]): ModelInfo[] {
|
|
29
29
|
if (!models.some((model) => model.vendor !== undefined)) {
|
|
30
30
|
return models;
|
|
31
31
|
}
|
|
@@ -40,6 +40,27 @@ function byVendor(models: ModelInfo[]): ModelInfo[] {
|
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/* The models a long list shows before it is opened: the default and the one that is picked. Null when the list is short enough to show whole. */
|
|
44
|
+
export function shortlistOf(kind: AgentKind, models: ModelInfo[], current: PickerCurrent | null): ModelInfo[] | null {
|
|
45
|
+
if (models.length <= COMPACT_MODEL_LIMIT) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return modelsByVendor(models.filter((model) => model.isDefault || (current?.provider === kind && current.model === model.slug)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* Whether the providers offer enough models together that a search is quicker than reading the list. */
|
|
52
|
+
export function wantsModelSearch(providers: readonly ProviderInfo[]): boolean {
|
|
53
|
+
return providers.reduce((count, entry) => count + entry.models.length, 0) > COMPACT_MODEL_LIMIT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Every model of every provider that a search finds, legacy ones included, by maker; a provider with none is left out. */
|
|
57
|
+
export function searchModels(providers: readonly ProviderInfo[], query: string): Array<{ entry: ProviderInfo; models: ModelInfo[] }> {
|
|
58
|
+
const needle = query.trim().toLowerCase();
|
|
59
|
+
return providers
|
|
60
|
+
.map((entry) => ({ entry, models: modelsByVendor(entry.models.filter((model) => matches(model, needle))) }))
|
|
61
|
+
.filter((group) => group.models.length > 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
const NONE: ReadonlySet<AgentKind> = new Set();
|
|
44
65
|
|
|
45
66
|
/*
|
|
@@ -55,7 +76,8 @@ export function pickerEntriesOf(
|
|
|
55
76
|
expanded: ReadonlySet<AgentKind> = NONE,
|
|
56
77
|
current: PickerCurrent | null = null
|
|
57
78
|
): PickerEntry[] {
|
|
58
|
-
const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] =>
|
|
79
|
+
const rowsOf = (entry: ProviderInfo, models: ModelInfo[]): PickerEntry[] =>
|
|
80
|
+
modelsByVendor(models).map((model) => ({ kind: 'model', provider: entry, model }));
|
|
59
81
|
if (query !== '') {
|
|
60
82
|
return providers.flatMap((entry) =>
|
|
61
83
|
rowsOf(
|
|
@@ -69,13 +91,13 @@ export function pickerEntriesOf(
|
|
|
69
91
|
);
|
|
70
92
|
const live = providers.flatMap((entry): PickerEntry[] => {
|
|
71
93
|
const models = entry.models.filter((model) => !model.legacy);
|
|
72
|
-
|
|
94
|
+
const shortlist = shortlistOf(entry.kind, models, current);
|
|
95
|
+
if (shortlist === null) {
|
|
73
96
|
return rowsOf(entry, models);
|
|
74
97
|
}
|
|
75
98
|
if (expanded.has(entry.kind)) {
|
|
76
99
|
return [{ kind: 'all', provider: entry, count: models.length, open: true }, ...rowsOf(entry, models)];
|
|
77
100
|
}
|
|
78
|
-
const shortlist = models.filter((model) => model.isDefault || (current?.provider === entry.kind && current.model === model.slug));
|
|
79
101
|
return [...rowsOf(entry, shortlist), { kind: 'all', provider: entry, count: models.length, open: false }];
|
|
80
102
|
});
|
|
81
103
|
if (legacy.length === 0) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import { Wrench } from 'lucide-react';
|
|
3
|
+
import type { ModelInfo } from '@adecore/agent-contracts';
|
|
4
|
+
import { Icon, Pill, Tooltip } from '@adecore/ui';
|
|
5
|
+
|
|
6
|
+
/* What a model row says beside the name: its badge, and a mark when the provider says the model can call tools. */
|
|
7
|
+
export function ModelMarks({ model }: { model: ModelInfo }) {
|
|
8
|
+
const { t } = useTranslation('agent-chat');
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{model.badge && (
|
|
12
|
+
<Pill tone="accent" shape="tag" size="sm">
|
|
13
|
+
{model.badge.charAt(0).toLocaleUpperCase() + model.badge.slice(1)}
|
|
14
|
+
</Pill>
|
|
15
|
+
)}
|
|
16
|
+
{model.tools === true && (
|
|
17
|
+
<Tooltip label={t('pickers.model.tools')}>
|
|
18
|
+
<span className="flex shrink-0 text-text-faint" aria-label={t('pickers.model.tools')}>
|
|
19
|
+
<Icon icon={Wrench} size={12} />
|
|
20
|
+
</span>
|
|
21
|
+
</Tooltip>
|
|
22
|
+
)}
|
|
23
|
+
</>
|
|
24
|
+
);
|
|
25
|
+
}
|
package/src/chat/ui/Pickers.tsx
CHANGED
|
@@ -9,9 +9,10 @@ import { modelRequirementOf } from '../../agents/model-requirement';
|
|
|
9
9
|
import { useChatHost } from '../../host';
|
|
10
10
|
import { ChatScopeContext } from '../../scope';
|
|
11
11
|
import { useReportedModels } from '../../state/chats';
|
|
12
|
+
import { ModelMarks } from './ModelMarks';
|
|
12
13
|
import { pickerEntriesOf, type PickerEntry } from '../logic/picker-entries';
|
|
13
14
|
import { forgetStashed, STASH_SHORTCUT, useStash, type StashedPrompt } from '../stash';
|
|
14
|
-
import { Icon, IconButton, Menu,
|
|
15
|
+
import { Icon, IconButton, Menu, Popover, Tooltip } from '@adecore/ui';
|
|
15
16
|
|
|
16
17
|
const triggerClass =
|
|
17
18
|
'flex h-7 shrink-0 items-center gap-1 whitespace-nowrap rounded-md px-2 text-xs text-text-muted hover:bg-surface-hover hover:text-text data-[popup-open]:bg-surface-active data-[popup-open]:text-text';
|
|
@@ -257,11 +258,7 @@ export function ModelPicker({
|
|
|
257
258
|
<span className="flex w-full items-center gap-2">
|
|
258
259
|
<Menu.Check kind="radio" checked={chosen} />
|
|
259
260
|
<span className="min-w-0 truncate">{entry.model.name}</span>
|
|
260
|
-
{entry.model
|
|
261
|
-
<Pill tone="accent" shape="tag" size="sm">
|
|
262
|
-
{entry.model.badge.charAt(0).toLocaleUpperCase() + entry.model.badge.slice(1)}
|
|
263
|
-
</Pill>
|
|
264
|
-
)}
|
|
261
|
+
<ModelMarks model={entry.model} />
|
|
265
262
|
<span className="grow" />
|
|
266
263
|
{entry.model.legacy && <span className="text-xs text-text-faint">{t('pickers.model.legacy')}</span>}
|
|
267
264
|
</span>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Fragment, useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
|
1
2
|
import { useTranslation } from 'react-i18next';
|
|
2
3
|
import clsx from 'clsx';
|
|
3
4
|
import {
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
FilePen,
|
|
10
11
|
Gauge,
|
|
11
12
|
Hand,
|
|
13
|
+
Search,
|
|
12
14
|
Settings,
|
|
13
15
|
Shield,
|
|
14
16
|
ShieldAlert,
|
|
@@ -22,10 +24,12 @@ import { canContinueOn } from '../../agents/accounts';
|
|
|
22
24
|
import { AgentIcon } from '../../agents/AgentIcon';
|
|
23
25
|
import type { AccountChoice } from '../account-choice';
|
|
24
26
|
import { modelName, shortModelName } from '../../agents/model-name';
|
|
27
|
+
import { ModelMarks } from './ModelMarks';
|
|
28
|
+
import { modelsByVendor, searchModels, shortlistOf, wantsModelSearch } from '../logic/picker-entries';
|
|
25
29
|
import { CONTEXT_OPTION, contextFraction, contextSegments, orderOptions, type ContextPart } from '../logic/context-usage';
|
|
26
30
|
import { RUNTIME_MODES } from '../runtime-modes';
|
|
27
31
|
import { formatClock, formatWeekdayClock, isSameDay, formatPercent, formatTokens } from '@adecore/ui/format';
|
|
28
|
-
import { Icon, Menu,
|
|
32
|
+
import { Icon, Menu, Tooltip, useNow } from '@adecore/ui';
|
|
29
33
|
import { useChatHost } from '../../host';
|
|
30
34
|
import { useUsageLimits } from '../../usage/limits';
|
|
31
35
|
|
|
@@ -134,6 +138,8 @@ export function RunSettings({
|
|
|
134
138
|
account
|
|
135
139
|
}: RunSettingsProps) {
|
|
136
140
|
const { t } = useTranslation('agent-chat');
|
|
141
|
+
const [query, setQuery] = useState('');
|
|
142
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
137
143
|
const owner = providers.find((entry) => entry.kind === provider);
|
|
138
144
|
const current = modelName(selection.model, owner?.models);
|
|
139
145
|
const short = shortModelName(current, owner?.models);
|
|
@@ -164,6 +170,100 @@ export function RunSettings({
|
|
|
164
170
|
onModel(value.slice(0, slash) as AgentKind, value.slice(slash + 1));
|
|
165
171
|
};
|
|
166
172
|
|
|
173
|
+
const searchable = wantsModelSearch(providers);
|
|
174
|
+
const found = query.trim() === '' ? null : searchModels(providers, query);
|
|
175
|
+
|
|
176
|
+
// The menu moves the focus to its popup as it opens, so the search field takes it a frame later.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!open || !searchable) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const frame = requestAnimationFrame(() => searchRef.current?.focus());
|
|
182
|
+
return () => cancelAnimationFrame(frame);
|
|
183
|
+
}, [open, searchable]);
|
|
184
|
+
|
|
185
|
+
const changeOpen = (next: boolean): void => {
|
|
186
|
+
if (!next) {
|
|
187
|
+
setQuery('');
|
|
188
|
+
}
|
|
189
|
+
onOpenChange(next);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// An item the arrow keys moved to has the focus; typing there still goes to the search.
|
|
193
|
+
const onPopupKey = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
194
|
+
if (!searchable || event.target === searchRef.current || event.metaKey || event.ctrlKey || event.altKey) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const typed = event.key.length === 1 && event.key !== ' ';
|
|
198
|
+
if (!typed && event.key !== 'Backspace') {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
setQuery((now) => (typed ? now + event.key : now.slice(0, -1)));
|
|
204
|
+
searchRef.current?.focus();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// The menu reads letters as a jump to an item and arrows as moving through them; the field keeps what it types.
|
|
208
|
+
const onSearchKey = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
209
|
+
if (event.key === 'Escape' && query !== '') {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
event.stopPropagation();
|
|
212
|
+
setQuery('');
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (event.key === 'Enter') {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
const first = found?.[0];
|
|
218
|
+
if (first !== undefined) {
|
|
219
|
+
onModel(first.entry.kind, first.models[0]!.slug);
|
|
220
|
+
changeOpen(false);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (event.key !== 'Escape' && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Tab') {
|
|
225
|
+
event.stopPropagation();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const modelRow = (entry: ProviderInfo, row: ModelInfo) => (
|
|
230
|
+
<Menu.RadioItem key={row.slug} value={modelValue(entry.kind, row.slug)} closeOnClick>
|
|
231
|
+
<AgentIcon kind={entry.kind} size={14} className="shrink-0" />
|
|
232
|
+
<span className="min-w-0 truncate">{row.name}</span>
|
|
233
|
+
<ModelMarks model={row} />
|
|
234
|
+
</Menu.RadioItem>
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Every model of one provider, under a label per maker where the provider names them.
|
|
238
|
+
const byMaker = (entry: ProviderInfo, models: ModelInfo[]) =>
|
|
239
|
+
modelsByVendor(models).map((row, i, sorted) => (
|
|
240
|
+
<Fragment key={row.slug}>
|
|
241
|
+
{row.vendor !== undefined && sorted[i - 1]?.vendor !== row.vendor && <Menu.GroupLabel>{row.vendor}</Menu.GroupLabel>}
|
|
242
|
+
{modelRow(entry, row)}
|
|
243
|
+
</Fragment>
|
|
244
|
+
));
|
|
245
|
+
|
|
246
|
+
// A provider with many models shows the default and the chosen one, with all of them one submenu away.
|
|
247
|
+
const providerModels = (entry: ProviderInfo, models: ModelInfo[]) => {
|
|
248
|
+
const shortlist = shortlistOf(entry.kind, models, { provider, model: selection.model });
|
|
249
|
+
if (shortlist === null) {
|
|
250
|
+
return models.map((row) => modelRow(entry, row));
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<>
|
|
254
|
+
{shortlist.map((row) => modelRow(entry, row))}
|
|
255
|
+
<Menu.SubmenuRoot>
|
|
256
|
+
<Menu.SubmenuTrigger className="text-text-muted">{t('pickers.model.allGroup', { count: models.length })}</Menu.SubmenuTrigger>
|
|
257
|
+
<Menu.Popup className="w-80">
|
|
258
|
+
<Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
|
|
259
|
+
{byMaker(entry, models)}
|
|
260
|
+
</Menu.RadioGroup>
|
|
261
|
+
</Menu.Popup>
|
|
262
|
+
</Menu.SubmenuRoot>
|
|
263
|
+
</>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
|
|
167
267
|
const modelGroups = (groups: typeof live) =>
|
|
168
268
|
groups.map(({ entry, models }) => (
|
|
169
269
|
<Menu.Group key={entry.kind}>
|
|
@@ -173,22 +273,12 @@ export function RunSettings({
|
|
|
173
273
|
{entry.name}
|
|
174
274
|
</Menu.GroupLabel>
|
|
175
275
|
)}
|
|
176
|
-
{models
|
|
177
|
-
<Menu.RadioItem key={row.slug} value={modelValue(entry.kind, row.slug)} closeOnClick>
|
|
178
|
-
<AgentIcon kind={entry.kind} size={14} className="shrink-0" />
|
|
179
|
-
<span className="min-w-0 truncate">{row.name}</span>
|
|
180
|
-
{row.badge && (
|
|
181
|
-
<Pill tone="accent" shape="tag" size="sm">
|
|
182
|
-
{row.badge.charAt(0).toLocaleUpperCase() + row.badge.slice(1)}
|
|
183
|
-
</Pill>
|
|
184
|
-
)}
|
|
185
|
-
</Menu.RadioItem>
|
|
186
|
-
))}
|
|
276
|
+
{providerModels(entry, models)}
|
|
187
277
|
</Menu.Group>
|
|
188
278
|
));
|
|
189
279
|
|
|
190
280
|
return (
|
|
191
|
-
<Menu.Root open={open} onOpenChange={
|
|
281
|
+
<Menu.Root open={open} onOpenChange={changeOpen} highlightItemOnHover={!searchable}>
|
|
192
282
|
<Tooltip label={permissionHint ?? t('pickers.settings.title')} kbd="/model">
|
|
193
283
|
{/* Styled from `open`: the tooltip puts `data-popup-open` on the same trigger. */}
|
|
194
284
|
<Menu.Trigger
|
|
@@ -233,63 +323,102 @@ export function RunSettings({
|
|
|
233
323
|
<Icon icon={open ? ChevronUp : ChevronDown} size={12} className="shrink-0 @max-sm/composer:hidden" />
|
|
234
324
|
</Menu.Trigger>
|
|
235
325
|
</Tooltip>
|
|
236
|
-
<Menu.Popup side="top" sideOffset={8} align="start" className="w-80">
|
|
326
|
+
<Menu.Popup side="top" sideOffset={8} align="start" className="w-80" onKeyDownCapture={onPopupKey}>
|
|
237
327
|
{providers.length === 0 && <div className="px-2.5 py-3 text-xs text-text-faint">{t('pickers.model.noProvider')}</div>}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
</Menu.RadioGroup>
|
|
253
|
-
</Menu.Popup>
|
|
254
|
-
</Menu.SubmenuRoot>
|
|
328
|
+
{searchable && (
|
|
329
|
+
<div className="flex items-center gap-2 border-b border-border px-2.5">
|
|
330
|
+
<Icon icon={Search} size={14} className="shrink-0 text-text-faint" />
|
|
331
|
+
<input
|
|
332
|
+
ref={searchRef}
|
|
333
|
+
className="h-8 w-full bg-transparent text-sm text-text outline-none placeholder:text-text-faint"
|
|
334
|
+
placeholder={t('pickers.model.search')}
|
|
335
|
+
aria-label={t('pickers.model.search')}
|
|
336
|
+
spellCheck={false}
|
|
337
|
+
value={query}
|
|
338
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
339
|
+
onKeyDown={onSearchKey}
|
|
340
|
+
/>
|
|
341
|
+
</div>
|
|
255
342
|
)}
|
|
256
|
-
<
|
|
257
|
-
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
size={14}
|
|
274
|
-
className={mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint'}
|
|
275
|
-
/>
|
|
276
|
-
</span>
|
|
277
|
-
<span className="flex min-w-0 grow flex-col">
|
|
278
|
-
{t(`modes.${mode}.label`)}
|
|
279
|
-
<span className="text-xs text-text-faint">{t(`modes.${mode}.hint`)}</span>
|
|
280
|
-
</span>
|
|
281
|
-
</Menu.RadioItem>
|
|
282
|
-
))}
|
|
283
|
-
</Menu.RadioGroup>
|
|
284
|
-
</Menu.Popup>
|
|
285
|
-
</Menu.SubmenuRoot>
|
|
286
|
-
{options.map((option) => (
|
|
287
|
-
<OptionRow key={option.id} option={option} selection={selection} onChange={(value) => onOption(option.id, value)} />
|
|
288
|
-
))}
|
|
289
|
-
{(hasContextOption || standaloneContext) && (
|
|
343
|
+
{found !== null && found.length === 0 && <div className="px-2.5 py-3 text-xs text-text-faint">{t('pickers.model.noMatch')}</div>}
|
|
344
|
+
{found !== null && found.length > 0 && (
|
|
345
|
+
<Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
|
|
346
|
+
{found.map(({ entry, models }) => (
|
|
347
|
+
<Menu.Group key={entry.kind}>
|
|
348
|
+
{grouped && (
|
|
349
|
+
<Menu.GroupLabel className="flex items-center gap-1.5">
|
|
350
|
+
<AgentIcon kind={entry.kind} size={12} />
|
|
351
|
+
{entry.name}
|
|
352
|
+
</Menu.GroupLabel>
|
|
353
|
+
)}
|
|
354
|
+
{byMaker(entry, models)}
|
|
355
|
+
</Menu.Group>
|
|
356
|
+
))}
|
|
357
|
+
</Menu.RadioGroup>
|
|
358
|
+
)}
|
|
359
|
+
{found === null && (
|
|
290
360
|
<>
|
|
361
|
+
<Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
|
|
362
|
+
{modelGroups(live)}
|
|
363
|
+
</Menu.RadioGroup>
|
|
364
|
+
{legacy.length > 0 && (
|
|
365
|
+
<Menu.SubmenuRoot>
|
|
366
|
+
<Menu.SubmenuTrigger className="text-text-muted">
|
|
367
|
+
<span className="min-w-0 grow truncate">
|
|
368
|
+
{t('pickers.model.legacyGroup', { count: legacy.reduce((count, group) => count + group.models.length, 0) })}
|
|
369
|
+
</span>
|
|
370
|
+
{chosenLegacy && <Menu.Hint className="truncate">{chosenLegacy.name}</Menu.Hint>}
|
|
371
|
+
</Menu.SubmenuTrigger>
|
|
372
|
+
<Menu.Popup className="min-w-56">
|
|
373
|
+
<Menu.RadioGroup value={chosenModel} onValueChange={chooseModel}>
|
|
374
|
+
{modelGroups(legacy)}
|
|
375
|
+
</Menu.RadioGroup>
|
|
376
|
+
</Menu.Popup>
|
|
377
|
+
</Menu.SubmenuRoot>
|
|
378
|
+
)}
|
|
291
379
|
<Menu.Separator />
|
|
292
|
-
<
|
|
380
|
+
{account !== null && <AccountSubmenu provider={provider} account={account} />}
|
|
381
|
+
<Menu.SubmenuRoot>
|
|
382
|
+
<Menu.SubmenuTrigger className="text-text-muted">
|
|
383
|
+
<Icon
|
|
384
|
+
icon={MODE_ICONS[runtimeMode]}
|
|
385
|
+
size={14}
|
|
386
|
+
className={clsx('shrink-0', fullAccess ? 'text-status-needs-you' : 'text-text-faint')}
|
|
387
|
+
/>
|
|
388
|
+
<span className="min-w-0 grow truncate">{t('pickers.mode.label')}</span>
|
|
389
|
+
<Menu.Hint className="truncate">{t(`modes.${runtimeMode}.label`)}</Menu.Hint>
|
|
390
|
+
</Menu.SubmenuTrigger>
|
|
391
|
+
<Menu.Popup className="w-72">
|
|
392
|
+
{permissionHint !== null && <p className="px-2.5 py-2 text-xs text-text-muted">{permissionHint}</p>}
|
|
393
|
+
<Menu.RadioGroup value={runtimeMode} onValueChange={(mode: RuntimeMode) => onMode(mode)}>
|
|
394
|
+
{RUNTIME_MODES.map((mode) => (
|
|
395
|
+
<Menu.RadioItem key={mode} value={mode} className="items-start">
|
|
396
|
+
{/* The hint makes the row two lines high; the box keeps the icon on the label's line. */}
|
|
397
|
+
<span className="grid h-lh w-4 shrink-0 place-items-center">
|
|
398
|
+
<Icon
|
|
399
|
+
icon={MODE_ICONS[mode]}
|
|
400
|
+
size={14}
|
|
401
|
+
className={mode === 'full-access' ? 'text-status-needs-you' : 'text-text-faint'}
|
|
402
|
+
/>
|
|
403
|
+
</span>
|
|
404
|
+
<span className="flex min-w-0 grow flex-col">
|
|
405
|
+
{t(`modes.${mode}.label`)}
|
|
406
|
+
<span className="text-xs text-text-faint">{t(`modes.${mode}.hint`)}</span>
|
|
407
|
+
</span>
|
|
408
|
+
</Menu.RadioItem>
|
|
409
|
+
))}
|
|
410
|
+
</Menu.RadioGroup>
|
|
411
|
+
</Menu.Popup>
|
|
412
|
+
</Menu.SubmenuRoot>
|
|
413
|
+
{options.map((option) => (
|
|
414
|
+
<OptionRow key={option.id} option={option} selection={selection} onChange={(value) => onOption(option.id, value)} />
|
|
415
|
+
))}
|
|
416
|
+
{(hasContextOption || standaloneContext) && (
|
|
417
|
+
<>
|
|
418
|
+
<Menu.Separator />
|
|
419
|
+
<ContextUsage usage={usage} disabled={compactDisabled} onCompact={onCompact} />
|
|
420
|
+
</>
|
|
421
|
+
)}
|
|
293
422
|
</>
|
|
294
423
|
)}
|
|
295
424
|
</Menu.Popup>
|
|
@@ -26,7 +26,7 @@ import { useChatHost, type CommandLook } from '../../../host';
|
|
|
26
26
|
import { lazyNamed } from '../../../lazy';
|
|
27
27
|
import { useChatActions } from '../../actions';
|
|
28
28
|
import { ChatScopeContext, useChatScope } from '../../../scope';
|
|
29
|
-
import { useCurrentItem } from '../../../state/chats';
|
|
29
|
+
import { useAwaitsApproval, useCurrentItem } from '../../../state/chats';
|
|
30
30
|
import { useTimelineFlash } from '../../timeline-flash';
|
|
31
31
|
import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
|
|
32
32
|
import { Icon, LazyBoundary, MiddleTruncate, Spinner, useTickingText } from '@adecore/ui';
|
|
@@ -378,6 +378,8 @@ export function WorkLiveRow({ chatId, tool: derived }: { chatId: string; tool: C
|
|
|
378
378
|
const shell = shellCommand(tool.name, tool.input);
|
|
379
379
|
const { commandLooks } = useChatHost();
|
|
380
380
|
const run = shell === null ? null : matchCommandLook(shell, commandLooks);
|
|
381
|
+
const awaiting = useAwaitsApproval(chatId, tool.toolUseId);
|
|
382
|
+
const { t } = useTranslation('agent-chat');
|
|
381
383
|
return (
|
|
382
384
|
<div>
|
|
383
385
|
<ToggleLine
|
|
@@ -390,7 +392,13 @@ export function WorkLiveRow({ chatId, tool: derived }: { chatId: string; tool: C
|
|
|
390
392
|
path={summary !== '' && toolSummaryIsPath(tool.name, tool.input)}
|
|
391
393
|
open={open && opens}
|
|
392
394
|
onToggle={opens ? () => setOpen((o) => !o) : undefined}
|
|
393
|
-
trailing={
|
|
395
|
+
trailing={
|
|
396
|
+
awaiting ? (
|
|
397
|
+
<span className="shrink-0 text-xs text-text-faint">{t('work.awaitingApproval')}</span>
|
|
398
|
+
) : (
|
|
399
|
+
<RunningFor startedAt={toolStartedAt(tool)} />
|
|
400
|
+
)
|
|
401
|
+
}
|
|
394
402
|
live
|
|
395
403
|
/>
|
|
396
404
|
{open && opens && <ToolBody tool={tool} />}
|
|
@@ -183,6 +183,7 @@ export default {
|
|
|
183
183
|
loadingDiff: 'Loading diff',
|
|
184
184
|
diffUnavailable: 'Could not load this diff',
|
|
185
185
|
runningFor: 'running for {{elapsed}}',
|
|
186
|
+
awaitingApproval: 'waiting for you',
|
|
186
187
|
forksAfter_one: 'A fork goes on after this turn',
|
|
187
188
|
forksAfter_other: '{{count}} forks go on after this turn',
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ export default {
|
|
|
439
440
|
legacyGroup_one: 'Legacy models ({{count}})',
|
|
440
441
|
legacyGroup_other: 'Legacy models ({{count}})',
|
|
441
442
|
legacy: 'Legacy',
|
|
443
|
+
tools: 'Can use tools',
|
|
442
444
|
requires: 'Requires {{cli}} {{version}} or later on {{machine}}',
|
|
443
445
|
requiresHere: 'Requires {{cli}} {{version}} or later',
|
|
444
446
|
providerSettings: 'Provider settings'
|
|
@@ -183,6 +183,7 @@ export default {
|
|
|
183
183
|
loadingDiff: 'Diff laden',
|
|
184
184
|
diffUnavailable: 'Deze diff kon niet worden geladen',
|
|
185
185
|
runningFor: 'loopt al {{elapsed}}',
|
|
186
|
+
awaitingApproval: 'wacht op jou',
|
|
186
187
|
forksAfter_one: 'Een fork gaat verder na deze turn',
|
|
187
188
|
forksAfter_other: '{{count}} forks gaan verder na deze turn',
|
|
188
189
|
omitted: {
|
|
@@ -439,6 +440,7 @@ export default {
|
|
|
439
440
|
legacyGroup_one: 'Legacy-modellen ({{count}})',
|
|
440
441
|
legacyGroup_other: 'Legacy-modellen ({{count}})',
|
|
441
442
|
legacy: 'Legacy',
|
|
443
|
+
tools: 'Kan tools gebruiken',
|
|
442
444
|
requires: 'Vereist {{cli}} {{version}} of nieuwer op {{machine}}',
|
|
443
445
|
requiresHere: 'Vereist {{cli}} {{version}} of nieuwer',
|
|
444
446
|
providerSettings: 'Providerinstellingen'
|
|
@@ -30,9 +30,11 @@ function ApprovalDetails({ item }: { item: ChatApprovalItem }) {
|
|
|
30
30
|
const editorPreview = host.code.editor !== undefined;
|
|
31
31
|
const maxHeight = expanded ? 480 : 176;
|
|
32
32
|
const input = typeof item.input === 'object' && item.input !== null ? (item.input as Record<string, unknown>) : {};
|
|
33
|
+
// Some agents describe a command by the command itself, which the box under it already shows.
|
|
34
|
+
const describes = item.description && item.description.trim() !== (typeof input.command === 'string' ? input.command.trim() : null);
|
|
33
35
|
return (
|
|
34
36
|
<>
|
|
35
|
-
{
|
|
37
|
+
{describes && <p className="text-sm text-text-muted">{item.description}</p>}
|
|
36
38
|
{patches.length + edits.length > 1 && (
|
|
37
39
|
<p className="text-xs text-text-muted">{t('approval.allChanges', { count: patches.length + edits.length })}</p>
|
|
38
40
|
)}
|
package/src/state/chats.ts
CHANGED
|
@@ -347,6 +347,11 @@ export function useReportedModels(provider: AgentKind): ReadonlySet<string> {
|
|
|
347
347
|
return useMemo(() => new Set(joined === '' ? [] : joined.split('\n')), [joined]);
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/* Whether a call still waits on the person's answer to an approval of it, when its clock is not running yet. */
|
|
351
|
+
export function useAwaitsApproval(chatId: string, toolUseId: string): boolean {
|
|
352
|
+
return useChatRow(chatId, (row) => waitingRequestsOf(row).some((request) => request.kind === 'approval' && request.toolUseId === toolUseId));
|
|
353
|
+
}
|
|
354
|
+
|
|
350
355
|
/* The item as the thread holds it now, for a row derived from the structure, which a delta leaves alone. */
|
|
351
356
|
export function useCurrentItem<T extends ChatItem>(chatId: string, derived: T): T {
|
|
352
357
|
return useChatRow(chatId, (row) => {
|