@sero-ai/ui 0.4.2 → 0.6.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/.turbo/turbo-build.log +636 -628
- package/.turbo/turbo-test.log +12 -11
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +20 -2
- package/dist/components/ai-elements/conversation-virtual.cjs +115 -0
- package/dist/components/ai-elements/conversation-virtual.d.cts +30 -0
- package/dist/components/ai-elements/conversation-virtual.d.ts +30 -0
- package/dist/components/ai-elements/conversation-virtual.js +115 -0
- package/dist/components/model-selection/available-model-picker.cjs +21 -77
- package/dist/components/model-selection/available-model-picker.js +22 -78
- package/dist/components/model-selection/model-picker-body.cjs +106 -0
- package/dist/components/model-selection/model-picker-body.d.cts +41 -0
- package/dist/components/model-selection/model-picker-body.d.ts +41 -0
- package/dist/components/model-selection/model-picker-body.js +106 -0
- package/dist/components/model-selection/thinking-level-picker.cjs +7 -3
- package/dist/components/model-selection/thinking-level-picker.js +7 -3
- package/dist/components/model-selection/thinking-picker.cjs +1 -1
- package/dist/components/model-selection/thinking-picker.js +1 -1
- package/dist/components/ui/calendar.cjs +1 -1
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/combobox.cjs +1 -1
- package/dist/components/ui/combobox.js +1 -1
- package/dist/components/ui/form.d.cts +2 -2
- package/dist/components/ui/form.d.ts +2 -2
- package/dist/components/ui/input-group.cjs +1 -1
- package/dist/components/ui/input-group.js +1 -1
- package/dist/components/ui/input-otp.cjs +1 -1
- package/dist/components/ui/input-otp.js +1 -1
- package/dist/components/ui/input.cjs +3 -3
- package/dist/components/ui/input.js +3 -3
- package/dist/components/ui/native-select.cjs +2 -2
- package/dist/components/ui/native-select.js +2 -2
- package/dist/components/ui/select.cjs +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/slider.cjs +6 -0
- package/dist/components/ui/slider.d.cts +1 -1
- package/dist/components/ui/slider.d.ts +1 -1
- package/dist/components/ui/slider.js +6 -0
- package/dist/components/ui/spinner.d.cts +2 -1
- package/dist/components/ui/spinner.d.ts +2 -1
- package/dist/components/ui/textarea.cjs +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/index.cjs +0 -55
- package/dist/index.d.cts +2 -66
- package/dist/index.d.ts +2 -66
- package/dist/index.js +0 -55
- package/dist/reference.cjs +56 -83
- package/dist/reference.js +56 -83
- package/dist/styles/ai-elements.css +2 -0
- package/dist/styles/context-editor.css +2 -0
- package/dist/styles/globals.css +32 -0
- package/dist/styles/model-selection.css +2 -0
- package/dist/styles/plugin.css +4 -1
- package/dist/theme/apply-theme.cjs +65 -12
- package/dist/theme/apply-theme.js +55 -2
- package/dist/theme/index.d.cts +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/types.cjs +15 -2
- package/dist/theme/types.d.cts +21 -1
- package/dist/theme/types.d.ts +21 -1
- package/dist/theme/types.js +14 -1
- package/package.json +44 -30
- package/scripts/smoke-dist.mjs +106 -4
- package/src/components/ai-elements/conversation-virtual.test.tsx +126 -0
- package/src/components/ai-elements/conversation-virtual.tsx +162 -0
- package/src/components/model-selection/available-model-picker.tsx +26 -98
- package/src/components/model-selection/model-picker-body.test.tsx +152 -0
- package/src/components/model-selection/model-picker-body.tsx +188 -0
- package/src/components/model-selection/thinking-level-picker.tsx +9 -3
- package/src/components/model-selection/thinking-picker.test.tsx +41 -0
- package/src/components/model-selection/thinking-picker.tsx +1 -1
- package/src/components/ui/calendar.test.tsx +43 -0
- package/src/components/ui/calendar.tsx +1 -1
- package/src/components/ui/combobox.tsx +1 -1
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input-otp.tsx +1 -1
- package/src/components/ui/input.tsx +3 -3
- package/src/components/ui/native-select.tsx +2 -2
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/slider.tsx +6 -0
- package/src/components/ui/spinner.tsx +1 -1
- package/src/components/ui/textarea.tsx +1 -1
- package/src/index.ts +4 -63
- package/src/styles/ai-elements.css +2 -0
- package/src/styles/context-editor.css +2 -0
- package/src/styles/globals.css +32 -0
- package/src/styles/model-selection.css +2 -0
- package/src/styles/plugin.css +4 -1
- package/src/theme/apply-theme.test.ts +126 -0
- package/src/theme/apply-theme.ts +66 -3
- package/src/theme/types.ts +33 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { act } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ModelPickerBody, ProviderLogo } from './model-picker-body';
|
|
6
|
+
|
|
7
|
+
const GROUPS = [
|
|
8
|
+
{
|
|
9
|
+
provider: 'openai',
|
|
10
|
+
displayName: 'OpenAI',
|
|
11
|
+
logo: 'https://models.dev/logos/openai.svg',
|
|
12
|
+
models: [
|
|
13
|
+
{ provider: 'openai', modelId: 'gpt-5', name: 'GPT-5', reasoning: true },
|
|
14
|
+
{ provider: 'openai', modelId: 'gpt-5-mini', name: 'GPT-5 Mini', reasoning: false },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
provider: 'anthropic',
|
|
19
|
+
displayName: 'Anthropic',
|
|
20
|
+
logo: '',
|
|
21
|
+
models: [
|
|
22
|
+
{ provider: 'anthropic', modelId: 'claude-opus-5', name: 'Claude Opus 5', reasoning: true },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('ProviderLogo', () => {
|
|
28
|
+
it('renders nothing when the provider has no logo', () => {
|
|
29
|
+
const html = renderToStaticMarkup(<ProviderLogo logo="" displayName="Anthropic" />);
|
|
30
|
+
|
|
31
|
+
// An empty src renders a broken image, which looks like a fault.
|
|
32
|
+
expect(html).toBe('');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('hides itself when the logo fails to load', () => {
|
|
36
|
+
// Logos are remote models.dev URLs. A phone on the local network may
|
|
37
|
+
// have no route to the internet, and must not show broken images.
|
|
38
|
+
const host = document.createElement('div');
|
|
39
|
+
document.body.appendChild(host);
|
|
40
|
+
const root = createRoot(host);
|
|
41
|
+
|
|
42
|
+
act(() => {
|
|
43
|
+
root.render(<ProviderLogo logo="https://models.dev/logos/openai.svg" displayName="OpenAI" />);
|
|
44
|
+
});
|
|
45
|
+
const img = host.querySelector('img');
|
|
46
|
+
expect(img).not.toBeNull();
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
img?.dispatchEvent(new Event('error', { bubbles: false }));
|
|
50
|
+
});
|
|
51
|
+
expect(host.querySelector('img')).toBeNull();
|
|
52
|
+
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
host.remove();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('ModelPickerBody', () => {
|
|
59
|
+
it('lists every provider group and its models', () => {
|
|
60
|
+
const html = renderToStaticMarkup(
|
|
61
|
+
<ModelPickerBody groups={GROUPS} value="openai/gpt-5" onChange={vi.fn()} />,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(html).toContain('OpenAI');
|
|
65
|
+
expect(html).toContain('Anthropic');
|
|
66
|
+
expect(html).toContain('GPT-5 Mini');
|
|
67
|
+
expect(html).toContain('Claude Opus 5');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('shows no logos when the caller turns them off', () => {
|
|
71
|
+
const withLogos = renderToStaticMarkup(
|
|
72
|
+
<ModelPickerBody groups={GROUPS} value="" onChange={vi.fn()} />,
|
|
73
|
+
);
|
|
74
|
+
const withoutLogos = renderToStaticMarkup(
|
|
75
|
+
<ModelPickerBody groups={GROUPS} value="" onChange={vi.fn()} showProviderLogos={false} />,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(withLogos).toContain('<img');
|
|
79
|
+
expect(withoutLogos).not.toContain('<img');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('says so when the host has no models at all', () => {
|
|
83
|
+
const html = renderToStaticMarkup(
|
|
84
|
+
<ModelPickerBody
|
|
85
|
+
groups={[]}
|
|
86
|
+
value=""
|
|
87
|
+
onChange={vi.fn()}
|
|
88
|
+
noModelsLabel="No models available"
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(html).toContain('No models available');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('hands back the selected model as provider/modelId', () => {
|
|
96
|
+
const onChange = vi.fn();
|
|
97
|
+
const host = document.createElement('div');
|
|
98
|
+
document.body.appendChild(host);
|
|
99
|
+
const root = createRoot(host);
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
root.render(
|
|
103
|
+
<ModelPickerBody
|
|
104
|
+
groups={GROUPS}
|
|
105
|
+
value="openai/gpt-5"
|
|
106
|
+
onChange={onChange}
|
|
107
|
+
autoFocusSearch={false}
|
|
108
|
+
/>,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const button = [...host.querySelectorAll('button')].find(
|
|
113
|
+
(candidate) => candidate.textContent?.includes('Claude Opus 5'),
|
|
114
|
+
);
|
|
115
|
+
act(() => button?.click());
|
|
116
|
+
|
|
117
|
+
expect(onChange).toHaveBeenCalledWith('anthropic/claude-opus-5');
|
|
118
|
+
|
|
119
|
+
act(() => root.unmount());
|
|
120
|
+
host.remove();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('filters the list by the search query', () => {
|
|
124
|
+
const host = document.createElement('div');
|
|
125
|
+
document.body.appendChild(host);
|
|
126
|
+
const root = createRoot(host);
|
|
127
|
+
|
|
128
|
+
act(() => {
|
|
129
|
+
root.render(
|
|
130
|
+
<ModelPickerBody groups={GROUPS} value="" onChange={vi.fn()} autoFocusSearch={false} />,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const input = host.querySelector('input');
|
|
135
|
+
expect(input).not.toBeNull();
|
|
136
|
+
act(() => {
|
|
137
|
+
// React tracks the value, so set it through the native setter.
|
|
138
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
139
|
+
window.HTMLInputElement.prototype,
|
|
140
|
+
'value',
|
|
141
|
+
)?.set;
|
|
142
|
+
setter?.call(input, 'opus');
|
|
143
|
+
input?.dispatchEvent(new Event('input', { bubbles: true }));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(host.textContent).toContain('Claude Opus 5');
|
|
147
|
+
expect(host.textContent).not.toContain('GPT-5 Mini');
|
|
148
|
+
|
|
149
|
+
act(() => root.unmount());
|
|
150
|
+
host.remove();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model list itself: a search field, provider groups, and model rows.
|
|
3
|
+
*
|
|
4
|
+
* This holds no popover and no trigger, so a caller decides how it is
|
|
5
|
+
* shown. `AvailableModelPicker` wraps it in a full-width settings field.
|
|
6
|
+
* A chat composer wraps it in a compact chip and adds a thinking control
|
|
7
|
+
* below it, inside the same popover.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
11
|
+
import { Check, Sparkles } from 'lucide-react';
|
|
12
|
+
import {
|
|
13
|
+
filterModelGroups,
|
|
14
|
+
modelKey,
|
|
15
|
+
type SharedAvailableModelGroup,
|
|
16
|
+
type SharedModelInfo,
|
|
17
|
+
} from '@sero-ai/common';
|
|
18
|
+
import { SearchInput } from '../ui/search-input';
|
|
19
|
+
import { cn } from '../../lib/utils';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A provider logo.
|
|
23
|
+
*
|
|
24
|
+
* `logo` is a remote URL, so it is empty for some providers and it fails
|
|
25
|
+
* on a machine with no internet. Either way we show nothing rather than
|
|
26
|
+
* a broken image.
|
|
27
|
+
*/
|
|
28
|
+
export function ProviderLogo({
|
|
29
|
+
logo,
|
|
30
|
+
displayName,
|
|
31
|
+
className,
|
|
32
|
+
}: {
|
|
33
|
+
logo: string;
|
|
34
|
+
displayName: string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}) {
|
|
37
|
+
const [failed, setFailed] = useState(false);
|
|
38
|
+
|
|
39
|
+
// A new provider deserves a fresh attempt.
|
|
40
|
+
useEffect(() => setFailed(false), [logo]);
|
|
41
|
+
|
|
42
|
+
if (!logo || failed) return null;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<img
|
|
46
|
+
src={logo}
|
|
47
|
+
alt={displayName}
|
|
48
|
+
onError={() => setFailed(true)}
|
|
49
|
+
className={cn('size-3.5 shrink-0 rounded-sm dark:invert', className)}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ModelPickerBodyProps<
|
|
55
|
+
TModel extends SharedModelInfo,
|
|
56
|
+
TGroup extends SharedAvailableModelGroup<TModel>,
|
|
57
|
+
> {
|
|
58
|
+
groups: TGroup[];
|
|
59
|
+
/** The selected model as `provider/modelId`, or '' for none. */
|
|
60
|
+
value: string;
|
|
61
|
+
/** Called with the new `provider/modelId`. */
|
|
62
|
+
onChange: (value: string) => void;
|
|
63
|
+
searchPlaceholder?: string;
|
|
64
|
+
emptyLabel?: string;
|
|
65
|
+
noModelsLabel?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Focus the search field on mount. Turn this off on a phone: the
|
|
68
|
+
* on-screen keyboard would cover the list the user came to read.
|
|
69
|
+
*/
|
|
70
|
+
autoFocusSearch?: boolean;
|
|
71
|
+
/** Hide provider logos where the network may not reach them. */
|
|
72
|
+
showProviderLogos?: boolean;
|
|
73
|
+
/** Shown at the right of the search field, such as a settings button. */
|
|
74
|
+
searchEndAdornment?: ReactNode;
|
|
75
|
+
className?: string;
|
|
76
|
+
/** Applied to the scrolling list, so a caller sets its height. */
|
|
77
|
+
listClassName?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ModelPickerBody<
|
|
81
|
+
TModel extends SharedModelInfo,
|
|
82
|
+
TGroup extends SharedAvailableModelGroup<TModel>,
|
|
83
|
+
>({
|
|
84
|
+
groups,
|
|
85
|
+
value,
|
|
86
|
+
onChange,
|
|
87
|
+
searchPlaceholder = 'Search models...',
|
|
88
|
+
emptyLabel = 'No matching models',
|
|
89
|
+
noModelsLabel = 'No models available',
|
|
90
|
+
autoFocusSearch = true,
|
|
91
|
+
showProviderLogos = true,
|
|
92
|
+
searchEndAdornment,
|
|
93
|
+
className,
|
|
94
|
+
listClassName,
|
|
95
|
+
}: ModelPickerBodyProps<TModel, TGroup>) {
|
|
96
|
+
const [query, setQuery] = useState('');
|
|
97
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
98
|
+
|
|
99
|
+
// The body mounts when its popover opens, so focusing on mount is
|
|
100
|
+
// enough; the query starts empty for the same reason.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!autoFocusSearch) return;
|
|
103
|
+
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
|
104
|
+
return () => cancelAnimationFrame(frame);
|
|
105
|
+
}, [autoFocusSearch]);
|
|
106
|
+
|
|
107
|
+
const filteredGroups = useMemo(() => filterModelGroups(groups, query), [groups, query]);
|
|
108
|
+
|
|
109
|
+
const totalResults = useMemo(
|
|
110
|
+
() => filteredGroups.reduce((count, group) => count + group.models.length, 0),
|
|
111
|
+
[filteredGroups],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleSelect = useCallback(
|
|
115
|
+
(provider: string, modelId: string) => {
|
|
116
|
+
onChange(modelKey(provider, modelId));
|
|
117
|
+
setQuery('');
|
|
118
|
+
},
|
|
119
|
+
[onChange],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={className}>
|
|
124
|
+
<SearchInput
|
|
125
|
+
ref={inputRef}
|
|
126
|
+
value={query}
|
|
127
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
128
|
+
placeholder={searchPlaceholder}
|
|
129
|
+
data-slot="model-filter"
|
|
130
|
+
containerClassName="border-b border-border/40"
|
|
131
|
+
endAdornment={searchEndAdornment}
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<div className={cn('max-h-[280px] overflow-y-auto py-1', listClassName)}>
|
|
135
|
+
{groups.length === 0 ? (
|
|
136
|
+
<div className="px-3 py-4 text-center text-sm text-muted-foreground">{noModelsLabel}</div>
|
|
137
|
+
) : totalResults === 0 ? (
|
|
138
|
+
<div className="px-3 py-4 text-center text-sm text-muted-foreground">{emptyLabel}</div>
|
|
139
|
+
) : (
|
|
140
|
+
filteredGroups.map((group, index) => (
|
|
141
|
+
<div key={group.provider}>
|
|
142
|
+
{index > 0 ? <div className="mx-3 border-t border-border/30" /> : null}
|
|
143
|
+
<div className="flex items-center gap-2 px-3 pb-1 pt-2">
|
|
144
|
+
{showProviderLogos ? (
|
|
145
|
+
<ProviderLogo logo={group.logo} displayName={group.displayName} />
|
|
146
|
+
) : null}
|
|
147
|
+
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
148
|
+
{group.displayName}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="px-1">
|
|
152
|
+
{group.models.map((model) => {
|
|
153
|
+
const nextValue = modelKey(model.provider, model.modelId);
|
|
154
|
+
const isSelected = nextValue === value;
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
key={nextValue}
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => handleSelect(model.provider, model.modelId)}
|
|
160
|
+
className={cn(
|
|
161
|
+
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors',
|
|
162
|
+
isSelected
|
|
163
|
+
? 'bg-secondary text-foreground'
|
|
164
|
+
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
<div className="flex size-4 shrink-0 items-center justify-center">
|
|
168
|
+
{isSelected ? (
|
|
169
|
+
<Check className="size-3.5 text-emerald-500" />
|
|
170
|
+
) : (
|
|
171
|
+
<div className="size-1.5 rounded-full bg-border" />
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
<span className="min-w-0 flex-1 truncate font-medium">{model.name}</span>
|
|
175
|
+
{model.reasoning ? (
|
|
176
|
+
<Sparkles className="size-3 shrink-0 text-amber-500/60" />
|
|
177
|
+
) : null}
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
))
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
@@ -5,6 +5,12 @@ import {
|
|
|
5
5
|
} from '@sero-ai/common';
|
|
6
6
|
import { cn } from '../../lib/utils';
|
|
7
7
|
|
|
8
|
+
// Compact label so all seven segments stay on a single line without wrapping.
|
|
9
|
+
const COMPACT_LABELS: Record<ThinkingLevel, string> = {
|
|
10
|
+
...THINKING_LABELS,
|
|
11
|
+
xhigh: 'X-High',
|
|
12
|
+
};
|
|
13
|
+
|
|
8
14
|
interface ThinkingLevelPickerProps {
|
|
9
15
|
value: string;
|
|
10
16
|
onChange: (value: ThinkingLevel) => void;
|
|
@@ -28,7 +34,7 @@ export function ThinkingLevelPicker({
|
|
|
28
34
|
: THINKING_LEVELS.filter((level) => allowed.has(level));
|
|
29
35
|
|
|
30
36
|
return (
|
|
31
|
-
<div className={cn('grid grid-cols-
|
|
37
|
+
<div className={cn('grid grid-cols-7 gap-1 rounded-lg border border-border/50 bg-muted/20 p-1', className)}>
|
|
32
38
|
{levels.map((level) => {
|
|
33
39
|
const isActive = value === level;
|
|
34
40
|
const isAvailable = allowed.has(level) || !availableLevels;
|
|
@@ -39,14 +45,14 @@ export function ThinkingLevelPicker({
|
|
|
39
45
|
disabled={disabled || !isAvailable}
|
|
40
46
|
onClick={() => onChange(level)}
|
|
41
47
|
className={cn(
|
|
42
|
-
'rounded-md px-2 py-1.5 text-sm font-medium transition-colors',
|
|
48
|
+
'whitespace-nowrap rounded-md px-2 py-1.5 text-sm font-medium transition-colors',
|
|
43
49
|
isActive
|
|
44
50
|
? 'bg-background text-foreground shadow-sm'
|
|
45
51
|
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
|
46
52
|
(!isAvailable || disabled) && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
|
47
53
|
)}
|
|
48
54
|
>
|
|
49
|
-
{
|
|
55
|
+
{COMPACT_LABELS[level]}
|
|
50
56
|
</button>
|
|
51
57
|
);
|
|
52
58
|
})}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ThinkingPicker } from './thinking-picker';
|
|
4
|
+
|
|
5
|
+
describe('ThinkingPicker', () => {
|
|
6
|
+
it('hides unsupported thinking levels', () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
<ThinkingPicker
|
|
9
|
+
available={['off', 'low', 'high']}
|
|
10
|
+
current="low"
|
|
11
|
+
disabled={false}
|
|
12
|
+
onSelect={vi.fn()}
|
|
13
|
+
/>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
expect(html).toContain('Off');
|
|
17
|
+
expect(html).toContain('Low');
|
|
18
|
+
expect(html).toContain('High');
|
|
19
|
+
expect(html).not.toContain('X-High');
|
|
20
|
+
expect(html).not.toContain('Max');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('shows only Off when the model has no thinking support', () => {
|
|
24
|
+
const html = renderToStaticMarkup(
|
|
25
|
+
<ThinkingPicker
|
|
26
|
+
available={[]}
|
|
27
|
+
current="off"
|
|
28
|
+
disabled
|
|
29
|
+
onSelect={vi.fn()}
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(html).toContain('Off');
|
|
34
|
+
expect(html).not.toContain('Min');
|
|
35
|
+
expect(html).not.toContain('Low');
|
|
36
|
+
expect(html).not.toContain('Med');
|
|
37
|
+
expect(html).not.toContain('High');
|
|
38
|
+
expect(html).not.toContain('X-High');
|
|
39
|
+
expect(html).not.toContain('Max');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -26,7 +26,7 @@ export function ThinkingPicker({
|
|
|
26
26
|
className
|
|
27
27
|
}: ThinkingPickerProps) {
|
|
28
28
|
const levels = disabled
|
|
29
|
-
? THINKING_LEVELS
|
|
29
|
+
? THINKING_LEVELS.filter((level) => level === 'off')
|
|
30
30
|
: THINKING_LEVELS.filter((level) => level === 'off' || available.includes(level));
|
|
31
31
|
const activeIndex = Math.max(
|
|
32
32
|
0,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { Calendar } from './calendar';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Nothing in this repo renders the calendar, but plugin authors get it
|
|
7
|
+
* from the package. react-day-picker v10 removed class-name slots that
|
|
8
|
+
* v9 accepted, and a removed slot is silently ignored at runtime, so a
|
|
9
|
+
* render is the only thing that catches it.
|
|
10
|
+
*/
|
|
11
|
+
describe('Calendar', () => {
|
|
12
|
+
it('renders a month grid', () => {
|
|
13
|
+
const html = renderToStaticMarkup(
|
|
14
|
+
<Calendar month={new Date(2026, 0, 1)} />,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(html).toContain('January');
|
|
18
|
+
// Every week of January 2026 plus the outside days.
|
|
19
|
+
expect(html).toContain('>1<');
|
|
20
|
+
expect(html).toContain('>31<');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('keeps the styling on the grid slot v10 renamed', () => {
|
|
24
|
+
const html = renderToStaticMarkup(
|
|
25
|
+
<Calendar month={new Date(2026, 0, 1)} />,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(html).toContain('border-collapse');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders the dropdown caption layout', () => {
|
|
32
|
+
const html = renderToStaticMarkup(
|
|
33
|
+
<Calendar
|
|
34
|
+
month={new Date(2026, 0, 1)}
|
|
35
|
+
captionLayout="dropdown"
|
|
36
|
+
startMonth={new Date(2025, 0)}
|
|
37
|
+
endMonth={new Date(2027, 11)}
|
|
38
|
+
/>,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(html).toContain('<select');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -86,7 +86,7 @@ function Calendar({
|
|
|
86
86
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-base h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
|
87
87
|
defaultClassNames.caption_label
|
|
88
88
|
),
|
|
89
|
-
|
|
89
|
+
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
|
90
90
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
91
91
|
weekday: cn(
|
|
92
92
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
|
@@ -235,7 +235,7 @@ function ComboboxChips({
|
|
|
235
235
|
<ComboboxPrimitive.Chips
|
|
236
236
|
data-slot="combobox-chips"
|
|
237
237
|
className={cn(
|
|
238
|
-
"dark:bg-input/30 border-input focus-within:border-ring
|
|
238
|
+
"dark:bg-input/30 border-input focus-within:border-ring has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-base shadow-xs transition-[color,box-shadow] has-data-[slot=combobox-chip]:px-1.5",
|
|
239
239
|
className
|
|
240
240
|
)}
|
|
241
241
|
{...props}
|
|
@@ -22,7 +22,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
22
22
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
|
23
23
|
|
|
24
24
|
// Focus state.
|
|
25
|
-
"has-[[data-slot=input-group-control]:focus-visible]:border-ring
|
|
25
|
+
"has-[[data-slot=input-group-control]:focus-visible]:border-ring",
|
|
26
26
|
|
|
27
27
|
// Error state.
|
|
28
28
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
|
@@ -51,7 +51,7 @@ function InputOTPSlot({
|
|
|
51
51
|
data-slot="input-otp-slot"
|
|
52
52
|
data-active={isActive}
|
|
53
53
|
className={cn(
|
|
54
|
-
"data-[active=true]:border-ring
|
|
54
|
+
"data-[active=true]:border-ring aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-base shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10",
|
|
55
55
|
className
|
|
56
56
|
)}
|
|
57
57
|
{...props}
|
|
@@ -10,9 +10,9 @@ function Input({ className, type, placeholder, "aria-label": ariaLabel, ...props
|
|
|
10
10
|
type={type}
|
|
11
11
|
data-slot="input"
|
|
12
12
|
className={cn(
|
|
13
|
-
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-base file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50
|
|
14
|
-
"focus-visible:border-ring
|
|
15
|
-
"aria-invalid:
|
|
13
|
+
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-base file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
14
|
+
"focus-visible:border-ring",
|
|
15
|
+
"aria-invalid:border-destructive",
|
|
16
16
|
className
|
|
17
17
|
)}
|
|
18
18
|
{...props}
|
|
@@ -20,8 +20,8 @@ function NativeSelect({
|
|
|
20
20
|
data-size={size}
|
|
21
21
|
className={cn(
|
|
22
22
|
"border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1",
|
|
23
|
-
"focus-visible:border-ring
|
|
24
|
-
"aria-invalid:
|
|
23
|
+
"focus-visible:border-ring",
|
|
24
|
+
"aria-invalid:border-destructive",
|
|
25
25
|
className
|
|
26
26
|
)}
|
|
27
27
|
{...props}
|
|
@@ -38,7 +38,7 @@ function SelectTrigger({
|
|
|
38
38
|
data-slot="select-trigger"
|
|
39
39
|
data-size={size}
|
|
40
40
|
className={cn(
|
|
41
|
-
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring
|
|
41
|
+
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-base whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
42
42
|
className
|
|
43
43
|
)}
|
|
44
44
|
{...props}
|
|
@@ -9,6 +9,9 @@ function Slider({
|
|
|
9
9
|
value,
|
|
10
10
|
min = 0,
|
|
11
11
|
max = 100,
|
|
12
|
+
id,
|
|
13
|
+
"aria-label": ariaLabel,
|
|
14
|
+
"aria-labelledby": ariaLabelledBy,
|
|
12
15
|
...props
|
|
13
16
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
|
14
17
|
const _values = React.useMemo(
|
|
@@ -56,6 +59,9 @@ function Slider({
|
|
|
56
59
|
<SliderPrimitive.Thumb
|
|
57
60
|
data-slot="slider-thumb"
|
|
58
61
|
key={thumbKeys[index]}
|
|
62
|
+
id={id && index === 0 ? id : undefined}
|
|
63
|
+
aria-label={ariaLabel}
|
|
64
|
+
aria-labelledby={ariaLabelledBy}
|
|
59
65
|
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
|
60
66
|
/>
|
|
61
67
|
))}
|
|
@@ -2,7 +2,7 @@ import { Loader2Icon } from "lucide-react"
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "../../lib/utils"
|
|
4
4
|
|
|
5
|
-
function Spinner({ className, ...props }: React.ComponentProps<
|
|
5
|
+
function Spinner({ className, ...props }: React.ComponentProps<typeof Loader2Icon>) {
|
|
6
6
|
return (
|
|
7
7
|
<Loader2Icon
|
|
8
8
|
role="status"
|
|
@@ -9,7 +9,7 @@ function Textarea({ className, placeholder, "aria-label": ariaLabel, ...props }:
|
|
|
9
9
|
placeholder={placeholder}
|
|
10
10
|
data-slot="textarea"
|
|
11
11
|
className={cn(
|
|
12
|
-
"border-input placeholder:text-muted-foreground focus-visible:border-ring
|
|
12
|
+
"border-input placeholder:text-muted-foreground focus-visible:border-ring aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
13
13
|
className
|
|
14
14
|
)}
|
|
15
15
|
{...props}
|