@sero-ai/ui 0.5.0 → 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 +386 -378
- package/.turbo/turbo-test.log +12 -11
- package/CHANGELOG.md +2 -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 +1 -1
- package/dist/components/ui/form.d.ts +1 -1
- 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.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/styles/globals.css +32 -0
- 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 +21 -20
- package/scripts/smoke-dist.mjs +2 -2
- 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/styles/globals.css +32 -0
- 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,126 @@
|
|
|
1
|
+
import { act } from 'react';
|
|
2
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { Conversation, ConversationContent } from './conversation';
|
|
5
|
+
import { ConversationVirtualList } from './conversation-virtual';
|
|
6
|
+
|
|
7
|
+
function Chat({ session, items, initialScrollToEnd = false }: { session: string; items: string[]; initialScrollToEnd?: boolean }) {
|
|
8
|
+
return (
|
|
9
|
+
<Conversation key={session} initial="instant">
|
|
10
|
+
<ConversationContent>
|
|
11
|
+
{items.length > 0 && (
|
|
12
|
+
<ConversationVirtualList
|
|
13
|
+
initialScrollToEnd={initialScrollToEnd}
|
|
14
|
+
items={items}
|
|
15
|
+
getItemKey={(item) => item}
|
|
16
|
+
renderItem={(item) => <p>{item}</p>}
|
|
17
|
+
/>
|
|
18
|
+
)}
|
|
19
|
+
</ConversationContent>
|
|
20
|
+
</Conversation>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let container: HTMLDivElement;
|
|
25
|
+
let root: Root;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
|
29
|
+
vi.stubGlobal('ResizeObserver', class {
|
|
30
|
+
observe() {}
|
|
31
|
+
unobserve() {}
|
|
32
|
+
disconnect() {}
|
|
33
|
+
});
|
|
34
|
+
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600);
|
|
35
|
+
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400);
|
|
36
|
+
vi.stubGlobal('requestAnimationFrame', () => 0);
|
|
37
|
+
vi.stubGlobal('cancelAnimationFrame', () => {});
|
|
38
|
+
container = document.createElement('div');
|
|
39
|
+
document.body.append(container);
|
|
40
|
+
root = createRoot(container);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
act(() => root.unmount());
|
|
45
|
+
container.remove();
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
vi.unstubAllGlobals();
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('ConversationVirtualList', () => {
|
|
52
|
+
it('hides initial measurement jumps and reveals only a stable bottom position', () => {
|
|
53
|
+
const frames: FrameRequestCallback[] = [];
|
|
54
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
55
|
+
frames.push(callback);
|
|
56
|
+
return frames.length;
|
|
57
|
+
});
|
|
58
|
+
let height = 1200;
|
|
59
|
+
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => height);
|
|
60
|
+
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(600);
|
|
61
|
+
act(() => root.render(<Chat session="a" items={['Last message']} initialScrollToEnd />));
|
|
62
|
+
const list = container.querySelector('[data-index]')!.parentElement!;
|
|
63
|
+
const scroller = container.querySelector('[role="log"]')!.firstElementChild!;
|
|
64
|
+
const advanceFrame = () => act(() => {
|
|
65
|
+
const pending = frames.splice(0);
|
|
66
|
+
pending.forEach((callback) => callback(0));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(list.style.visibility).toBe('hidden');
|
|
70
|
+
advanceFrame();
|
|
71
|
+
expect(scroller.scrollTop).toBe(599);
|
|
72
|
+
height = 1800;
|
|
73
|
+
advanceFrame();
|
|
74
|
+
expect(scroller.scrollTop).toBe(1199);
|
|
75
|
+
expect(list.style.visibility).toBe('hidden');
|
|
76
|
+
advanceFrame();
|
|
77
|
+
expect(list.style.visibility).toBe('hidden');
|
|
78
|
+
advanceFrame();
|
|
79
|
+
expect(list.style.visibility).toBe('');
|
|
80
|
+
|
|
81
|
+
scroller.scrollTop = 200;
|
|
82
|
+
act(() => root.render(<Chat session="a" items={['Last message', 'New message']} initialScrollToEnd />));
|
|
83
|
+
advanceFrame();
|
|
84
|
+
expect(scroller.scrollTop).toBe(200);
|
|
85
|
+
expect(list.style.visibility).toBe('');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reveals a continuously growing transcript without restarting the deadline on new items', () => {
|
|
89
|
+
vi.useFakeTimers();
|
|
90
|
+
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
|
91
|
+
setTimeout(() => callback(performance.now()), 16));
|
|
92
|
+
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
|
|
93
|
+
let height = 1200;
|
|
94
|
+
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => height);
|
|
95
|
+
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(600);
|
|
96
|
+
const items = ['Running tool output'];
|
|
97
|
+
act(() => root.render(<Chat session="streaming" items={items} initialScrollToEnd />));
|
|
98
|
+
const list = container.querySelector('[data-index]')!.parentElement!;
|
|
99
|
+
expect(list.style.visibility).toBe('hidden');
|
|
100
|
+
|
|
101
|
+
for (let frame = 0; frame < 16; frame += 1) {
|
|
102
|
+
height += 100;
|
|
103
|
+
// Changing the count also restarts the positioning effect, as new tool
|
|
104
|
+
// messages can arrive while the current output row grows.
|
|
105
|
+
items.push(`Tool update ${frame}`);
|
|
106
|
+
act(() => root.render(<Chat session="streaming" items={[...items]} initialScrollToEnd />));
|
|
107
|
+
act(() => vi.advanceTimersByTime(16));
|
|
108
|
+
if (frame < 15) expect(list.style.visibility).toBe('hidden');
|
|
109
|
+
}
|
|
110
|
+
expect(list.style.visibility).toBe('');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('shows cached messages after switching away and returning', () => {
|
|
114
|
+
act(() => root.render(<Chat session="a" items={[]} />));
|
|
115
|
+
act(() => root.render(<Chat session="a" items={['Session A message']} />));
|
|
116
|
+
expect(container.textContent).toContain('Session A message');
|
|
117
|
+
|
|
118
|
+
act(() => root.render(<Chat session="b" items={[]} />));
|
|
119
|
+
act(() => root.render(<Chat session="b" items={['Session B message']} />));
|
|
120
|
+
expect(container.textContent).toContain('Session B message');
|
|
121
|
+
|
|
122
|
+
act(() => root.render(<Chat session="a" items={['Session A message']} />));
|
|
123
|
+
expect(container.textContent).toContain('Session A message');
|
|
124
|
+
expect(container.textContent).not.toContain('Session B message');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
4
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
5
|
+
import { useStickToBottomContext } from "use-stick-to-bottom";
|
|
6
|
+
|
|
7
|
+
import { cn } from "../../lib/utils";
|
|
8
|
+
|
|
9
|
+
export interface ConversationVirtualListProps<T> {
|
|
10
|
+
items: T[];
|
|
11
|
+
getItemKey: (item: T, index: number) => string;
|
|
12
|
+
/** Return null for an item that renders nothing; it then takes no space. */
|
|
13
|
+
renderItem: (item: T, index: number) => ReactNode;
|
|
14
|
+
/** Called on a scroll within `startThreshold` of the top, including programmatic scrolls. */
|
|
15
|
+
onReachStart?: () => void;
|
|
16
|
+
startThreshold?: number;
|
|
17
|
+
estimateSize?: number;
|
|
18
|
+
/** Reveal the initial transcript only after its last row is positioned. */
|
|
19
|
+
initialScrollToEnd?: boolean;
|
|
20
|
+
overscan?: number;
|
|
21
|
+
/** Class for the wrapper around each rendered item, for example a bottom gap. */
|
|
22
|
+
rowClassName?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Windowed rows inside a `Conversation`. Only rows near the viewport are in
|
|
28
|
+
* the DOM, so node count stays bounded for any thread length. The scroll
|
|
29
|
+
* element and stick-to-bottom behavior stay with `StickToBottom`.
|
|
30
|
+
*
|
|
31
|
+
* Items prepended to `items` (older history) keep the viewport anchored:
|
|
32
|
+
* the scroll offset moves by the height the new rows added above it.
|
|
33
|
+
*/
|
|
34
|
+
export function ConversationVirtualList<T>({
|
|
35
|
+
items,
|
|
36
|
+
getItemKey,
|
|
37
|
+
renderItem,
|
|
38
|
+
onReachStart,
|
|
39
|
+
startThreshold = 240,
|
|
40
|
+
estimateSize = 96,
|
|
41
|
+
initialScrollToEnd = false,
|
|
42
|
+
overscan = 6,
|
|
43
|
+
rowClassName,
|
|
44
|
+
className,
|
|
45
|
+
}: ConversationVirtualListProps<T>) {
|
|
46
|
+
const { scrollRef, state: scrollState } = useStickToBottomContext();
|
|
47
|
+
const [initialPositioned, setInitialPositioned] = useState(!initialScrollToEnd);
|
|
48
|
+
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null);
|
|
49
|
+
|
|
50
|
+
// The parent scroll ref attaches after child layout effects. Read it after
|
|
51
|
+
// commit so a cached transcript also gets a render with a viewport.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setScrollElement(scrollRef.current);
|
|
54
|
+
}, [scrollRef]);
|
|
55
|
+
|
|
56
|
+
const virtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
|
|
57
|
+
count: items.length,
|
|
58
|
+
getScrollElement: () => scrollElement,
|
|
59
|
+
estimateSize: () => estimateSize,
|
|
60
|
+
overscan,
|
|
61
|
+
getItemKey: (index) => getItemKey(items[index], index),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Live output may never reach a stable height. Keep the reveal deadline
|
|
65
|
+
// independent of item updates so streaming cannot restart the wait.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (initialPositioned) return;
|
|
68
|
+
const timeout = setTimeout(() => setInitialPositioned(true), 250);
|
|
69
|
+
return () => clearTimeout(timeout);
|
|
70
|
+
}, [initialPositioned]);
|
|
71
|
+
|
|
72
|
+
// Virtual rows replace estimated heights over several frames. Position them
|
|
73
|
+
// while hidden so opening history does not display those intermediate jumps.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (initialPositioned || !scrollElement || items.length === 0) return;
|
|
76
|
+
let frame: number;
|
|
77
|
+
let previousHeight = -1;
|
|
78
|
+
let stableFrames = 0;
|
|
79
|
+
const position = () => {
|
|
80
|
+
const height = scrollElement.scrollHeight;
|
|
81
|
+
const target = Math.max(0, scrollState.calculatedTargetScrollTop);
|
|
82
|
+
const lastRow = virtualizer.getVirtualItems().at(-1);
|
|
83
|
+
const settled = lastRow?.index === items.length - 1
|
|
84
|
+
&& Math.abs(scrollElement.scrollTop - target) <= 1
|
|
85
|
+
&& height === previousHeight;
|
|
86
|
+
stableFrames = settled ? stableFrames + 1 : 0;
|
|
87
|
+
previousHeight = height;
|
|
88
|
+
scrollState.scrollTop = target;
|
|
89
|
+
if (stableFrames >= 2) {
|
|
90
|
+
setInitialPositioned(true);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
frame = requestAnimationFrame(position);
|
|
94
|
+
};
|
|
95
|
+
frame = requestAnimationFrame(position);
|
|
96
|
+
return () => cancelAnimationFrame(frame);
|
|
97
|
+
}, [initialPositioned, items.length, scrollElement, scrollState, virtualizer]);
|
|
98
|
+
|
|
99
|
+
// Prepend anchoring: remember where the previous first item went, and shift
|
|
100
|
+
// the scroll offset by the height that now sits above it.
|
|
101
|
+
const firstKeyRef = useRef<string | null>(null);
|
|
102
|
+
const totalSizeRef = useRef(0);
|
|
103
|
+
const firstKey = items.length > 0 ? getItemKey(items[0], 0) : null;
|
|
104
|
+
|
|
105
|
+
useLayoutEffect(() => {
|
|
106
|
+
const previousFirstKey = firstKeyRef.current;
|
|
107
|
+
const previousTotal = totalSizeRef.current;
|
|
108
|
+
firstKeyRef.current = firstKey;
|
|
109
|
+
totalSizeRef.current = virtualizer.getTotalSize();
|
|
110
|
+
|
|
111
|
+
if (previousFirstKey === null || previousFirstKey === firstKey) return;
|
|
112
|
+
const previousFirstIndex = items.findIndex((item, index) => getItemKey(item, index) === previousFirstKey);
|
|
113
|
+
if (previousFirstIndex <= 0) return;
|
|
114
|
+
|
|
115
|
+
const scrollElement = scrollRef.current;
|
|
116
|
+
if (!scrollElement) return;
|
|
117
|
+
scrollElement.scrollTop += totalSizeRef.current - previousTotal;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Native scroll events include programmatic positioning and prepend anchoring.
|
|
121
|
+
// The caller must guard duplicate or in-flight history requests.
|
|
122
|
+
const onReachStartRef = useRef(onReachStart);
|
|
123
|
+
useLayoutEffect(() => {
|
|
124
|
+
onReachStartRef.current = onReachStart;
|
|
125
|
+
}, [onReachStart]);
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const scrollElement = scrollRef.current;
|
|
128
|
+
if (!scrollElement) return;
|
|
129
|
+
const handleScroll = () => {
|
|
130
|
+
if (scrollElement.scrollTop <= startThreshold) onReachStartRef.current?.();
|
|
131
|
+
};
|
|
132
|
+
scrollElement.addEventListener("scroll", handleScroll, { passive: true });
|
|
133
|
+
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
|
134
|
+
}, [scrollRef, startThreshold]);
|
|
135
|
+
|
|
136
|
+
const measureRow = useCallback(
|
|
137
|
+
(node: HTMLDivElement | null) => virtualizer.measureElement(node),
|
|
138
|
+
[virtualizer],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={cn("relative w-full", className)}
|
|
144
|
+
style={{ height: virtualizer.getTotalSize(), visibility: initialPositioned ? undefined : "hidden" }}
|
|
145
|
+
>
|
|
146
|
+
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
147
|
+
const content = renderItem(items[virtualRow.index], virtualRow.index);
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
key={virtualRow.key}
|
|
151
|
+
data-index={virtualRow.index}
|
|
152
|
+
ref={measureRow}
|
|
153
|
+
className="absolute top-0 left-0 w-full"
|
|
154
|
+
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
|
155
|
+
>
|
|
156
|
+
{content === null ? null : <div className={rowClassName}>{content}</div>}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* A model picker shaped as a settings field.
|
|
3
|
+
*
|
|
4
|
+
* The trigger is a full-width box that names the current model. The list
|
|
5
|
+
* itself is `ModelPickerBody`, so a composer can show the same list under
|
|
6
|
+
* a compact chip instead. Thinking is a separate control here: a settings
|
|
7
|
+
* card keeps it visible beside the model rather than behind a click.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
11
|
+
import { ChevronDown, Sparkles, X } from 'lucide-react';
|
|
3
12
|
import {
|
|
4
|
-
filterModelGroups,
|
|
5
13
|
findGroup,
|
|
6
14
|
findModel,
|
|
7
|
-
modelKey,
|
|
8
15
|
parseModelKey,
|
|
9
16
|
type SharedAvailableModelGroup,
|
|
10
17
|
type SharedModelInfo,
|
|
11
18
|
} from '@sero-ai/common';
|
|
12
19
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
13
|
-
import {
|
|
20
|
+
import { ModelPickerBody, ProviderLogo } from './model-picker-body';
|
|
14
21
|
import { cn } from '../../lib/utils';
|
|
15
22
|
|
|
16
23
|
interface AvailableModelPickerProps<
|
|
@@ -45,8 +52,6 @@ export function AvailableModelPicker<
|
|
|
45
52
|
className,
|
|
46
53
|
}: AvailableModelPickerProps<TModel, TGroup>) {
|
|
47
54
|
const [open, setOpen] = useState(false);
|
|
48
|
-
const [query, setQuery] = useState('');
|
|
49
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
50
55
|
|
|
51
56
|
const selected = useMemo(() => {
|
|
52
57
|
const parsed = parseModelKey(value);
|
|
@@ -65,38 +70,19 @@ export function AvailableModelPicker<
|
|
|
65
70
|
};
|
|
66
71
|
}, [groups, value]);
|
|
67
72
|
|
|
68
|
-
const
|
|
69
|
-
()
|
|
70
|
-
[groups, query],
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const totalResults = useMemo(
|
|
74
|
-
() => filteredGroups.reduce((count, group) => count + group.models.length, 0),
|
|
75
|
-
[filteredGroups],
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
|
79
|
-
setOpen(nextOpen);
|
|
80
|
-
if (!nextOpen) return;
|
|
81
|
-
setQuery('');
|
|
82
|
-
requestAnimationFrame(() => inputRef.current?.focus());
|
|
83
|
-
}, []);
|
|
84
|
-
|
|
85
|
-
const handleSelect = useCallback((provider: string, modelId: string) => {
|
|
86
|
-
onChange(modelKey(provider, modelId));
|
|
73
|
+
const handleSelect = useCallback((nextValue: string) => {
|
|
74
|
+
onChange(nextValue);
|
|
87
75
|
setOpen(false);
|
|
88
|
-
setQuery('');
|
|
89
76
|
}, [onChange]);
|
|
90
77
|
|
|
91
78
|
const handleClear = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
|
92
79
|
event.stopPropagation();
|
|
93
80
|
onChange('');
|
|
94
81
|
setOpen(false);
|
|
95
|
-
setQuery('');
|
|
96
82
|
}, [onChange]);
|
|
97
83
|
|
|
98
84
|
return (
|
|
99
|
-
<Popover open={open} onOpenChange={
|
|
85
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
100
86
|
<PopoverTrigger asChild disabled={disabled}>
|
|
101
87
|
<div
|
|
102
88
|
role="button"
|
|
@@ -110,10 +96,10 @@ export function AvailableModelPicker<
|
|
|
110
96
|
>
|
|
111
97
|
{selected.group && selected.model ? (
|
|
112
98
|
<>
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
className="size-4
|
|
99
|
+
<ProviderLogo
|
|
100
|
+
logo={selected.group.logo}
|
|
101
|
+
displayName={selected.group.displayName}
|
|
102
|
+
className="size-4"
|
|
117
103
|
/>
|
|
118
104
|
<div className="min-w-0 flex-1">
|
|
119
105
|
<div className="flex items-center gap-1.5">
|
|
@@ -159,72 +145,14 @@ export function AvailableModelPicker<
|
|
|
159
145
|
className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl border-border/60 bg-background p-0 shadow-xl"
|
|
160
146
|
onWheel={(event) => event.stopPropagation()}
|
|
161
147
|
>
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
value={
|
|
165
|
-
onChange={
|
|
166
|
-
|
|
167
|
-
|
|
148
|
+
<ModelPickerBody
|
|
149
|
+
groups={groups}
|
|
150
|
+
value={value}
|
|
151
|
+
onChange={handleSelect}
|
|
152
|
+
searchPlaceholder={searchPlaceholder}
|
|
153
|
+
emptyLabel={emptyLabel}
|
|
154
|
+
noModelsLabel={noModelsLabel}
|
|
168
155
|
/>
|
|
169
|
-
|
|
170
|
-
<div className="max-h-[280px] overflow-y-auto py-1">
|
|
171
|
-
{groups.length === 0 ? (
|
|
172
|
-
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
173
|
-
{noModelsLabel}
|
|
174
|
-
</div>
|
|
175
|
-
) : totalResults === 0 ? (
|
|
176
|
-
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
|
177
|
-
{emptyLabel}
|
|
178
|
-
</div>
|
|
179
|
-
) : (
|
|
180
|
-
filteredGroups.map((group, index) => (
|
|
181
|
-
<div key={group.provider}>
|
|
182
|
-
{index > 0 ? <div className="mx-3 border-t border-border/30" /> : null}
|
|
183
|
-
<div className="flex items-center gap-2 px-3 pb-1 pt-2">
|
|
184
|
-
<img
|
|
185
|
-
src={group.logo}
|
|
186
|
-
alt={group.displayName}
|
|
187
|
-
className="size-3.5 shrink-0 rounded-sm dark:invert"
|
|
188
|
-
/>
|
|
189
|
-
<span className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
|
190
|
-
{group.displayName}
|
|
191
|
-
</span>
|
|
192
|
-
</div>
|
|
193
|
-
<div className="px-1">
|
|
194
|
-
{group.models.map((model) => {
|
|
195
|
-
const nextValue = modelKey(model.provider, model.modelId);
|
|
196
|
-
const isSelected = nextValue === value;
|
|
197
|
-
return (
|
|
198
|
-
<button
|
|
199
|
-
key={nextValue}
|
|
200
|
-
type="button"
|
|
201
|
-
onClick={() => handleSelect(model.provider, model.modelId)}
|
|
202
|
-
className={cn(
|
|
203
|
-
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm transition-colors',
|
|
204
|
-
isSelected
|
|
205
|
-
? 'bg-secondary text-foreground'
|
|
206
|
-
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
|
|
207
|
-
)}
|
|
208
|
-
>
|
|
209
|
-
<div className="flex size-4 shrink-0 items-center justify-center">
|
|
210
|
-
{isSelected ? (
|
|
211
|
-
<Check className="size-3.5 text-emerald-500" />
|
|
212
|
-
) : (
|
|
213
|
-
<div className="size-1.5 rounded-full bg-border" />
|
|
214
|
-
)}
|
|
215
|
-
</div>
|
|
216
|
-
<span className="min-w-0 flex-1 truncate font-medium">{model.name}</span>
|
|
217
|
-
{model.reasoning ? (
|
|
218
|
-
<Sparkles className="size-3 shrink-0 text-amber-500/60" />
|
|
219
|
-
) : null}
|
|
220
|
-
</button>
|
|
221
|
-
);
|
|
222
|
-
})}
|
|
223
|
-
</div>
|
|
224
|
-
</div>
|
|
225
|
-
))
|
|
226
|
-
)}
|
|
227
|
-
</div>
|
|
228
156
|
</PopoverContent>
|
|
229
157
|
</Popover>
|
|
230
158
|
);
|
|
@@ -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
|
+
});
|