@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/{ChannelsList-C63gOoYI.js → ChannelsList-CvK4qHfg.js} +4 -4
  3. package/dist/assets/ChatPage-Co3GqIVP.js +37 -0
  4. package/dist/assets/{DocBrowser-CI4jOzJY.js → DocBrowser-BFmW6e-4.js} +1 -1
  5. package/dist/assets/{LogoBadge-DImV63-L.js → LogoBadge-DZL-zQTr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-B360oSAV.js → MarketplacePage-B__MZRrD.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-KIQgx_7h.js → McpMarketplacePage-C_VKm1uq.js} +2 -2
  8. package/dist/assets/{ModelConfig-Ben3tQoX.js → ModelConfig-CqJubuwU.js} +1 -1
  9. package/dist/assets/{ProvidersList-DE-S9mq0.js → ProvidersList-BoSsFBk5.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-S1ChRWMX.js +1 -0
  11. package/dist/assets/RuntimeConfig-WnFUsayT.js +1 -0
  12. package/dist/assets/{SearchConfig-DeOa-M6j.js → SearchConfig-D9V07oqj.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Ci8pJmzd.js → SecretsConfig-Ci8sEzaV.js} +2 -2
  14. package/dist/assets/{SessionsConfig-B6zq55yu.js → SessionsConfig-5Nznhx9P.js} +2 -2
  15. package/dist/assets/{chat-session-display--oo5yuIw.js → chat-session-display-D0ZcEkUq.js} +1 -1
  16. package/dist/assets/{index-LhlkB00c.js → index-BvCYcN48.js} +3 -3
  17. package/dist/assets/{index-Bro-iRcb.css → index-CfVmBgkf.css} +1 -1
  18. package/dist/assets/{label-3TKt0PoZ.js → label-AurG3ZpO.js} +1 -1
  19. package/dist/assets/{page-layout-CopkIM3Q.js → page-layout-Q2hHkfJy.js} +1 -1
  20. package/dist/assets/{popover-CUx8uRJw.js → popover-BKInm43u.js} +1 -1
  21. package/dist/assets/security-config-BbPGNJAB.js +1 -0
  22. package/dist/assets/skeleton-CuKw6-Ww.js +1 -0
  23. package/dist/assets/{status-dot-D6vJMwD7.js → status-dot-DLk8UxLB.js} +1 -1
  24. package/dist/assets/{switch-A3-ClT1P.js → switch-BxMSKsQS.js} +1 -1
  25. package/dist/assets/{tabs-custom-BVSd5urq.js → tabs-custom-B6gK-RY6.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-ChPriea6.js → useConfirmDialog-Dth62a0a.js} +1 -1
  27. package/dist/assets/{vendor-BEQcLDx6.js → vendor-MCpnpiKt.js} +35 -35
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +107 -1
  31. package/src/components/chat/adapters/chat-input-bar.adapter.ts +40 -6
  32. package/src/components/chat/chat-recent-models.manager.ts +8 -0
  33. package/src/components/chat/containers/chat-input-bar.container.tsx +14 -1
  34. package/src/components/chat/ncp/NcpChatPage.tsx +47 -0
  35. package/src/components/chat/ncp/ncp-chat-input.manager.ts +2 -0
  36. package/src/components/chat/ncp/ncp-chat-realtime-reload.test.ts +44 -0
  37. package/src/components/chat/ncp/ncp-chat-realtime-reload.ts +20 -0
  38. package/src/lib/recent-selection.manager.test.ts +68 -0
  39. package/src/lib/recent-selection.manager.ts +105 -0
  40. package/dist/assets/ChatPage-Ci3Gz0qh.js +0 -37
  41. package/dist/assets/RemoteAccessPage-DxUia6R-.js +0 -1
  42. package/dist/assets/RuntimeConfig-CQcGfNZT.js +0 -1
  43. package/dist/assets/security-config-BL29kTzz.js +0 -1
  44. 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-LhlkB00c.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-BEQcLDx6.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-Bro-iRcb.css">
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.12",
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-http-agent-client": "0.3.5",
35
- "@nextclaw/ncp-react": "0.4.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.modelLabel,
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,
@@ -0,0 +1,8 @@
1
+ import { RecentSelectionManager } from '@/lib/recent-selection.manager';
2
+
3
+ export const chatRecentModelsManager = new RecentSelectionManager({
4
+ storageKey: 'nextclaw.chat.recent-models',
5
+ limit: 3
6
+ });
7
+
8
+ export const CHAT_RECENT_MODELS_MIN_OPTIONS = 5;
@@ -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
+ }