@nextclaw/ui 0.11.12 → 0.11.14
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/CHANGELOG.md +13 -0
- package/dist/assets/{ChannelsList-C63gOoYI.js → ChannelsList-CvK4qHfg.js} +4 -4
- package/dist/assets/ChatPage-Co3GqIVP.js +37 -0
- package/dist/assets/{DocBrowser-CI4jOzJY.js → DocBrowser-BFmW6e-4.js} +1 -1
- package/dist/assets/{LogoBadge-DImV63-L.js → LogoBadge-DZL-zQTr.js} +1 -1
- package/dist/assets/{MarketplacePage-B360oSAV.js → MarketplacePage-B__MZRrD.js} +1 -1
- package/dist/assets/{McpMarketplacePage-KIQgx_7h.js → McpMarketplacePage-C_VKm1uq.js} +2 -2
- package/dist/assets/{ModelConfig-Ben3tQoX.js → ModelConfig-CqJubuwU.js} +1 -1
- package/dist/assets/{ProvidersList-DE-S9mq0.js → ProvidersList-BoSsFBk5.js} +1 -1
- package/dist/assets/RemoteAccessPage-S1ChRWMX.js +1 -0
- package/dist/assets/RuntimeConfig-WnFUsayT.js +1 -0
- package/dist/assets/{SearchConfig-DeOa-M6j.js → SearchConfig-D9V07oqj.js} +1 -1
- package/dist/assets/{SecretsConfig-Ci8pJmzd.js → SecretsConfig-Ci8sEzaV.js} +2 -2
- package/dist/assets/{SessionsConfig-B6zq55yu.js → SessionsConfig-5Nznhx9P.js} +2 -2
- package/dist/assets/{chat-session-display--oo5yuIw.js → chat-session-display-D0ZcEkUq.js} +1 -1
- package/dist/assets/{index-LhlkB00c.js → index-BvCYcN48.js} +3 -3
- package/dist/assets/{index-Bro-iRcb.css → index-CfVmBgkf.css} +1 -1
- package/dist/assets/{label-3TKt0PoZ.js → label-AurG3ZpO.js} +1 -1
- package/dist/assets/{page-layout-CopkIM3Q.js → page-layout-Q2hHkfJy.js} +1 -1
- package/dist/assets/{popover-CUx8uRJw.js → popover-BKInm43u.js} +1 -1
- package/dist/assets/security-config-BbPGNJAB.js +1 -0
- package/dist/assets/skeleton-CuKw6-Ww.js +1 -0
- package/dist/assets/{status-dot-D6vJMwD7.js → status-dot-DLk8UxLB.js} +1 -1
- package/dist/assets/{switch-A3-ClT1P.js → switch-BxMSKsQS.js} +1 -1
- package/dist/assets/{tabs-custom-BVSd5urq.js → tabs-custom-B6gK-RY6.js} +1 -1
- package/dist/assets/{useConfirmDialog-ChPriea6.js → useConfirmDialog-Dth62a0a.js} +1 -1
- package/dist/assets/{vendor-BEQcLDx6.js → vendor-MCpnpiKt.js} +35 -35
- package/dist/index.html +3 -3
- package/package.json +4 -4
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +107 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +40 -6
- package/src/components/chat/chat-recent-models.manager.ts +8 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +14 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +47 -0
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +2 -0
- package/src/components/chat/ncp/ncp-chat-realtime-reload.test.ts +44 -0
- package/src/components/chat/ncp/ncp-chat-realtime-reload.ts +20 -0
- package/src/lib/recent-selection.manager.test.ts +68 -0
- package/src/lib/recent-selection.manager.ts +105 -0
- package/dist/assets/ChatPage-Ci3Gz0qh.js +0 -37
- package/dist/assets/RemoteAccessPage-DxUia6R-.js +0 -1
- package/dist/assets/RuntimeConfig-CQcGfNZT.js +0 -1
- package/dist/assets/security-config-BL29kTzz.js +0 -1
- package/dist/assets/skeleton-Bs4zvcql.js +0 -1
package/dist/index.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-BvCYcN48.js"></script>
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-MCpnpiKt.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CfVmBgkf.css">
|
|
12
12
|
</head>
|
|
13
13
|
|
|
14
14
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/ui",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"zod": "^3.23.8",
|
|
30
30
|
"zustand": "^5.0.2",
|
|
31
31
|
"@nextclaw/agent-chat": "0.1.4",
|
|
32
|
-
"@nextclaw/ncp": "0.4.1",
|
|
33
32
|
"@nextclaw/agent-chat-ui": "0.2.14",
|
|
34
|
-
"@nextclaw/ncp
|
|
35
|
-
"@nextclaw/ncp-react": "0.4.
|
|
33
|
+
"@nextclaw/ncp": "0.4.1",
|
|
34
|
+
"@nextclaw/ncp-react": "0.4.5",
|
|
35
|
+
"@nextclaw/ncp-http-agent-client": "0.3.5"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@testing-library/react": "^16.3.0",
|
|
@@ -95,17 +95,123 @@ describe('buildModelToolbarSelect', () => {
|
|
|
95
95
|
providerLabel: 'MiniMax'
|
|
96
96
|
}
|
|
97
97
|
],
|
|
98
|
+
recentModelValues: [],
|
|
98
99
|
selectedModel: 'dashscope/qwen3-coder-next',
|
|
99
100
|
isModelOptionsLoading: false,
|
|
100
101
|
hasModelOptions: true,
|
|
101
102
|
onValueChange,
|
|
102
103
|
texts: {
|
|
103
104
|
modelSelectPlaceholder: 'Select model',
|
|
104
|
-
modelNoOptionsLabel: 'No models'
|
|
105
|
+
modelNoOptionsLabel: 'No models',
|
|
106
|
+
recentModelsLabel: 'Recent',
|
|
107
|
+
allModelsLabel: 'All models'
|
|
105
108
|
}
|
|
106
109
|
});
|
|
107
110
|
|
|
108
111
|
expect(select.value).toBe('minimax/MiniMax-M2.7');
|
|
109
112
|
expect(select.selectedLabel).toBe('MiniMax/MiniMax-M2.7');
|
|
113
|
+
expect(select.options[0]).toEqual({
|
|
114
|
+
value: 'minimax/MiniMax-M2.7',
|
|
115
|
+
label: 'MiniMax/MiniMax-M2.7'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('groups recent models ahead of the remaining catalog', () => {
|
|
120
|
+
const select = buildModelToolbarSelect({
|
|
121
|
+
modelOptions: [
|
|
122
|
+
{
|
|
123
|
+
value: 'openai/gpt-5',
|
|
124
|
+
modelLabel: 'gpt-5',
|
|
125
|
+
providerLabel: 'OpenAI'
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
value: 'anthropic/claude-sonnet-4',
|
|
129
|
+
modelLabel: 'claude-sonnet-4',
|
|
130
|
+
providerLabel: 'Anthropic'
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
value: 'minimax/MiniMax-M2.7',
|
|
134
|
+
modelLabel: 'MiniMax-M2.7',
|
|
135
|
+
providerLabel: 'MiniMax'
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
recentModelValues: ['anthropic/claude-sonnet-4', 'missing/model'],
|
|
139
|
+
selectedModel: 'openai/gpt-5',
|
|
140
|
+
isModelOptionsLoading: false,
|
|
141
|
+
hasModelOptions: true,
|
|
142
|
+
onValueChange: vi.fn(),
|
|
143
|
+
texts: {
|
|
144
|
+
modelSelectPlaceholder: 'Select model',
|
|
145
|
+
modelNoOptionsLabel: 'No models',
|
|
146
|
+
recentModelsLabel: 'Recent',
|
|
147
|
+
allModelsLabel: 'All models'
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(select.groups).toEqual([
|
|
152
|
+
{
|
|
153
|
+
key: 'recent-models',
|
|
154
|
+
label: 'Recent',
|
|
155
|
+
options: [
|
|
156
|
+
{
|
|
157
|
+
value: 'anthropic/claude-sonnet-4',
|
|
158
|
+
label: 'Anthropic/claude-sonnet-4'
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'all-models',
|
|
164
|
+
label: 'All models',
|
|
165
|
+
options: [
|
|
166
|
+
{
|
|
167
|
+
value: 'openai/gpt-5',
|
|
168
|
+
label: 'OpenAI/gpt-5'
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
value: 'minimax/MiniMax-M2.7',
|
|
172
|
+
label: 'MiniMax/MiniMax-M2.7'
|
|
173
|
+
}
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('preserves recent model order from newest to oldest', () => {
|
|
180
|
+
const select = buildModelToolbarSelect({
|
|
181
|
+
modelOptions: [
|
|
182
|
+
{
|
|
183
|
+
value: 'openai/gpt-5',
|
|
184
|
+
modelLabel: 'gpt-5',
|
|
185
|
+
providerLabel: 'OpenAI'
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
value: 'anthropic/claude-sonnet-4',
|
|
189
|
+
modelLabel: 'claude-sonnet-4',
|
|
190
|
+
providerLabel: 'Anthropic'
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
value: 'deepseek/deepseek-chat',
|
|
194
|
+
modelLabel: 'deepseek-chat',
|
|
195
|
+
providerLabel: 'DeepSeek'
|
|
196
|
+
}
|
|
197
|
+
],
|
|
198
|
+
recentModelValues: ['deepseek/deepseek-chat', 'openai/gpt-5', 'anthropic/claude-sonnet-4'],
|
|
199
|
+
selectedModel: 'openai/gpt-5',
|
|
200
|
+
isModelOptionsLoading: false,
|
|
201
|
+
hasModelOptions: true,
|
|
202
|
+
onValueChange: vi.fn(),
|
|
203
|
+
texts: {
|
|
204
|
+
modelSelectPlaceholder: 'Select model',
|
|
205
|
+
modelNoOptionsLabel: 'No models',
|
|
206
|
+
recentModelsLabel: 'Recent',
|
|
207
|
+
allModelsLabel: 'All models'
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(select.groups?.[0]?.options.map((option) => option.value)).toEqual([
|
|
212
|
+
'deepseek/deepseek-chat',
|
|
213
|
+
'openai/gpt-5',
|
|
214
|
+
'anthropic/claude-sonnet-4'
|
|
215
|
+
]);
|
|
110
216
|
});
|
|
111
217
|
});
|
|
@@ -46,12 +46,20 @@ export type ChatInputBarAdapterTexts = {
|
|
|
46
46
|
noSkillDescription: string;
|
|
47
47
|
modelSelectPlaceholder: string;
|
|
48
48
|
modelNoOptionsLabel: string;
|
|
49
|
+
recentModelsLabel: string;
|
|
50
|
+
allModelsLabel: string;
|
|
49
51
|
sessionTypePlaceholder: string;
|
|
50
52
|
thinkingLabels: Record<ChatThinkingLevel, string>;
|
|
51
53
|
noModelOptionsLabel: string;
|
|
52
54
|
configureProviderLabel: string;
|
|
53
55
|
};
|
|
54
56
|
|
|
57
|
+
function formatModelOptionLabel(option: ChatModelRecord): string {
|
|
58
|
+
const modelLabel = option.modelLabel.trim();
|
|
59
|
+
const providerLabel = option.providerLabel.trim();
|
|
60
|
+
return providerLabel ? `${providerLabel}/${modelLabel}` : modelLabel;
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export function resolveSlashQuery(draft: string): string | null {
|
|
56
64
|
const match = /^\/([^\s]*)$/.exec(draft);
|
|
57
65
|
if (!match) {
|
|
@@ -231,30 +239,56 @@ export function buildModelStateHint(params: {
|
|
|
231
239
|
|
|
232
240
|
export function buildModelToolbarSelect(params: {
|
|
233
241
|
modelOptions: ChatModelRecord[];
|
|
242
|
+
recentModelValues?: string[];
|
|
234
243
|
selectedModel: string;
|
|
235
244
|
isModelOptionsLoading: boolean;
|
|
236
245
|
hasModelOptions: boolean;
|
|
237
246
|
onValueChange: (value: string) => void;
|
|
238
|
-
texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel'>;
|
|
247
|
+
texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel' | 'recentModelsLabel' | 'allModelsLabel'>;
|
|
239
248
|
}): ChatToolbarSelect {
|
|
240
249
|
const selectedModelOption = params.modelOptions.find((option) => option.value === params.selectedModel);
|
|
241
250
|
const fallbackModelOption = params.modelOptions[0];
|
|
242
251
|
const resolvedModelOption = selectedModelOption ?? fallbackModelOption;
|
|
243
252
|
const resolvedValue = params.hasModelOptions ? resolvedModelOption?.value : undefined;
|
|
253
|
+
const recentValueSet = new Set(params.recentModelValues ?? []);
|
|
254
|
+
const modelOptionMap = new Map(params.modelOptions.map((option) => [option.value, option] as const));
|
|
255
|
+
const recentOptions = (params.recentModelValues ?? [])
|
|
256
|
+
.map((value) => modelOptionMap.get(value))
|
|
257
|
+
.filter((option): option is ChatModelRecord => Boolean(option));
|
|
258
|
+
const remainingOptions = params.modelOptions.filter((option) => !recentValueSet.has(option.value));
|
|
259
|
+
const optionGroups =
|
|
260
|
+
recentOptions.length > 0
|
|
261
|
+
? [
|
|
262
|
+
{
|
|
263
|
+
key: 'recent-models',
|
|
264
|
+
label: params.texts.recentModelsLabel,
|
|
265
|
+
options: recentOptions.map((option) => ({
|
|
266
|
+
value: option.value,
|
|
267
|
+
label: formatModelOptionLabel(option)
|
|
268
|
+
}))
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
key: 'all-models',
|
|
272
|
+
label: params.texts.allModelsLabel,
|
|
273
|
+
options: remainingOptions.map((option) => ({
|
|
274
|
+
value: option.value,
|
|
275
|
+
label: formatModelOptionLabel(option)
|
|
276
|
+
}))
|
|
277
|
+
}
|
|
278
|
+
].filter((group) => group.options.length > 0)
|
|
279
|
+
: undefined;
|
|
244
280
|
|
|
245
281
|
return {
|
|
246
282
|
key: 'model',
|
|
247
283
|
value: resolvedValue,
|
|
248
284
|
placeholder: params.texts.modelSelectPlaceholder,
|
|
249
|
-
selectedLabel: resolvedModelOption
|
|
250
|
-
? `${resolvedModelOption.providerLabel}/${resolvedModelOption.modelLabel}`
|
|
251
|
-
: undefined,
|
|
285
|
+
selectedLabel: resolvedModelOption ? formatModelOptionLabel(resolvedModelOption) : undefined,
|
|
252
286
|
icon: 'sparkles',
|
|
253
287
|
options: params.modelOptions.map((option) => ({
|
|
254
288
|
value: option.value,
|
|
255
|
-
label: option
|
|
256
|
-
description: option.providerLabel
|
|
289
|
+
label: formatModelOptionLabel(option)
|
|
257
290
|
})),
|
|
291
|
+
groups: optionGroups,
|
|
258
292
|
disabled: !params.hasModelOptions,
|
|
259
293
|
loading: params.isModelOptionsLoading,
|
|
260
294
|
emptyLabel: params.texts.modelNoOptionsLabel,
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
type ChatThinkingLevel
|
|
17
17
|
} from '@/components/chat/adapters/chat-input-bar.adapter';
|
|
18
18
|
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
19
|
+
import {
|
|
20
|
+
CHAT_RECENT_MODELS_MIN_OPTIONS,
|
|
21
|
+
chatRecentModelsManager
|
|
22
|
+
} from '@/components/chat/chat-recent-models.manager';
|
|
19
23
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
20
24
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
21
25
|
import { t } from '@/lib/i18n';
|
|
@@ -104,6 +108,10 @@ export function ChatInputBarContainer() {
|
|
|
104
108
|
[snapshot.skillRecords, officialSkillBadgeLabel]
|
|
105
109
|
);
|
|
106
110
|
const modelRecords = useMemo(() => toModelRecords(snapshot.modelOptions), [snapshot.modelOptions]);
|
|
111
|
+
const recentModelValues = chatRecentModelsManager.resolveVisible({
|
|
112
|
+
availableValues: modelRecords.map((option) => option.value),
|
|
113
|
+
minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS
|
|
114
|
+
});
|
|
107
115
|
|
|
108
116
|
const hasModelOptions = modelRecords.length > 0;
|
|
109
117
|
const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
|
|
@@ -116,6 +124,8 @@ export function ChatInputBarContainer() {
|
|
|
116
124
|
: hasModelOptions
|
|
117
125
|
? t('chatInputPlaceholder')
|
|
118
126
|
: t('chatModelNoOptions');
|
|
127
|
+
const recentModelsLabel = language === 'zh' ? '最近选择' : 'Recent';
|
|
128
|
+
const allModelsLabel = language === 'zh' ? '全部模型' : 'All models';
|
|
119
129
|
|
|
120
130
|
const slashItems = useMemo(
|
|
121
131
|
() => buildChatSlashItems(skillRecords, slashQuery ?? '', slashTexts),
|
|
@@ -171,13 +181,16 @@ export function ChatInputBarContainer() {
|
|
|
171
181
|
const toolbarSelects = [
|
|
172
182
|
buildModelToolbarSelect({
|
|
173
183
|
modelOptions: modelRecords,
|
|
184
|
+
recentModelValues,
|
|
174
185
|
selectedModel: snapshot.selectedModel,
|
|
175
186
|
isModelOptionsLoading,
|
|
176
187
|
hasModelOptions,
|
|
177
188
|
onValueChange: presenter.chatInputManager.selectModel,
|
|
178
189
|
texts: {
|
|
179
190
|
modelSelectPlaceholder: t('chatSelectModel'),
|
|
180
|
-
modelNoOptionsLabel: t('chatModelNoOptions')
|
|
191
|
+
modelNoOptionsLabel: t('chatModelNoOptions'),
|
|
192
|
+
recentModelsLabel,
|
|
193
|
+
allModelsLabel
|
|
181
194
|
}
|
|
182
195
|
}),
|
|
183
196
|
buildThinkingToolbarSelect({
|
|
@@ -22,6 +22,8 @@ import { useChatSessionListStore } from '@/components/chat/stores/chat-session-l
|
|
|
22
22
|
import { resolveSessionTypeLabel } from '@/components/chat/useChatSessionTypeState';
|
|
23
23
|
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
24
24
|
import { normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
|
|
25
|
+
import { appClient } from '@/transport';
|
|
26
|
+
import { resolveNcpChatRealtimeReloadAction } from '@/components/chat/ncp/ncp-chat-realtime-reload';
|
|
25
27
|
|
|
26
28
|
function buildNcpSendMetadata(payload: {
|
|
27
29
|
model?: string;
|
|
@@ -69,6 +71,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
69
71
|
const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
|
|
70
72
|
const threadRef = useRef<HTMLDivElement | null>(null);
|
|
71
73
|
const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
|
|
74
|
+
const pendingRealtimeReloadRef = useRef(false);
|
|
72
75
|
const routeSessionKey = useMemo(
|
|
73
76
|
() => parseSessionKeyFromRoute(routeSessionIdParam),
|
|
74
77
|
[routeSessionIdParam]
|
|
@@ -154,6 +157,50 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
154
157
|
const stopDisabledReason = agent.isRunning ? null : '__preparing__';
|
|
155
158
|
const lastSendError = agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
|
|
156
159
|
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const flushRealtimeReload = () => {
|
|
162
|
+
const action = resolveNcpChatRealtimeReloadAction({
|
|
163
|
+
isHydrating: agent.isHydrating,
|
|
164
|
+
isRunning: agent.isRunning,
|
|
165
|
+
isSending: agent.isSending,
|
|
166
|
+
});
|
|
167
|
+
if (action === 'defer') {
|
|
168
|
+
pendingRealtimeReloadRef.current = true;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (action === 'skip') {
|
|
172
|
+
pendingRealtimeReloadRef.current = false;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
pendingRealtimeReloadRef.current = false;
|
|
176
|
+
void agent.reloadSeed();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return appClient.subscribe((event) => {
|
|
180
|
+
if (event.type === 'session.summary.upsert') {
|
|
181
|
+
if (event.payload.summary.sessionId !== activeSessionId) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
flushRealtimeReload();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (event.type === 'session.updated' && event.payload.sessionKey === activeSessionId) {
|
|
188
|
+
flushRealtimeReload();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}, [activeSessionId, agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!pendingRealtimeReloadRef.current) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (agent.isHydrating || agent.isRunning || agent.isSending) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
pendingRealtimeReloadRef.current = false;
|
|
201
|
+
void agent.reloadSeed();
|
|
202
|
+
}, [agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
|
|
203
|
+
|
|
157
204
|
useEffect(() => {
|
|
158
205
|
presenter.chatStreamActionsManager.bind({
|
|
159
206
|
sendMessage: async (payload) => {
|
|
@@ -19,6 +19,7 @@ import type { ChatInputSnapshot } from '@/components/chat/stores/chat-input.stor
|
|
|
19
19
|
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
20
20
|
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
21
21
|
import { ChatSessionPreferenceSync } from '@/components/chat/chat-session-preference-sync';
|
|
22
|
+
import { chatRecentModelsManager } from '@/components/chat/chat-recent-models.manager';
|
|
22
23
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
23
24
|
import { normalizeSessionType } from '@/components/chat/useChatSessionTypeState';
|
|
24
25
|
|
|
@@ -242,6 +243,7 @@ export class NcpChatInputManager {
|
|
|
242
243
|
|
|
243
244
|
selectModel = (value: string) => {
|
|
244
245
|
this.setSelectedModel(value);
|
|
246
|
+
chatRecentModelsManager.remember(value);
|
|
245
247
|
this.sessionPreferenceSync.syncSelectedSessionPreferences();
|
|
246
248
|
};
|
|
247
249
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveNcpChatRealtimeReloadAction } from '@/components/chat/ncp/ncp-chat-realtime-reload';
|
|
3
|
+
|
|
4
|
+
describe('resolveNcpChatRealtimeReloadAction', () => {
|
|
5
|
+
it('defers reload while the page is hydrating', () => {
|
|
6
|
+
expect(
|
|
7
|
+
resolveNcpChatRealtimeReloadAction({
|
|
8
|
+
isHydrating: true,
|
|
9
|
+
isRunning: false,
|
|
10
|
+
isSending: false,
|
|
11
|
+
}),
|
|
12
|
+
).toBe('defer');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('skips reload while the current session run is still active', () => {
|
|
16
|
+
expect(
|
|
17
|
+
resolveNcpChatRealtimeReloadAction({
|
|
18
|
+
isHydrating: false,
|
|
19
|
+
isRunning: true,
|
|
20
|
+
isSending: false,
|
|
21
|
+
}),
|
|
22
|
+
).toBe('skip');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('skips reload while the current page is still sending', () => {
|
|
26
|
+
expect(
|
|
27
|
+
resolveNcpChatRealtimeReloadAction({
|
|
28
|
+
isHydrating: false,
|
|
29
|
+
isRunning: false,
|
|
30
|
+
isSending: true,
|
|
31
|
+
}),
|
|
32
|
+
).toBe('skip');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reloads immediately once the current page is idle', () => {
|
|
36
|
+
expect(
|
|
37
|
+
resolveNcpChatRealtimeReloadAction({
|
|
38
|
+
isHydrating: false,
|
|
39
|
+
isRunning: false,
|
|
40
|
+
isSending: false,
|
|
41
|
+
}),
|
|
42
|
+
).toBe('reload');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type NcpChatRealtimeReloadAction = "reload" | "defer" | "skip";
|
|
2
|
+
|
|
3
|
+
export function resolveNcpChatRealtimeReloadAction(params: {
|
|
4
|
+
isHydrating: boolean;
|
|
5
|
+
isRunning: boolean;
|
|
6
|
+
isSending: boolean;
|
|
7
|
+
}): NcpChatRealtimeReloadAction {
|
|
8
|
+
if (params.isHydrating) {
|
|
9
|
+
return "defer";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// While the current page owns the active run, live stream events already
|
|
13
|
+
// update the conversation state. Rehydrating from realtime session summaries
|
|
14
|
+
// here can reintroduce transient "running" state after the run has ended.
|
|
15
|
+
if (params.isRunning || params.isSending) {
|
|
16
|
+
return "skip";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return "reload";
|
|
20
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { RecentSelectionManager } from '@/lib/recent-selection.manager';
|
|
2
|
+
|
|
3
|
+
describe('RecentSelectionManager', () => {
|
|
4
|
+
const storageKey = 'test.recent-selection-manager';
|
|
5
|
+
let storageState: Record<string, string>;
|
|
6
|
+
let storage: Pick<Storage, 'getItem' | 'setItem'>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
storageState = {};
|
|
10
|
+
storage = {
|
|
11
|
+
getItem: (key) => storageState[key] ?? null,
|
|
12
|
+
setItem: (key, value) => {
|
|
13
|
+
storageState[key] = value;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('stores recent values in LRU order and respects the size limit', () => {
|
|
19
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
|
|
20
|
+
|
|
21
|
+
manager.remember('openai/gpt-5');
|
|
22
|
+
manager.remember('anthropic/claude-sonnet-4');
|
|
23
|
+
manager.remember('minimax/MiniMax-M2.7');
|
|
24
|
+
manager.remember('openai/gpt-5');
|
|
25
|
+
|
|
26
|
+
expect(manager.read()).toEqual([
|
|
27
|
+
'openai/gpt-5',
|
|
28
|
+
'minimax/MiniMax-M2.7',
|
|
29
|
+
'anthropic/claude-sonnet-4'
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('filters recent values by the currently available list and threshold', () => {
|
|
34
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 4, storage });
|
|
35
|
+
manager.remember('openai/gpt-5');
|
|
36
|
+
manager.remember('anthropic/claude-sonnet-4');
|
|
37
|
+
manager.remember('missing/model');
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
manager.resolveVisible({
|
|
41
|
+
availableValues: [
|
|
42
|
+
'openai/gpt-5',
|
|
43
|
+
'anthropic/claude-sonnet-4',
|
|
44
|
+
'minimax/MiniMax-M2.7',
|
|
45
|
+
'deepseek/deepseek-chat',
|
|
46
|
+
'openrouter/openai/gpt-4.1',
|
|
47
|
+
'gemini/gemini-2.5-pro'
|
|
48
|
+
],
|
|
49
|
+
minAvailableCount: 5,
|
|
50
|
+
limit: 2
|
|
51
|
+
})
|
|
52
|
+
).toEqual(['anthropic/claude-sonnet-4', 'openai/gpt-5']);
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
manager.resolveVisible({
|
|
56
|
+
availableValues: ['openai/gpt-5', 'anthropic/claude-sonnet-4', 'minimax/MiniMax-M2.7'],
|
|
57
|
+
minAvailableCount: 5
|
|
58
|
+
})
|
|
59
|
+
).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns an empty list when storage content is malformed', () => {
|
|
63
|
+
storageState[storageKey] = '{broken-json';
|
|
64
|
+
const manager = new RecentSelectionManager({ storageKey, limit: 3, storage });
|
|
65
|
+
|
|
66
|
+
expect(manager.read()).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
type RecentSelectionManagerOptions = {
|
|
2
|
+
storageKey: string;
|
|
3
|
+
limit: number;
|
|
4
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
type VisibleRecentSelectionParams = {
|
|
8
|
+
availableValues: string[];
|
|
9
|
+
minAvailableCount: number;
|
|
10
|
+
limit?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export class RecentSelectionManager {
|
|
14
|
+
constructor(private readonly options: RecentSelectionManagerOptions) {}
|
|
15
|
+
|
|
16
|
+
read(): string[] {
|
|
17
|
+
const storage = this.getStorage();
|
|
18
|
+
if (!storage) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return this.normalizeList(JSON.parse(storage.getItem(this.options.storageKey) ?? '[]'));
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
remember(value: string): string[] {
|
|
29
|
+
const normalizedValue = this.normalizeValue(value);
|
|
30
|
+
if (!normalizedValue) {
|
|
31
|
+
return this.read();
|
|
32
|
+
}
|
|
33
|
+
const next = [normalizedValue, ...this.read().filter((item) => item !== normalizedValue)].slice(0, this.options.limit);
|
|
34
|
+
this.write(next);
|
|
35
|
+
return next;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
resolveVisible(params: VisibleRecentSelectionParams): string[] {
|
|
39
|
+
const availableValues = this.normalizeList(params.availableValues, Number.POSITIVE_INFINITY);
|
|
40
|
+
if (availableValues.length <= params.minAvailableCount) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const availableSet = new Set(availableValues);
|
|
44
|
+
const visible: string[] = [];
|
|
45
|
+
const maxVisibleItems = Math.max(1, params.limit ?? this.options.limit);
|
|
46
|
+
for (const value of this.read()) {
|
|
47
|
+
if (!availableSet.has(value) || visible.includes(value)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
visible.push(value);
|
|
51
|
+
if (visible.length >= maxVisibleItems) {
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return visible;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private write(values: string[]): void {
|
|
59
|
+
const storage = this.getStorage();
|
|
60
|
+
if (!storage) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
storage.setItem(this.options.storageKey, JSON.stringify(this.normalizeList(values)));
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore storage write failures and keep the runtime behavior deterministic.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private getStorage(): Storage | null {
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(this.options, 'storage')) {
|
|
72
|
+
return (this.options.storage as Storage | null | undefined) ?? null;
|
|
73
|
+
}
|
|
74
|
+
if (typeof window === 'undefined') {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
return window.localStorage;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private normalizeList(values: unknown, limit = this.options.limit): string[] {
|
|
81
|
+
if (!Array.isArray(values)) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
const deduped: string[] = [];
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
const normalized = this.normalizeValue(value);
|
|
87
|
+
if (!normalized || deduped.includes(normalized)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
deduped.push(normalized);
|
|
91
|
+
if (deduped.length >= limit) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return deduped;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private normalizeValue(value: unknown): string | null {
|
|
99
|
+
if (typeof value !== 'string') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const normalized = value.trim();
|
|
103
|
+
return normalized.length > 0 ? normalized : null;
|
|
104
|
+
}
|
|
105
|
+
}
|