@vvedantb/ui 0.17.1 → 0.18.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 (129) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,505 @@
1
+ "use client";
2
+
3
+ import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import { Command as CommandPrimitive } from "cmdk";
5
+ import { IconCheck, IconSearch } from "@tabler/icons-react";
6
+ import { Command, CommandEmpty, CommandItem, CommandList } from "../ui/command";
7
+ import {
8
+ Tooltip,
9
+ TooltipContent,
10
+ TooltipProvider,
11
+ TooltipTrigger,
12
+ } from "../ui/tooltip";
13
+ import { useCn, useUiConfig } from "../ui/ui-config";
14
+ import { ProviderIcon } from "./provider-icon";
15
+ import {
16
+ type ModelAccount,
17
+ type ModelOption,
18
+ formatModelDisplayLabel,
19
+ getProviderLabel,
20
+ } from "./model-picker-types";
21
+
22
+ export type ModelPickerInstance = {
23
+ key: string;
24
+ provider: string;
25
+ accountId: string | null;
26
+ label: string;
27
+ };
28
+
29
+ type ModelPickerRow = {
30
+ compositeKey: string;
31
+ modelId: string;
32
+ modelLabel: string;
33
+ instance: ModelPickerInstance;
34
+ };
35
+
36
+ export function providerInstanceInitials(label: string): string {
37
+ const words = label.replace(/[_-]+/g, " ").split(/\s+/u).filter(Boolean);
38
+ if (words.length === 0) return "";
39
+ const first = words[0];
40
+ if (!first) return "";
41
+ if (words.length === 1) return first.slice(0, 2).toUpperCase();
42
+ const second = words[1];
43
+ if (!second) return first.slice(0, 2).toUpperCase();
44
+ return `${first.charAt(0)}${second.charAt(0)}`.toUpperCase();
45
+ }
46
+
47
+ export function teamInstanceKey(provider: string): string {
48
+ return `team:${provider}`;
49
+ }
50
+
51
+ function toCompositeKey(instanceKey: string, modelId: string): string {
52
+ return `${instanceKey}::${modelId}`;
53
+ }
54
+
55
+ export function buildPickerInstances<TModel extends string>(
56
+ options: ReadonlyArray<ModelOption<TModel>>,
57
+ accounts: ReadonlyArray<ModelAccount> | undefined,
58
+ ): ModelPickerInstance[] {
59
+ const providers: string[] = [];
60
+ for (const option of options) {
61
+ if (!providers.includes(option.provider)) {
62
+ providers.push(option.provider);
63
+ }
64
+ }
65
+
66
+ const instances: ModelPickerInstance[] = [];
67
+ for (const provider of providers) {
68
+ instances.push({
69
+ key: teamInstanceKey(provider),
70
+ provider,
71
+ accountId: null,
72
+ label: getProviderLabel(provider),
73
+ });
74
+ const providerAccounts =
75
+ accounts?.filter((account) => account.provider === provider) ?? [];
76
+ for (const account of providerAccounts) {
77
+ instances.push({
78
+ key: account.id,
79
+ provider,
80
+ accountId: account.id,
81
+ label: account.label,
82
+ });
83
+ }
84
+ }
85
+ return instances;
86
+ }
87
+
88
+ function InstanceIcon({
89
+ instance,
90
+ size = 20,
91
+ showBadge,
92
+ }: {
93
+ instance: ModelPickerInstance;
94
+ size?: number;
95
+ showBadge: boolean;
96
+ }) {
97
+ const ring = useUiConfig("ModelPicker")?.badgeRing?.panel ?? "var(--color-surface-secondary)";
98
+ return (
99
+ <span className="relative isolate inline-flex shrink-0 items-center justify-center overflow-visible">
100
+ <ProviderIcon provider={instance.provider} size={size} />
101
+ {showBadge ? (
102
+ <span
103
+ className="pointer-events-none absolute -right-0.5 -bottom-0.5 z-10 flex h-3 min-w-3 items-center justify-center rounded-full bg-surface-secondary px-0.5 text-[7px] font-semibold leading-none text-muted-foreground"
104
+ style={{
105
+ boxShadow: `0 0 0 1.5px ${ring}`,
106
+ }}
107
+ aria-hidden
108
+ >
109
+ {providerInstanceInitials(instance.label)}
110
+ </span>
111
+ ) : null}
112
+ </span>
113
+ );
114
+ }
115
+
116
+ function rowFooter(instance: ModelPickerInstance): string {
117
+ const providerLabel = getProviderLabel(instance.provider);
118
+ if (instance.accountId === null) return providerLabel;
119
+ return `${providerLabel} · ${instance.label}`;
120
+ }
121
+
122
+ /**
123
+ * Searchable model + account list used by `ModelPicker` and by menu embeds
124
+ * (context/dropdown submenus). Do not reimplement provider/account radios
125
+ * elsewhere — pass the same options/accounts hooks the modal uses.
126
+ */
127
+ export function ModelPickerContent<TModel extends string>({
128
+ value,
129
+ accountId,
130
+ options,
131
+ accounts,
132
+ onSelect,
133
+ canSelectTeamWhilePersonal = true,
134
+ header,
135
+ }: {
136
+ value: TModel;
137
+ accountId: string | null;
138
+ options: ReadonlyArray<ModelOption<TModel>>;
139
+ accounts?: ReadonlyArray<ModelAccount>;
140
+ onSelect: (modelId: TModel, accountId: string | null) => void;
141
+ canSelectTeamWhilePersonal?: boolean;
142
+ /** Trait controls (or any extra chrome) pinned above the model list. */
143
+ header?: ReactNode;
144
+ }) {
145
+ const merge = useCn();
146
+ const instances = useMemo(
147
+ () => buildPickerInstances(options, accounts),
148
+ [options, accounts],
149
+ );
150
+
151
+ const selectedPersonalAccount = accountId
152
+ ? accounts?.find((account) => account.id === accountId)
153
+ : undefined;
154
+ const lockedTeamProvider = selectedPersonalAccount?.provider;
155
+ const lockedTeamKey = lockedTeamProvider
156
+ ? teamInstanceKey(lockedTeamProvider)
157
+ : undefined;
158
+ const lockedTeamLabel = selectedPersonalAccount
159
+ ? `${getProviderLabel(selectedPersonalAccount.provider)} Team is unavailable while using ${selectedPersonalAccount.label}'s personal account`
160
+ : undefined;
161
+
162
+ const initialInstanceKey = useMemo(() => {
163
+ if (accountId) {
164
+ const match = instances.find((instance) => instance.key === accountId);
165
+ if (match) return match.key;
166
+ }
167
+ const selected = options.find((option) => option.id === value);
168
+ if (selected) return teamInstanceKey(selected.provider);
169
+ const first = instances[0];
170
+ return first ? first.key : "";
171
+ }, [accountId, instances, options, value]);
172
+
173
+ const [selectedInstanceKey, setSelectedInstanceKey] =
174
+ useState(initialInstanceKey);
175
+ const [search, setSearch] = useState("");
176
+ const [showTopFade, setShowTopFade] = useState(false);
177
+ const [showBottomFade, setShowBottomFade] = useState(false);
178
+ const [indicatorTop, setIndicatorTop] = useState<number | null>(null);
179
+ const searchInputRef = useRef<HTMLInputElement>(null);
180
+ const railContentRef = useRef<HTMLDivElement>(null);
181
+
182
+ const isSearching = search.trim().length > 0;
183
+
184
+ const selectedInstance =
185
+ instances.find((instance) => instance.key === selectedInstanceKey) ??
186
+ instances[0];
187
+
188
+ const activeCompositeKey = useMemo(() => {
189
+ const selected = options.find((option) => option.id === value);
190
+ if (!selected) return "";
191
+ if (accountId) {
192
+ const accountInstance = instances.find(
193
+ (instance) => instance.key === accountId,
194
+ );
195
+ if (accountInstance) {
196
+ return toCompositeKey(accountInstance.key, selected.id);
197
+ }
198
+ }
199
+ return toCompositeKey(teamInstanceKey(selected.provider), selected.id);
200
+ }, [accountId, instances, options, value]);
201
+
202
+ const rows = useMemo((): ModelPickerRow[] => {
203
+ const sourceInstances = isSearching
204
+ ? instances
205
+ : selectedInstance
206
+ ? [selectedInstance]
207
+ : [];
208
+ const out: ModelPickerRow[] = [];
209
+ for (const instance of sourceInstances) {
210
+ for (const option of options) {
211
+ if (option.provider !== instance.provider) continue;
212
+ out.push({
213
+ compositeKey: toCompositeKey(instance.key, option.id),
214
+ modelId: option.id,
215
+ modelLabel: option.label,
216
+ instance,
217
+ });
218
+ }
219
+ }
220
+ return out;
221
+ }, [instances, isSearching, options, selectedInstance]);
222
+
223
+ const focusSearch = () => {
224
+ searchInputRef.current?.focus({ preventScroll: true });
225
+ };
226
+
227
+ // Match t3code: focus survives popover open + any later focus steal.
228
+ useLayoutEffect(() => {
229
+ focusSearch();
230
+ const frame = window.requestAnimationFrame(() => {
231
+ focusSearch();
232
+ });
233
+ const timeout = window.setTimeout(() => {
234
+ focusSearch();
235
+ }, 0);
236
+ return () => {
237
+ window.cancelAnimationFrame(frame);
238
+ window.clearTimeout(timeout);
239
+ };
240
+ }, []);
241
+
242
+ // Measure the active rail button — hardcoded spacing drifts vs real layout.
243
+ useLayoutEffect(() => {
244
+ if (isSearching) {
245
+ setIndicatorTop(null);
246
+ return;
247
+ }
248
+ const content = railContentRef.current;
249
+ if (!content || !selectedInstance) {
250
+ setIndicatorTop(null);
251
+ return;
252
+ }
253
+ const selectedButton = content.querySelector(
254
+ `[data-model-picker-instance="${CSS.escape(selectedInstance.key)}"]`,
255
+ );
256
+ if (!(selectedButton instanceof HTMLElement)) {
257
+ setIndicatorTop(null);
258
+ return;
259
+ }
260
+ // With a scrolling rail the active provider can open off-screen; `nearest`
261
+ // is a no-op when it is already visible.
262
+ selectedButton.scrollIntoView({ block: "nearest" });
263
+ const contentRect = content.getBoundingClientRect();
264
+ const buttonRect = selectedButton.getBoundingClientRect();
265
+ setIndicatorTop(
266
+ buttonRect.top -
267
+ contentRect.top +
268
+ content.scrollTop +
269
+ buttonRect.height / 2 -
270
+ 10,
271
+ );
272
+ }, [instances, isSearching, selectedInstance]);
273
+
274
+ const updateScrollFades = (element: HTMLElement) => {
275
+ const { scrollTop, scrollHeight, clientHeight } = element;
276
+ setShowTopFade(scrollTop > 2);
277
+ setShowBottomFade(scrollTop + clientHeight < scrollHeight - 2);
278
+ };
279
+
280
+ const providerCounts = useMemo(() => {
281
+ const counts = new Map<string, number>();
282
+ for (const instance of instances) {
283
+ counts.set(instance.provider, (counts.get(instance.provider) ?? 0) + 1);
284
+ }
285
+ return counts;
286
+ }, [instances]);
287
+
288
+ return (
289
+ <TooltipProvider>
290
+ <div
291
+ className="flex h-96 max-h-[min(24rem,var(--radix-popover-content-available-height))] w-full flex-col overflow-hidden rounded-lg bg-popover smooth-shadow-ring-lg"
292
+ data-model-picker-content
293
+ >
294
+ {header ? (
295
+ <div
296
+ className="shrink-0 border-b border-border bg-surface-secondary/30 px-3 py-2"
297
+ onPointerDown={(event) => event.stopPropagation()}
298
+ >
299
+ {header}
300
+ </div>
301
+ ) : null}
302
+ <div className="flex min-h-0 flex-1 overflow-hidden">
303
+ {!isSearching ? (
304
+ // The rail grows with every provider + personal account, so it has to
305
+ // scroll on its own once it outruns the panel — `overflow-hidden`
306
+ // simply clipped the tail off.
307
+ <div className="w-12 shrink-0 overflow-y-auto overscroll-contain border-r border-border bg-surface-secondary/30 scrollbar-none">
308
+ <div
309
+ ref={railContentRef}
310
+ className="relative flex flex-col gap-1 px-1 pb-1 pt-0.5"
311
+ >
312
+ {indicatorTop !== null ? (
313
+ // Pinned at `top: 0` and moved by `translateY`, the same way the
314
+ // tabs pill lands on its tab. Transitioning `top` relayouted the
315
+ // rail on every frame of a slide that happens while the pointer
316
+ // is still moving down the list.
317
+ <div
318
+ className="pointer-events-none absolute top-0 right-0 z-10 h-5 w-[3px] rounded-l-full bg-primary transition-transform duration-[var(--motion-base)]"
319
+ style={{ transform: `translateY(${indicatorTop}px)` }}
320
+ aria-hidden
321
+ />
322
+ ) : null}
323
+ {instances.map((instance) => {
324
+ const showBadge =
325
+ (providerCounts.get(instance.provider) ?? 0) > 1;
326
+ const isPersonalTeamVariant =
327
+ lockedTeamKey !== undefined && instance.key === lockedTeamKey;
328
+ const isLockedTeam =
329
+ isPersonalTeamVariant && !canSelectTeamWhilePersonal;
330
+ const showTeamLockedHint =
331
+ isPersonalTeamVariant && lockedTeamLabel !== undefined;
332
+ return (
333
+ <Tooltip key={instance.key}>
334
+ <TooltipTrigger asChild>
335
+ <button
336
+ type="button"
337
+ data-model-picker-instance={instance.key}
338
+ disabled={isLockedTeam}
339
+ className={merge(
340
+ "relative isolate flex aspect-square w-full items-center justify-center rounded-md transition-colors",
341
+ isPersonalTeamVariant
342
+ ? "opacity-40"
343
+ : "hover:bg-surface-secondary",
344
+ isLockedTeam
345
+ ? "cursor-not-allowed"
346
+ : "cursor-pointer hover:bg-surface-secondary",
347
+ )}
348
+ aria-label={instance.label}
349
+ onClick={() => {
350
+ if (isLockedTeam) return;
351
+ setSelectedInstanceKey(instance.key);
352
+ window.requestAnimationFrame(() => {
353
+ focusSearch();
354
+ });
355
+ }}
356
+ >
357
+ <InstanceIcon
358
+ instance={instance}
359
+ size={20}
360
+ showBadge={showBadge}
361
+ />
362
+ </button>
363
+ </TooltipTrigger>
364
+ <TooltipContent side="left" sideOffset={8}>
365
+ {showTeamLockedHint ? lockedTeamLabel : instance.label}
366
+ </TooltipContent>
367
+ </Tooltip>
368
+ );
369
+ })}
370
+ </div>
371
+ </div>
372
+ ) : null}
373
+
374
+ {/*
375
+ Key only on the rail instance — NOT on isSearching. Remounting when the
376
+ first search character lands unmounts the input and drops focus (the
377
+ "click twice" bug). Row filtering already switches search vs rail views.
378
+ */}
379
+ <Command
380
+ key={selectedInstanceKey}
381
+ className="flex min-w-0 flex-1 flex-col rounded-none border-0 bg-surface-secondary/40"
382
+ defaultValue={activeCompositeKey || undefined}
383
+ >
384
+ <div className="px-4 pt-2.5">
385
+ <div className="flex items-center gap-2 border-b border-border/70 pb-2.5 transition-colors focus-within:border-ring">
386
+ <IconSearch
387
+ className="size-4 shrink-0 text-muted-foreground/55"
388
+ aria-hidden
389
+ />
390
+ <CommandPrimitive.Input
391
+ ref={searchInputRef}
392
+ value={search}
393
+ onValueChange={setSearch}
394
+ placeholder="Search models..."
395
+ className="h-auto w-full bg-transparent py-0 text-sm outline-hidden placeholder:text-muted-foreground"
396
+ onMouseDown={(event) => event.stopPropagation()}
397
+ onTouchStart={(event) => event.stopPropagation()}
398
+ />
399
+ </div>
400
+ </div>
401
+
402
+ <div className="relative min-h-0 flex-1">
403
+ {showTopFade ? (
404
+ <div
405
+ className="pointer-events-none absolute inset-x-0 top-0 z-10 h-6 bg-linear-to-b from-surface-secondary/40 to-transparent"
406
+ aria-hidden
407
+ />
408
+ ) : null}
409
+ {showBottomFade ? (
410
+ <div
411
+ className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-6 bg-linear-to-t from-surface-secondary/40 to-transparent"
412
+ aria-hidden
413
+ />
414
+ ) : null}
415
+
416
+ <CommandList
417
+ className="max-h-none h-full flex-1 overflow-y-auto overscroll-contain px-1.5 py-1.5"
418
+ onScroll={(event) => updateScrollFades(event.currentTarget)}
419
+ ref={(node) => {
420
+ if (node) updateScrollFades(node);
421
+ }}
422
+ >
423
+ <CommandEmpty>No models found</CommandEmpty>
424
+ {rows.map((row) => {
425
+ const isActive = row.compositeKey === activeCompositeKey;
426
+ const showBadge =
427
+ (providerCounts.get(row.instance.provider) ?? 0) > 1;
428
+ const isPersonalTeamVariant =
429
+ lockedTeamKey !== undefined &&
430
+ row.instance.key === lockedTeamKey;
431
+ const isLockedTeamRow =
432
+ isPersonalTeamVariant && !canSelectTeamWhilePersonal;
433
+ const showTeamLockedHint =
434
+ isPersonalTeamVariant && lockedTeamLabel !== undefined;
435
+ return (
436
+ <CommandItem
437
+ key={row.compositeKey}
438
+ value={row.compositeKey}
439
+ disabled={isLockedTeamRow}
440
+ keywords={[
441
+ row.modelLabel,
442
+ formatModelDisplayLabel(
443
+ row.instance.provider,
444
+ row.modelLabel,
445
+ ),
446
+ getProviderLabel(row.instance.provider),
447
+ row.instance.label,
448
+ ]}
449
+ onSelect={() => {
450
+ if (isLockedTeamRow) return;
451
+ const option = options.find(
452
+ (entry) => entry.id === row.modelId,
453
+ );
454
+ if (!option) return;
455
+ setSelectedInstanceKey(row.instance.key);
456
+ setSearch("");
457
+ onSelect(option.id, row.instance.accountId);
458
+ }}
459
+ className={merge(
460
+ "flex flex-col items-stretch gap-1 rounded-md px-2 py-2.5 data-[selected=true]:bg-surface-secondary",
461
+ isPersonalTeamVariant && "opacity-50",
462
+ isLockedTeamRow ? "cursor-not-allowed" : "cursor-pointer",
463
+ )}
464
+ >
465
+ <Tooltip>
466
+ <TooltipTrigger asChild>
467
+ <div className="flex min-w-0 flex-col items-stretch gap-1">
468
+ <div className="flex min-w-0 items-center gap-2">
469
+ <span className="min-w-0 flex-1 truncate text-xs font-medium">
470
+ {row.modelLabel}
471
+ </span>
472
+ {isActive ? (
473
+ <IconCheck
474
+ className="size-3.5 shrink-0 text-primary"
475
+ aria-hidden
476
+ />
477
+ ) : null}
478
+ </div>
479
+ <div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/70">
480
+ <InstanceIcon
481
+ instance={row.instance}
482
+ size={12}
483
+ showBadge={showBadge}
484
+ />
485
+ <span className="truncate">
486
+ {rowFooter(row.instance)}
487
+ </span>
488
+ </div>
489
+ </div>
490
+ </TooltipTrigger>
491
+ {showTeamLockedHint ? (
492
+ <TooltipContent>{lockedTeamLabel}</TooltipContent>
493
+ ) : null}
494
+ </Tooltip>
495
+ </CommandItem>
496
+ );
497
+ })}
498
+ </CommandList>
499
+ </div>
500
+ </Command>
501
+ </div>
502
+ </div>
503
+ </TooltipProvider>
504
+ );
505
+ }
@@ -0,0 +1,56 @@
1
+ export interface ModelOption<TModel extends string = string> {
2
+ id: TModel;
3
+ provider: string;
4
+ label: string;
5
+ }
6
+
7
+ /** A provider account the viewer can run on, surfaced as a group in the picker. */
8
+ export interface ModelAccount {
9
+ id: string;
10
+ provider: string;
11
+ label: string;
12
+ /**
13
+ * Owned by whoever the run bills to, not a teammate's shared account. Only
14
+ * own accounts may be picked automatically; a teammate's share is explicit.
15
+ */
16
+ isOwn: boolean;
17
+ }
18
+
19
+ export function getProviderLabel(provider: string): string {
20
+ switch (provider) {
21
+ case "claude":
22
+ return "Claude";
23
+ case "codex":
24
+ return "GPT";
25
+ case "opencode":
26
+ return "Opencode";
27
+ case "cursor":
28
+ return "Cursor";
29
+ default:
30
+ return provider;
31
+ }
32
+ }
33
+
34
+ /** t3code-style list/trigger label: "Claude Opus", not bare "Opus". */
35
+ export function formatModelDisplayLabel(
36
+ provider: string,
37
+ label: string,
38
+ ): string {
39
+ // Codex models are already named "GPT …"; don't prefix with "Codex".
40
+ if (provider === "codex") {
41
+ return label;
42
+ }
43
+ const providerLabel = getProviderLabel(provider);
44
+ if (label.toLowerCase().startsWith(providerLabel.toLowerCase())) {
45
+ return label;
46
+ }
47
+ return `${providerLabel} ${label}`;
48
+ }
49
+
50
+ export function findModelOption<TModel extends string>(
51
+ value: TModel,
52
+ options: ReadonlyArray<ModelOption<TModel>>,
53
+ ): ModelOption<TModel> | null {
54
+ const option = options.find((entry) => entry.id === value);
55
+ return option ?? options[0] ?? null;
56
+ }