@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.
- package/README.md +251 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- 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
|
+
}
|