@chat-js/cli 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.js +216 -171
  2. package/package.json +1 -1
  3. package/templates/chat-app/CHANGELOG.md +19 -0
  4. package/templates/chat-app/app/(chat)/actions.ts +9 -9
  5. package/templates/chat-app/app/(chat)/api/chat/prepare/route.ts +94 -0
  6. package/templates/chat-app/app/(chat)/api/chat/route.ts +97 -14
  7. package/templates/chat-app/chat.config.ts +144 -156
  8. package/templates/chat-app/components/chat-sync.tsx +6 -3
  9. package/templates/chat-app/components/feedback-actions.tsx +7 -3
  10. package/templates/chat-app/components/message-editor.tsx +8 -3
  11. package/templates/chat-app/components/message-siblings.tsx +14 -1
  12. package/templates/chat-app/components/model-selector.tsx +669 -407
  13. package/templates/chat-app/components/multimodal-input.tsx +252 -18
  14. package/templates/chat-app/components/parallel-response-cards.tsx +157 -0
  15. package/templates/chat-app/components/part/text-message-part.tsx +9 -5
  16. package/templates/chat-app/components/retry-button.tsx +25 -8
  17. package/templates/chat-app/components/user-message.tsx +136 -125
  18. package/templates/chat-app/hooks/chat-sync-hooks.ts +11 -0
  19. package/templates/chat-app/hooks/use-navigate-to-message.ts +39 -0
  20. package/templates/chat-app/lib/ai/gateway-model-defaults.ts +154 -100
  21. package/templates/chat-app/lib/ai/gateways/openrouter-gateway.ts +2 -2
  22. package/templates/chat-app/lib/ai/tools/generate-image.ts +9 -2
  23. package/templates/chat-app/lib/ai/tools/generate-video.ts +3 -0
  24. package/templates/chat-app/lib/ai/types.ts +74 -3
  25. package/templates/chat-app/lib/config-schema.ts +131 -132
  26. package/templates/chat-app/lib/config.ts +2 -2
  27. package/templates/chat-app/lib/db/migrations/0044_gray_red_shift.sql +5 -0
  28. package/templates/chat-app/lib/db/migrations/meta/0044_snapshot.json +1567 -0
  29. package/templates/chat-app/lib/db/migrations/meta/_journal.json +8 -1
  30. package/templates/chat-app/lib/db/queries.ts +84 -4
  31. package/templates/chat-app/lib/db/schema.ts +4 -1
  32. package/templates/chat-app/lib/message-conversion.ts +14 -2
  33. package/templates/chat-app/lib/stores/hooks-threads.ts +37 -1
  34. package/templates/chat-app/lib/stores/with-threads.test.ts +137 -0
  35. package/templates/chat-app/lib/stores/with-threads.ts +157 -4
  36. package/templates/chat-app/lib/thread-utils.ts +23 -2
  37. package/templates/chat-app/package.json +1 -1
  38. package/templates/chat-app/providers/chat-input-provider.tsx +40 -2
  39. package/templates/chat-app/scripts/db-branch-delete.sh +7 -1
  40. package/templates/chat-app/scripts/db-branch-use.sh +7 -1
  41. package/templates/chat-app/scripts/with-db.sh +7 -1
  42. package/templates/chat-app/vitest.config.ts +2 -0
@@ -1,34 +1,50 @@
1
1
  "use client";
2
2
 
3
- import { ChevronRightIcon, ChevronUpIcon, FilterIcon } from "lucide-react";
3
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, FilterIcon } from "lucide-react";
4
4
  import Link from "next/link";
5
5
  import {
6
- memo,
7
- startTransition,
8
- useCallback,
9
- useMemo,
10
- useOptimistic,
11
- useState,
6
+ memo,
7
+ type ReactNode,
8
+ startTransition,
9
+ useCallback,
10
+ useEffect,
11
+ useMemo,
12
+ useOptimistic,
13
+ useRef,
14
+ useState,
12
15
  } from "react";
13
16
  import { Badge } from "@/components/ui/badge";
14
17
  import { Button } from "@/components/ui/button";
15
18
  import { Checkbox } from "@/components/ui/checkbox";
16
19
  import {
17
- Command,
18
- CommandEmpty,
19
- CommandGroup,
20
- CommandInput,
21
- CommandList,
22
- CommandItem as UICommandItem,
20
+ Command,
21
+ CommandEmpty,
22
+ CommandGroup,
23
+ CommandInput,
24
+ CommandList,
25
+ CommandItem as UICommandItem,
23
26
  } from "@/components/ui/command";
27
+ import {
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuTrigger,
32
+ } from "@/components/ui/dropdown-menu";
24
33
  import { Label } from "@/components/ui/label";
25
34
  import {
26
- Popover,
27
- PopoverContent,
28
- PopoverTrigger,
35
+ Popover,
36
+ PopoverContent,
37
+ PopoverTrigger,
29
38
  } from "@/components/ui/popover";
39
+ import { Switch } from "@/components/ui/switch";
30
40
  import { LoginCtaBanner } from "@/components/upgrade-cta/login-cta-banner";
31
41
  import type { AppModelDefinition, AppModelId } from "@/lib/ai/app-models";
42
+ import {
43
+ getPrimarySelectedModelId,
44
+ isSelectedModelCounts,
45
+ type SelectedModelValue,
46
+ } from "@/lib/ai/types";
47
+ import { config } from "@/lib/config";
32
48
  import { getEnabledFeatures } from "@/lib/features-config";
33
49
  import { ANONYMOUS_LIMITS } from "@/lib/types/anonymous";
34
50
  import { cn } from "@/lib/utils";
@@ -40,414 +56,660 @@ type FeatureFilter = Record<string, boolean>;
40
56
 
41
57
  const enabledFeatures = getEnabledFeatures();
42
58
  const initialFilters = enabledFeatures.reduce<FeatureFilter>((acc, feature) => {
43
- acc[feature.key] = false;
44
- return acc;
59
+ acc[feature.key] = false;
60
+ return acc;
45
61
  }, {});
46
62
 
47
63
  function getFeatureIcons(model: AppModelDefinition) {
48
- const icons: React.ReactNode[] = [];
49
- const enabled = getEnabledFeatures();
50
-
51
- const featureIconMap = [
52
- {
53
- key: "functionCalling",
54
- condition: model.toolCall,
55
- config: enabled.find((f) => f.key === "functionCalling"),
56
- },
57
- {
58
- key: "imageInput",
59
- condition: model.input?.image,
60
- config: enabled.find((f) => f.key === "imageInput"),
61
- },
62
- {
63
- key: "pdfInput",
64
- condition: model.input?.pdf,
65
- config: enabled.find((f) => f.key === "pdfInput"),
66
- },
67
- ];
68
-
69
- for (const { condition, config } of featureIconMap) {
70
- if (condition && config) {
71
- const IconComponent = config.icon;
72
- icons.push(
73
- <div
74
- className="flex items-center"
75
- key={config.key}
76
- title={config.description}
77
- >
78
- <IconComponent className="h-3 w-3 text-muted-foreground" />
79
- </div>
80
- );
81
- }
82
- }
83
-
84
- return icons;
64
+ const icons: React.ReactNode[] = [];
65
+ const enabled = getEnabledFeatures();
66
+
67
+ const featureIconMap = [
68
+ {
69
+ key: "functionCalling",
70
+ condition: model.toolCall,
71
+ config: enabled.find((f) => f.key === "functionCalling"),
72
+ },
73
+ {
74
+ key: "imageInput",
75
+ condition: model.input?.image,
76
+ config: enabled.find((f) => f.key === "imageInput"),
77
+ },
78
+ {
79
+ key: "pdfInput",
80
+ condition: model.input?.pdf,
81
+ config: enabled.find((f) => f.key === "pdfInput"),
82
+ },
83
+ ];
84
+
85
+ for (const { condition, config } of featureIconMap) {
86
+ if (condition && config) {
87
+ const IconComponent = config.icon;
88
+ icons.push(
89
+ <div
90
+ className="flex items-center"
91
+ key={config.key}
92
+ title={config.description}
93
+ >
94
+ <IconComponent className="h-3 w-3 text-muted-foreground" />
95
+ </div>,
96
+ );
97
+ }
98
+ }
99
+
100
+ return icons;
101
+ }
102
+
103
+ function buildMultiModelSelection(
104
+ modelIds: AppModelId[],
105
+ ): Record<AppModelId, number> {
106
+ return modelIds.reduce<Record<AppModelId, number>>(
107
+ (acc, modelId) => {
108
+ acc[modelId] = 1;
109
+ return acc;
110
+ },
111
+ {} as Record<AppModelId, number>,
112
+ );
113
+ }
114
+
115
+ function getSelectionCount(selection: SelectedModelValue): number {
116
+ if (typeof selection === "string") {
117
+ return 1;
118
+ }
119
+
120
+ return Object.values(selection).reduce((count, value) => count + value, 0);
85
121
  }
86
122
 
87
123
  function PureCommandItem({
88
- model,
89
- disabled,
90
- isSelected,
91
- onSelect,
124
+ model,
125
+ disabled,
126
+ isSelected,
127
+ count,
128
+ selectionControl,
129
+ onSelect,
130
+ onCountChange,
92
131
  }: {
93
- model: AppModelDefinition;
94
- disabled?: boolean;
95
- isSelected: boolean;
96
- onSelect: () => void;
132
+ model: AppModelDefinition;
133
+ disabled?: boolean;
134
+ isSelected: boolean;
135
+ count?: number;
136
+ selectionControl?: ReactNode;
137
+ onSelect: () => void;
138
+ onCountChange?: (delta: number) => void;
97
139
  }) {
98
- const featureIcons = useMemo(() => getFeatureIcons(model), [model]);
99
- const searchValue = useMemo(
100
- () =>
101
- `${model.name} ${model.reasoning ? "reasoning" : ""} ${model.owned_by} `.toLowerCase(),
102
- [model]
103
- );
104
-
105
- const reasoningConfig = useMemo(
106
- () => getEnabledFeatures().find((f) => f.key === "reasoning"),
107
- []
108
- );
109
-
110
- return (
111
- <UICommandItem
112
- className={cn(
113
- "flex h-9 w-full cursor-pointer items-center justify-between px-3 py-1.5 transition-all",
114
- isSelected && "border-l-2 border-l-primary bg-primary/10",
115
- disabled && "cursor-not-allowed opacity-50"
116
- )}
117
- onSelect={() => !disabled && onSelect()}
118
- value={searchValue}
119
- >
120
- <div className="flex min-w-0 flex-1 items-center gap-2.5">
121
- <div className="shrink-0">
122
- <ModelSelectorLogo modelId={model.id} />
123
- </div>
124
- <span className="flex items-center gap-1.5 truncate font-medium text-sm">
125
- {model.name}
126
- {model.reasoning && reasoningConfig && (
127
- <span
128
- className="inline-flex shrink-0 items-center gap-1"
129
- title={reasoningConfig.description}
130
- >
131
- <reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
132
- </span>
133
- )}
134
- </span>
135
- </div>
136
- {featureIcons.length > 0 && (
137
- <div className="flex shrink-0 items-center gap-1">{featureIcons}</div>
138
- )}
139
- </UICommandItem>
140
- );
140
+ const featureIcons = useMemo(() => getFeatureIcons(model), [model]);
141
+ const searchValue = useMemo(
142
+ () =>
143
+ `${model.name} ${model.reasoning ? "reasoning" : ""} ${model.owned_by} `.toLowerCase(),
144
+ [model],
145
+ );
146
+
147
+ const reasoningConfig = useMemo(
148
+ () => getEnabledFeatures().find((f) => f.key === "reasoning"),
149
+ [],
150
+ );
151
+
152
+ return (
153
+ <UICommandItem
154
+ className={cn(
155
+ "flex h-9 w-full cursor-pointer items-center justify-between px-3 py-1.5 transition-all",
156
+ isSelected && "border-l-2 border-l-primary bg-primary/10",
157
+ disabled && "cursor-not-allowed opacity-50",
158
+ )}
159
+ onSelect={() => !disabled && onSelect()}
160
+ value={searchValue}
161
+ >
162
+ <div className="flex min-w-0 flex-1 items-center gap-2.5">
163
+ {selectionControl}
164
+ <div className="shrink-0">
165
+ <ModelSelectorLogo modelId={model.id} />
166
+ </div>
167
+ <span className="flex items-center gap-1.5 truncate font-medium text-sm">
168
+ {model.name}
169
+ {model.reasoning && reasoningConfig && (
170
+ <span
171
+ className="inline-flex shrink-0 items-center gap-1"
172
+ title={reasoningConfig.description}
173
+ >
174
+ <reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
175
+ </span>
176
+ )}
177
+ </span>
178
+ </div>
179
+ <div className="flex shrink-0 items-center gap-1">
180
+ {featureIcons}
181
+ {isSelected && onCountChange && count !== undefined && (
182
+ <DropdownMenu>
183
+ <DropdownMenuTrigger
184
+ asChild
185
+ onClick={(e) => e.stopPropagation()}
186
+ onMouseDown={(e) => e.stopPropagation()}
187
+ >
188
+ <button
189
+ className="flex items-center gap-0.5 rounded-full bg-primary/15 px-1.5 py-0.5 font-semibold text-foreground text-xs tabular-nums hover:bg-primary/25"
190
+ type="button"
191
+ >
192
+ {count}×
193
+ <ChevronDownIcon className="h-2.5 w-2.5" />
194
+ </button>
195
+ </DropdownMenuTrigger>
196
+ <DropdownMenuContent
197
+ align="end"
198
+ onKeyDown={(e) => e.stopPropagation()}
199
+ >
200
+ {[1, 2, 3, 4].map((n) => (
201
+ <DropdownMenuItem
202
+ key={n}
203
+ onClick={(e) => {
204
+ e.stopPropagation();
205
+ onCountChange(n - count);
206
+ }}
207
+ >
208
+ {n}x
209
+ {n === count && (
210
+ <CheckIcon className="ml-auto h-3 w-3" />
211
+ )}
212
+ </DropdownMenuItem>
213
+ ))}
214
+ </DropdownMenuContent>
215
+ </DropdownMenu>
216
+ )}
217
+ </div>
218
+ </UICommandItem>
219
+ );
141
220
  }
142
221
 
143
222
  const CommandItem = memo(
144
- PureCommandItem,
145
- (prev, next) =>
146
- prev.model.id === next.model.id &&
147
- prev.disabled === next.disabled &&
148
- prev.isSelected === next.isSelected
223
+ PureCommandItem,
224
+ (prev, next) =>
225
+ prev.model.id === next.model.id &&
226
+ prev.disabled === next.disabled &&
227
+ prev.isSelected === next.isSelected &&
228
+ prev.count === next.count &&
229
+ (prev.onCountChange !== undefined) === (next.onCountChange !== undefined),
149
230
  );
150
231
 
151
232
  function PureModelSelector({
152
- selectedModelId,
153
- className,
154
- onModelChangeAction,
233
+ selectedModelId,
234
+ selectedModelSelection,
235
+ className,
236
+ onModelSelectionChangeAction,
155
237
  }: {
156
- selectedModelId: AppModelId;
157
- onModelChangeAction?: (modelId: AppModelId) => void;
158
- className?: string;
238
+ selectedModelId: AppModelId;
239
+ selectedModelSelection: SelectedModelValue;
240
+ onModelSelectionChangeAction?: (selection: SelectedModelValue) => void;
241
+ className?: string;
159
242
  }) {
160
- const { data: session } = useSession();
161
- const isAnonymous = !session?.user;
162
- const { models: chatModels, allModels } = useChatModels();
163
-
164
- const [open, setOpen] = useState(false);
165
- const [filterOpen, setFilterOpen] = useState(false);
166
- const [optimisticModelId, setOptimisticModelId] =
167
- useOptimistic(selectedModelId);
168
- const [featureFilters, setFeatureFilters] =
169
- useState<FeatureFilter>(initialFilters);
170
-
171
- interface ModelItem {
172
- disabled: boolean;
173
- model: AppModelDefinition;
174
- }
175
-
176
- const models = useMemo<ModelItem[]>(
177
- () =>
178
- chatModels.map((m) => ({
179
- model: m,
180
- disabled:
181
- isAnonymous &&
182
- !(
183
- ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
184
- ).includes(m.id),
185
- })),
186
- [isAnonymous, chatModels]
187
- );
188
-
189
- const hasDisabledModels = useMemo(
190
- () => models.some((m) => m.disabled),
191
- [models]
192
- );
193
-
194
- const filteredModels = useMemo(() => {
195
- const hasActiveFilters = Object.values(featureFilters).some(Boolean);
196
- if (!hasActiveFilters) {
197
- return models;
198
- }
199
-
200
- return models.filter(({ model }) =>
201
- Object.entries(featureFilters).every(([key, isActive]) => {
202
- if (!isActive) {
203
- return true;
204
- }
205
- switch (key) {
206
- case "reasoning":
207
- return model.reasoning;
208
- case "functionCalling":
209
- return model.toolCall;
210
- case "imageInput":
211
- return model.input?.image;
212
- case "pdfInput":
213
- return model.input?.pdf;
214
- case "audioInput":
215
- return model.input?.audio;
216
- case "imageOutput":
217
- return model.output?.image;
218
- case "audioOutput":
219
- return model.output?.audio;
220
- default:
221
- return true;
222
- }
223
- })
224
- );
225
- }, [models, featureFilters]);
226
-
227
- const selectedItem = useMemo<ModelItem | null>(() => {
228
- // First try to find in filtered models (user's enabled models)
229
- const found = models.find((m) => m.model.id === optimisticModelId);
230
- if (found) {
231
- return found;
232
- }
233
-
234
- // Fallback: look in all models to at least display the model name
235
- // This handles cases where preferences are loading or model was disabled
236
- const fallbackModel = allModels.find((m) => m.id === optimisticModelId);
237
- if (fallbackModel) {
238
- return {
239
- model: fallbackModel,
240
- disabled:
241
- isAnonymous &&
242
- !(
243
- ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
244
- ).includes(fallbackModel.id),
245
- } satisfies ModelItem;
246
- }
247
-
248
- return null;
249
- }, [models, allModels, optimisticModelId, isAnonymous]);
250
- const reasoningConfig = useMemo(
251
- () => getEnabledFeatures().find((f) => f.key === "reasoning"),
252
- []
253
- );
254
- const activeFilterCount = useMemo(
255
- () => Object.values(featureFilters).filter(Boolean).length,
256
- [featureFilters]
257
- );
258
-
259
- const selectModel = useCallback(
260
- (id: AppModelId) => {
261
- startTransition(() => {
262
- setOptimisticModelId(id);
263
- onModelChangeAction?.(id);
264
- setOpen(false);
265
- });
266
- },
267
- [onModelChangeAction, setOptimisticModelId]
268
- );
269
-
270
- return (
271
- <Popover onOpenChange={setOpen} open={open}>
272
- <PopoverTrigger asChild>
273
- <Button
274
- aria-expanded={open}
275
- className={cn("flex w-fit justify-between gap-2 md:px-2", className)}
276
- data-testid="model-selector"
277
- role="combobox"
278
- variant="ghost"
279
- >
280
- <div className="flex items-center gap-2">
281
- {selectedItem && (
282
- <div className="shrink-0">
283
- <ModelSelectorLogo modelId={selectedItem.model.id} />
284
- </div>
285
- )}
286
- <p className="inline-flex items-center gap-1.5 truncate">
287
- {selectedItem?.model.name || "Select model"}
288
- {selectedItem?.model.reasoning && reasoningConfig && (
289
- <span
290
- className="inline-flex shrink-0 items-center gap-1"
291
- title={reasoningConfig.description}
292
- >
293
- <reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
294
- </span>
295
- )}
296
- </p>
297
- </div>
298
- <ChevronUpIcon
299
- className={cn(
300
- "h-4 w-4 shrink-0 opacity-50 transition-transform",
301
- open && "rotate-180"
302
- )}
303
- />
304
- </Button>
305
- </PopoverTrigger>
306
- <PopoverContent
307
- align="start"
308
- className="w-[350px] p-0"
309
- onInteractOutside={(e) => {
310
- // Prevent closing when interacting with nested filter popover
311
- if (
312
- (e.target as HTMLElement).closest('[data-slot="command-input"]')
313
- ) {
314
- e.preventDefault();
315
- }
316
- }}
317
- >
318
- {open && (
319
- <Command>
320
- <div className="flex items-center border-b">
321
- <CommandInput
322
- className="px-3"
323
- containerClassName="w-full border-0 h-11"
324
- onClick={(e) => {
325
- // Prevent closing when interacting with nested filter popover
326
- e.stopPropagation();
327
- }}
328
- placeholder="Search models..."
329
- />
330
- <Popover onOpenChange={setFilterOpen} open={filterOpen}>
331
- <PopoverTrigger asChild>
332
- <Button
333
- className={cn(
334
- "relative mr-3 h-8 w-8 p-0",
335
- activeFilterCount > 0 && "text-primary"
336
- )}
337
- size="sm"
338
- variant="ghost"
339
- >
340
- <FilterIcon className="h-4 w-4" />
341
- {activeFilterCount > 0 && (
342
- <Badge
343
- className="absolute -top-1 -right-1 flex h-4 min-w-[16px] items-center justify-center p-0 text-xs"
344
- variant="secondary"
345
- >
346
- {activeFilterCount}
347
- </Badge>
348
- )}
349
- </Button>
350
- </PopoverTrigger>
351
- <PopoverContent align="end" className="p-0">
352
- <div className="p-4">
353
- <div className="mb-3 flex h-7 items-center justify-between">
354
- <div className="font-medium text-sm">Filter by Tools</div>
355
- {activeFilterCount > 0 && (
356
- <Button
357
- className="h-6 text-xs"
358
- onClick={() => setFeatureFilters(initialFilters)}
359
- size="sm"
360
- variant="ghost"
361
- >
362
- Clear filters
363
- </Button>
364
- )}
365
- </div>
366
- <div className="grid grid-cols-1 gap-2">
367
- {enabledFeatures.map((feature) => {
368
- const IconComponent = feature.icon;
369
- return (
370
- <div
371
- className="flex items-center space-x-2"
372
- key={feature.key}
373
- >
374
- <Checkbox
375
- checked={featureFilters[feature.key]}
376
- id={feature.key}
377
- onCheckedChange={(checked) =>
378
- setFeatureFilters((prev) => ({
379
- ...prev,
380
- [feature.key]: Boolean(checked),
381
- }))
382
- }
383
- />
384
- <Label
385
- className="flex items-center gap-1.5 text-sm"
386
- htmlFor={feature.key}
387
- >
388
- <IconComponent className="h-3.5 w-3.5" />
389
- {feature.name}
390
- </Label>
391
- </div>
392
- );
393
- })}
394
- </div>
395
- </div>
396
- </PopoverContent>
397
- </Popover>
398
- </div>
399
- {hasDisabledModels && (
400
- <div className="p-3">
401
- <LoginCtaBanner
402
- compact
403
- message="Sign in to unlock all models."
404
- variant="default"
405
- />
406
- </div>
407
- )}
408
- <CommandList
409
- className="max-h-[400px]"
410
- onMouseDown={(e) => e.stopPropagation()}
411
- >
412
- <CommandEmpty>No model found.</CommandEmpty>
413
- <CommandGroup>
414
- {filteredModels.map(({ model, disabled }) => (
415
- <CommandItem
416
- disabled={disabled}
417
- isSelected={model.id === optimisticModelId}
418
- key={model.id}
419
- model={model}
420
- onSelect={() => selectModel(model.id)}
421
- />
422
- ))}
423
- </CommandGroup>
424
- </CommandList>
425
- {!isAnonymous && (
426
- <div className="border-t p-2">
427
- <Button
428
- asChild
429
- className="w-full justify-between"
430
- size="sm"
431
- variant="ghost"
432
- >
433
- <Link aria-label="Add Models" href="/settings/models">
434
- Add Models
435
- <ChevronRightIcon className="h-4 w-4" />
436
- </Link>
437
- </Button>
438
- </div>
439
- )}
440
- </Command>
441
- )}
442
- </PopoverContent>
443
- </Popover>
444
- );
243
+ const { data: session } = useSession();
244
+ const isAnonymous = !session?.user;
245
+ const { models: chatModels, allModels } = useChatModels();
246
+
247
+ const [open, setOpen] = useState(false);
248
+ const [filterOpen, setFilterOpen] = useState(false);
249
+ const [optimisticSelection, setOptimisticSelection] = useOptimistic(
250
+ selectedModelSelection,
251
+ );
252
+ // Ref so callbacks don't capture stale optimisticSelection in their closure
253
+ const optimisticSelectionRef = useRef(optimisticSelection);
254
+ optimisticSelectionRef.current = optimisticSelection;
255
+ const [featureFilters, setFeatureFilters] =
256
+ useState<FeatureFilter>(initialFilters);
257
+ const [useMultipleModels, setUseMultipleModels] = useState(
258
+ isSelectedModelCounts(selectedModelSelection),
259
+ );
260
+
261
+ interface ModelItem {
262
+ disabled: boolean;
263
+ model: AppModelDefinition;
264
+ }
265
+
266
+ useEffect(() => {
267
+ setUseMultipleModels(isSelectedModelCounts(selectedModelSelection));
268
+ }, [selectedModelSelection]);
269
+
270
+ const optimisticModelId = useMemo(
271
+ () => getPrimarySelectedModelId(optimisticSelection) ?? selectedModelId,
272
+ [optimisticSelection, selectedModelId],
273
+ );
274
+
275
+ const selectedModelIds = useMemo(() => {
276
+ if (typeof optimisticSelection === "string") {
277
+ return new Set<AppModelId>([optimisticSelection]);
278
+ }
279
+
280
+ return new Set<AppModelId>(
281
+ Object.entries(optimisticSelection)
282
+ .filter(([, count]) => count > 0)
283
+ .map(([modelId]) => modelId as AppModelId),
284
+ );
285
+ }, [optimisticSelection]);
286
+
287
+ const models = useMemo<ModelItem[]>(
288
+ () =>
289
+ chatModels.map((m) => ({
290
+ model: m,
291
+ disabled:
292
+ isAnonymous &&
293
+ !(
294
+ ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
295
+ ).includes(m.id),
296
+ })),
297
+ [isAnonymous, chatModels],
298
+ );
299
+
300
+ const hasDisabledModels = useMemo(
301
+ () => models.some((m) => m.disabled),
302
+ [models],
303
+ );
304
+
305
+ const filteredModels = useMemo(() => {
306
+ const hasActiveFilters = Object.values(featureFilters).some(Boolean);
307
+ if (!hasActiveFilters) {
308
+ return models;
309
+ }
310
+
311
+ return models.filter(({ model }) =>
312
+ Object.entries(featureFilters).every(([key, isActive]) => {
313
+ if (!isActive) {
314
+ return true;
315
+ }
316
+ switch (key) {
317
+ case "reasoning":
318
+ return model.reasoning;
319
+ case "functionCalling":
320
+ return model.toolCall;
321
+ case "imageInput":
322
+ return model.input?.image;
323
+ case "pdfInput":
324
+ return model.input?.pdf;
325
+ case "audioInput":
326
+ return model.input?.audio;
327
+ case "imageOutput":
328
+ return model.output?.image;
329
+ case "audioOutput":
330
+ return model.output?.audio;
331
+ default:
332
+ return true;
333
+ }
334
+ }),
335
+ );
336
+ }, [models, featureFilters]);
337
+
338
+ const selectedItem = useMemo<ModelItem | null>(() => {
339
+ // First try to find in filtered models (user's enabled models)
340
+ const found = models.find((m) => m.model.id === optimisticModelId);
341
+ if (found) {
342
+ return found;
343
+ }
344
+
345
+ // Fallback: look in all models to at least display the model name
346
+ // This handles cases where preferences are loading or model was disabled
347
+ const fallbackModel = allModels.find((m) => m.id === optimisticModelId);
348
+ if (fallbackModel) {
349
+ return {
350
+ model: fallbackModel,
351
+ disabled:
352
+ isAnonymous &&
353
+ !(
354
+ ANONYMOUS_LIMITS.AVAILABLE_MODELS as readonly AppModelId[]
355
+ ).includes(fallbackModel.id),
356
+ } satisfies ModelItem;
357
+ }
358
+
359
+ return null;
360
+ }, [models, allModels, optimisticModelId, isAnonymous]);
361
+ const reasoningConfig = useMemo(
362
+ () => getEnabledFeatures().find((f) => f.key === "reasoning"),
363
+ [],
364
+ );
365
+ const activeFilterCount = useMemo(
366
+ () => Object.values(featureFilters).filter(Boolean).length,
367
+ [featureFilters],
368
+ );
369
+ const selectedModelCount = useMemo(
370
+ () => getSelectionCount(optimisticSelection),
371
+ [optimisticSelection],
372
+ );
373
+ const triggerLabel = useMemo(() => {
374
+ if (useMultipleModels && selectedModelCount > 1) {
375
+ return `${selectedItem?.model.name || "Selected model"} +${selectedModelCount - 1}`;
376
+ }
377
+
378
+ return selectedItem?.model.name || "Select model";
379
+ }, [selectedItem?.model.name, selectedModelCount, useMultipleModels]);
380
+
381
+ const selectSingleModel = useCallback(
382
+ (id: AppModelId) => {
383
+ startTransition(() => {
384
+ setOptimisticSelection(id);
385
+ onModelSelectionChangeAction?.(id);
386
+ setOpen(false);
387
+ });
388
+ },
389
+ [onModelSelectionChangeAction, setOptimisticSelection],
390
+ );
391
+
392
+ const toggleMultiModel = useCallback(
393
+ (id: AppModelId) => {
394
+ startTransition(() => {
395
+ const current = optimisticSelectionRef.current;
396
+ const currentCounts: Record<AppModelId, number> =
397
+ typeof current === "string"
398
+ ? ({ [current]: 1 } as Record<AppModelId, number>)
399
+ : (current as Record<AppModelId, number>);
400
+
401
+ const isAlreadySelected = (currentCounts[id] ?? 0) > 0;
402
+
403
+ let nextSelection: Record<AppModelId, number>;
404
+ if (isAlreadySelected) {
405
+ const remaining = Object.entries(currentCounts).filter(
406
+ ([k, v]) => k !== id && v > 0,
407
+ );
408
+ if (remaining.length === 0) return;
409
+ nextSelection = Object.fromEntries(remaining) as Record<
410
+ AppModelId,
411
+ number
412
+ >;
413
+ } else {
414
+ nextSelection = { ...currentCounts, [id]: 1 } as Record<
415
+ AppModelId,
416
+ number
417
+ >;
418
+ }
419
+
420
+ setOptimisticSelection(nextSelection);
421
+ onModelSelectionChangeAction?.(nextSelection);
422
+ });
423
+ },
424
+ [onModelSelectionChangeAction, setOptimisticSelection],
425
+ );
426
+
427
+ const handleCountChange = useCallback(
428
+ (id: AppModelId, delta: number) => {
429
+ startTransition(() => {
430
+ const current = optimisticSelectionRef.current;
431
+ const currentCounts: Record<AppModelId, number> =
432
+ typeof current === "string"
433
+ ? ({ [current]: 1 } as Record<AppModelId, number>)
434
+ : (current as Record<AppModelId, number>);
435
+
436
+ const newCount = (currentCounts[id] ?? 0) + delta;
437
+ let nextSelection: SelectedModelValue;
438
+
439
+ if (newCount <= 0) {
440
+ const remaining = Object.entries(currentCounts).filter(
441
+ ([k, v]) => k !== id && v > 0,
442
+ );
443
+ if (remaining.length === 0) return;
444
+ nextSelection = Object.fromEntries(remaining) as Record<
445
+ AppModelId,
446
+ number
447
+ >;
448
+ } else {
449
+ nextSelection = { ...currentCounts, [id]: newCount } as Record<
450
+ AppModelId,
451
+ number
452
+ >;
453
+ }
454
+
455
+ setOptimisticSelection(nextSelection);
456
+ onModelSelectionChangeAction?.(nextSelection);
457
+ });
458
+ },
459
+ [onModelSelectionChangeAction, setOptimisticSelection],
460
+ );
461
+
462
+ const handleMultipleModelsToggle = useCallback(
463
+ (checked: boolean) => {
464
+ setUseMultipleModels(checked);
465
+
466
+ if (checked) {
467
+ const nextSelection = buildMultiModelSelection([optimisticModelId]);
468
+ startTransition(() => {
469
+ setOptimisticSelection(nextSelection);
470
+ onModelSelectionChangeAction?.(nextSelection);
471
+ });
472
+ return;
473
+ }
474
+
475
+ startTransition(() => {
476
+ setOptimisticSelection(optimisticModelId);
477
+ onModelSelectionChangeAction?.(optimisticModelId);
478
+ });
479
+ },
480
+ [onModelSelectionChangeAction, optimisticModelId, setOptimisticSelection],
481
+ );
482
+
483
+ return (
484
+ <Popover onOpenChange={setOpen} open={open}>
485
+ <PopoverTrigger asChild>
486
+ <Button
487
+ aria-expanded={open}
488
+ className={cn("flex w-fit justify-between gap-2 md:px-2", className)}
489
+ data-testid="model-selector"
490
+ role="combobox"
491
+ variant="ghost"
492
+ >
493
+ <div className="flex items-center gap-2">
494
+ {selectedItem && (
495
+ <div className="shrink-0">
496
+ <ModelSelectorLogo modelId={selectedItem.model.id} />
497
+ </div>
498
+ )}
499
+ <p className="inline-flex items-center gap-1.5 truncate">
500
+ {triggerLabel}
501
+ {selectedItem?.model.reasoning && reasoningConfig && (
502
+ <span
503
+ className="inline-flex shrink-0 items-center gap-1"
504
+ title={reasoningConfig.description}
505
+ >
506
+ <reasoningConfig.icon className="h-3 w-3 text-muted-foreground" />
507
+ </span>
508
+ )}
509
+ </p>
510
+ </div>
511
+ <ChevronUpIcon
512
+ className={cn(
513
+ "h-4 w-4 shrink-0 opacity-50 transition-transform",
514
+ open && "rotate-180",
515
+ )}
516
+ />
517
+ </Button>
518
+ </PopoverTrigger>
519
+ <PopoverContent
520
+ align="start"
521
+ className="w-[350px] p-0"
522
+ onFocusOutside={(e) => e.preventDefault()}
523
+ onInteractOutside={(e) => {
524
+ // Prevent closing when interacting with nested popovers rendered in portals
525
+ if (
526
+ (e.target as HTMLElement).closest(
527
+ "[data-radix-popper-content-wrapper]",
528
+ )
529
+ ) {
530
+ e.preventDefault();
531
+ }
532
+ }}
533
+ >
534
+ {open && (
535
+ <Command>
536
+ <div className="flex items-center border-b">
537
+ <CommandInput
538
+ className="px-3"
539
+ containerClassName="w-full border-0 h-11"
540
+ onClick={(e) => {
541
+ // Prevent closing when interacting with nested filter popover
542
+ e.stopPropagation();
543
+ }}
544
+ placeholder="Search models..."
545
+ />
546
+ <Popover onOpenChange={setFilterOpen} open={filterOpen}>
547
+ <PopoverTrigger asChild>
548
+ <Button
549
+ className={cn(
550
+ "relative mr-3 h-8 w-8 p-0",
551
+ activeFilterCount > 0 && "text-primary",
552
+ )}
553
+ size="sm"
554
+ variant="ghost"
555
+ >
556
+ <FilterIcon className="h-4 w-4" />
557
+ {activeFilterCount > 0 && (
558
+ <Badge
559
+ className="absolute -top-1 -right-1 flex h-4 min-w-[16px] items-center justify-center p-0 text-xs"
560
+ variant="secondary"
561
+ >
562
+ {activeFilterCount}
563
+ </Badge>
564
+ )}
565
+ </Button>
566
+ </PopoverTrigger>
567
+ <PopoverContent align="end" className="p-0">
568
+ <div className="p-4">
569
+ <div className="mb-3 flex h-7 items-center justify-between">
570
+ <div className="font-medium text-sm">Filter by Tools</div>
571
+ {activeFilterCount > 0 && (
572
+ <Button
573
+ className="h-6 text-xs"
574
+ onClick={() => setFeatureFilters(initialFilters)}
575
+ size="sm"
576
+ variant="ghost"
577
+ >
578
+ Clear filters
579
+ </Button>
580
+ )}
581
+ </div>
582
+ <div className="grid grid-cols-1 gap-2">
583
+ {enabledFeatures.map((feature) => {
584
+ const IconComponent = feature.icon;
585
+ return (
586
+ <div
587
+ className="flex items-center space-x-2"
588
+ key={feature.key}
589
+ >
590
+ <Checkbox
591
+ checked={featureFilters[feature.key]}
592
+ id={feature.key}
593
+ onCheckedChange={(checked) =>
594
+ setFeatureFilters((prev) => ({
595
+ ...prev,
596
+ [feature.key]: Boolean(checked),
597
+ }))
598
+ }
599
+ />
600
+ <Label
601
+ className="flex items-center gap-1.5 text-sm"
602
+ htmlFor={feature.key}
603
+ >
604
+ <IconComponent className="h-3.5 w-3.5" />
605
+ {feature.name}
606
+ </Label>
607
+ </div>
608
+ );
609
+ })}
610
+ </div>
611
+ </div>
612
+ </PopoverContent>
613
+ </Popover>
614
+ </div>
615
+ {!isAnonymous && config.features.parallelResponses && (
616
+ <div className="flex items-center justify-between border-b px-3 py-2">
617
+ <Label
618
+ className="cursor-pointer text-sm"
619
+ htmlFor="use-multiple-models"
620
+ >
621
+ Use Multiple Models
622
+ </Label>
623
+ <Switch
624
+ checked={useMultipleModels}
625
+ id="use-multiple-models"
626
+ onCheckedChange={handleMultipleModelsToggle}
627
+ />
628
+ </div>
629
+ )}
630
+ {hasDisabledModels && (
631
+ <div className="p-3">
632
+ <LoginCtaBanner
633
+ compact
634
+ message="Sign in to unlock all models."
635
+ variant="default"
636
+ />
637
+ </div>
638
+ )}
639
+ <CommandList
640
+ className="max-h-[min(25dvh,400px)]"
641
+ onMouseDown={(e) => e.stopPropagation()}
642
+ >
643
+ <CommandEmpty>No model found.</CommandEmpty>
644
+ <CommandGroup>
645
+ {filteredModels.map(({ model, disabled }) => {
646
+ const isSelected = useMultipleModels
647
+ ? selectedModelIds.has(model.id)
648
+ : model.id === optimisticModelId;
649
+ const count =
650
+ useMultipleModels &&
651
+ typeof optimisticSelection !== "string"
652
+ ? (optimisticSelection as Record<AppModelId, number>)[
653
+ model.id
654
+ ] ?? 0
655
+ : undefined;
656
+ return (
657
+ <CommandItem
658
+ count={isSelected ? count : undefined}
659
+ disabled={disabled}
660
+ isSelected={isSelected}
661
+ key={model.id}
662
+ model={model}
663
+ onCountChange={
664
+ useMultipleModels
665
+ ? (delta) => handleCountChange(model.id, delta)
666
+ : undefined
667
+ }
668
+ onSelect={() =>
669
+ useMultipleModels
670
+ ? toggleMultiModel(model.id)
671
+ : selectSingleModel(model.id)
672
+ }
673
+ selectionControl={
674
+ useMultipleModels ? (
675
+ <Checkbox
676
+ checked={isSelected}
677
+ className="pointer-events-none"
678
+ />
679
+ ) : null
680
+ }
681
+ />
682
+ );
683
+ })}
684
+ </CommandGroup>
685
+ </CommandList>
686
+ {!isAnonymous && (
687
+ <div className="border-t p-2">
688
+ <Button
689
+ asChild
690
+ className="w-full justify-between"
691
+ size="sm"
692
+ variant="ghost"
693
+ >
694
+ <Link aria-label="Add Models" href="/settings/models">
695
+ Add Models
696
+ <ChevronRightIcon className="h-4 w-4" />
697
+ </Link>
698
+ </Button>
699
+ </div>
700
+ )}
701
+ </Command>
702
+ )}
703
+ </PopoverContent>
704
+ </Popover>
705
+ );
445
706
  }
446
707
 
447
708
  export const ModelSelector = memo(
448
- PureModelSelector,
449
- (prev, next) =>
450
- prev.selectedModelId === next.selectedModelId &&
451
- prev.className === next.className &&
452
- prev.onModelChangeAction === next.onModelChangeAction
709
+ PureModelSelector,
710
+ (prev, next) =>
711
+ prev.selectedModelId === next.selectedModelId &&
712
+ prev.selectedModelSelection === next.selectedModelSelection &&
713
+ prev.className === next.className &&
714
+ prev.onModelSelectionChangeAction === next.onModelSelectionChangeAction,
453
715
  );