@chat-js/cli 0.4.0 → 0.6.1

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