@nextclaw/ui 0.11.11 → 0.11.13

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 +19 -0
  2. package/dist/assets/{ChannelsList-c6t0mn89.js → ChannelsList-BlQD1VuM.js} +4 -4
  3. package/dist/assets/ChatPage-DBvm558n.js +37 -0
  4. package/dist/assets/{DocBrowser-DNArT9C7.js → DocBrowser-DTww3NZc.js} +1 -1
  5. package/dist/assets/{LogoBadge-Bg6qKIGM.js → LogoBadge-D0ogG1ut.js} +1 -1
  6. package/dist/assets/{MarketplacePage-Bdg8GqQ6.js → MarketplacePage-DTHw6n0X.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-CU6gr58O.js → McpMarketplacePage-BikE0mBl.js} +2 -2
  8. package/dist/assets/{ModelConfig-CHlpmjUg.js → ModelConfig-CvM__Pz1.js} +1 -1
  9. package/dist/assets/{ProvidersList-CwTZF2yz.js → ProvidersList-DtZWZlL0.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-E5fT1pem.js +1 -0
  11. package/dist/assets/RuntimeConfig-DyZNiqYT.js +1 -0
  12. package/dist/assets/{SearchConfig-COmMqF50.js → SearchConfig-C1bhOCNX.js} +1 -1
  13. package/dist/assets/{SecretsConfig-u9OrM8fR.js → SecretsConfig-CYmy1Sqy.js} +2 -2
  14. package/dist/assets/{SessionsConfig-Cu8ou527.js → SessionsConfig-DSlhPpIE.js} +2 -2
  15. package/dist/assets/{chat-session-display-cb3oTJlV.js → chat-session-display-D9YuDGe3.js} +1 -1
  16. package/dist/assets/index-BBz4mi7g.js +8 -0
  17. package/dist/assets/{index-Bro-iRcb.css → index-CfVmBgkf.css} +1 -1
  18. package/dist/assets/{label-DkFojDz9.js → label-C7Xd_hqz.js} +1 -1
  19. package/dist/assets/{page-layout-DJ1cEM0C.js → page-layout-VxCaUcrD.js} +1 -1
  20. package/dist/assets/{popover-OLgPYzWf.js → popover-CC4znqAM.js} +1 -1
  21. package/dist/assets/security-config-7eVxJq8b.js +1 -0
  22. package/dist/assets/skeleton-DhZRDdHm.js +1 -0
  23. package/dist/assets/{status-dot-Cf8rkmc5.js → status-dot-Bi7Ze-LS.js} +1 -1
  24. package/dist/assets/{switch-CF529ZId.js → switch-COBEivEX.js} +1 -1
  25. package/dist/assets/{tabs-custom-BweiG3H2.js → tabs-custom-B9j40wuu.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-_kYcW1mi.js → useConfirmDialog-N8nuxOq-.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 +6 -6
  30. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +68 -1
  31. package/src/components/chat/adapters/chat-input-bar.adapter.ts +37 -6
  32. package/src/components/chat/chat-recent-models.manager.ts +8 -0
  33. package/src/components/chat/containers/chat-input-bar.container.tsx +16 -1
  34. package/src/components/chat/ncp/NcpChatPage.tsx +37 -0
  35. package/src/components/chat/ncp/ncp-chat-input.manager.ts +2 -0
  36. package/src/lib/i18n.ts +2 -0
  37. package/src/lib/recent-selection.manager.test.ts +68 -0
  38. package/src/lib/recent-selection.manager.ts +105 -0
  39. package/dist/assets/ChatPage-2A8MjFld.js +0 -37
  40. package/dist/assets/RemoteAccessPage-bBI52qCV.js +0 -1
  41. package/dist/assets/RuntimeConfig-BvifZdub.js +0 -1
  42. package/dist/assets/index-BRfdTpro.js +0 -8
  43. package/dist/assets/security-config-C1kqOE-O.js +0 -1
  44. package/dist/assets/skeleton-CZp_aCj4.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-BRfdTpro.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-BBz4mi7g.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.11",
3
+ "version": "0.11.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,11 @@
28
28
  "tailwind-merge": "^2.5.4",
29
29
  "zod": "^3.23.8",
30
30
  "zustand": "^5.0.2",
31
- "@nextclaw/agent-chat": "0.1.3",
32
- "@nextclaw/ncp": "0.4.0",
33
- "@nextclaw/ncp-http-agent-client": "0.3.4",
34
- "@nextclaw/agent-chat-ui": "0.2.13",
35
- "@nextclaw/ncp-react": "0.4.3"
31
+ "@nextclaw/ncp": "0.4.1",
32
+ "@nextclaw/agent-chat-ui": "0.2.14",
33
+ "@nextclaw/ncp-react": "0.4.5",
34
+ "@nextclaw/agent-chat": "0.1.4",
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,84 @@ 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
+ ]);
110
177
  });
111
178
  });
@@ -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,53 @@ 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 recentOptions = params.modelOptions.filter((option) => recentValueSet.has(option.value));
255
+ const remainingOptions = params.modelOptions.filter((option) => !recentValueSet.has(option.value));
256
+ const optionGroups =
257
+ recentOptions.length > 0
258
+ ? [
259
+ {
260
+ key: 'recent-models',
261
+ label: params.texts.recentModelsLabel,
262
+ options: recentOptions.map((option) => ({
263
+ value: option.value,
264
+ label: formatModelOptionLabel(option)
265
+ }))
266
+ },
267
+ {
268
+ key: 'all-models',
269
+ label: params.texts.allModelsLabel,
270
+ options: remainingOptions.map((option) => ({
271
+ value: option.value,
272
+ label: formatModelOptionLabel(option)
273
+ }))
274
+ }
275
+ ].filter((group) => group.options.length > 0)
276
+ : undefined;
244
277
 
245
278
  return {
246
279
  key: 'model',
247
280
  value: resolvedValue,
248
281
  placeholder: params.texts.modelSelectPlaceholder,
249
- selectedLabel: resolvedModelOption
250
- ? `${resolvedModelOption.providerLabel}/${resolvedModelOption.modelLabel}`
251
- : undefined,
282
+ selectedLabel: resolvedModelOption ? formatModelOptionLabel(resolvedModelOption) : undefined,
252
283
  icon: 'sparkles',
253
284
  options: params.modelOptions.map((option) => ({
254
285
  value: option.value,
255
- label: option.modelLabel,
256
- description: option.providerLabel
286
+ label: formatModelOptionLabel(option)
257
287
  })),
288
+ groups: optionGroups,
258
289
  disabled: !params.hasModelOptions,
259
290
  loading: params.isModelOptionsLoading,
260
291
  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,14 @@ export function ChatInputBarContainer() {
104
108
  [snapshot.skillRecords, officialSkillBadgeLabel]
105
109
  );
106
110
  const modelRecords = useMemo(() => toModelRecords(snapshot.modelOptions), [snapshot.modelOptions]);
111
+ const recentModelValues = useMemo(
112
+ () =>
113
+ chatRecentModelsManager.resolveVisible({
114
+ availableValues: modelRecords.map((option) => option.value),
115
+ minAvailableCount: CHAT_RECENT_MODELS_MIN_OPTIONS
116
+ }),
117
+ [modelRecords, snapshot.selectedModel]
118
+ );
107
119
 
108
120
  const hasModelOptions = modelRecords.length > 0;
109
121
  const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
@@ -171,13 +183,16 @@ export function ChatInputBarContainer() {
171
183
  const toolbarSelects = [
172
184
  buildModelToolbarSelect({
173
185
  modelOptions: modelRecords,
186
+ recentModelValues,
174
187
  selectedModel: snapshot.selectedModel,
175
188
  isModelOptionsLoading,
176
189
  hasModelOptions,
177
190
  onValueChange: presenter.chatInputManager.selectModel,
178
191
  texts: {
179
192
  modelSelectPlaceholder: t('chatSelectModel'),
180
- modelNoOptionsLabel: t('chatModelNoOptions')
193
+ modelNoOptionsLabel: t('chatModelNoOptions'),
194
+ recentModelsLabel: t('chatRecentModels'),
195
+ allModelsLabel: t('chatAllModels')
181
196
  }
182
197
  }),
183
198
  buildThinkingToolbarSelect({
@@ -22,6 +22,7 @@ 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';
25
26
 
26
27
  function buildNcpSendMetadata(payload: {
27
28
  model?: string;
@@ -69,6 +70,7 @@ export function NcpChatPage({ view }: ChatPageProps) {
69
70
  const { sessionId: routeSessionIdParam } = useParams<{ sessionId?: string }>();
70
71
  const threadRef = useRef<HTMLDivElement | null>(null);
71
72
  const selectedSessionKeyRef = useRef<string | null>(selectedSessionKey);
73
+ const pendingRealtimeReloadRef = useRef(false);
72
74
  const routeSessionKey = useMemo(
73
75
  () => parseSessionKeyFromRoute(routeSessionIdParam),
74
76
  [routeSessionIdParam]
@@ -154,6 +156,41 @@ export function NcpChatPage({ view }: ChatPageProps) {
154
156
  const stopDisabledReason = agent.isRunning ? null : '__preparing__';
155
157
  const lastSendError = agent.hydrateError?.message ?? agent.snapshot.error?.message ?? null;
156
158
 
159
+ useEffect(() => {
160
+ const flushRealtimeReload = () => {
161
+ if (agent.isHydrating || agent.isRunning || agent.isSending) {
162
+ pendingRealtimeReloadRef.current = true;
163
+ return;
164
+ }
165
+ pendingRealtimeReloadRef.current = false;
166
+ void agent.reloadSeed();
167
+ };
168
+
169
+ return appClient.subscribe((event) => {
170
+ if (event.type === 'session.summary.upsert') {
171
+ if (event.payload.summary.sessionId !== activeSessionId) {
172
+ return;
173
+ }
174
+ flushRealtimeReload();
175
+ return;
176
+ }
177
+ if (event.type === 'session.updated' && event.payload.sessionKey === activeSessionId) {
178
+ flushRealtimeReload();
179
+ }
180
+ });
181
+ }, [activeSessionId, agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
182
+
183
+ useEffect(() => {
184
+ if (!pendingRealtimeReloadRef.current) {
185
+ return;
186
+ }
187
+ if (agent.isHydrating || agent.isRunning || agent.isSending) {
188
+ return;
189
+ }
190
+ pendingRealtimeReloadRef.current = false;
191
+ void agent.reloadSeed();
192
+ }, [agent.isHydrating, agent.isRunning, agent.isSending, agent.reloadSeed]);
193
+
157
194
  useEffect(() => {
158
195
  presenter.chatStreamActionsManager.bind({
159
196
  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
 
package/src/lib/i18n.ts CHANGED
@@ -185,6 +185,8 @@ export const LABELS: Record<string, { zh: string; en: string }> = {
185
185
  zh: 'Agent 默认模型标识,使用带 provider 前缀的格式。例如:openai/gpt-5.1、anthropic/claude-opus-4-1、deepseek/deepseek-chat、minimax/MiniMax-M2.5、openrouter/openai/gpt-5.3-codex。',
186
186
  en: 'Default model identifier used by the agent. Use provider-prefixed format. Examples: openai/gpt-5.1 · anthropic/claude-opus-4-1 · deepseek/deepseek-chat · minimax/MiniMax-M2.5 · openrouter/openai/gpt-5.3-codex.'
187
187
  },
188
+ chatRecentModels: { zh: '最近选择', en: 'Recent' },
189
+ chatAllModels: { zh: '全部模型', en: 'All models' },
188
190
  maxToolIterations: { zh: '最大工具迭代次数', en: 'Max Tool Iterations' },
189
191
  saveChanges: { zh: '保存变更', en: 'Save Changes' },
190
192
 
@@ -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
+ }