@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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +636 -628
  2. package/.turbo/turbo-test.log +12 -11
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +20 -2
  5. package/dist/components/ai-elements/conversation-virtual.cjs +115 -0
  6. package/dist/components/ai-elements/conversation-virtual.d.cts +30 -0
  7. package/dist/components/ai-elements/conversation-virtual.d.ts +30 -0
  8. package/dist/components/ai-elements/conversation-virtual.js +115 -0
  9. package/dist/components/model-selection/available-model-picker.cjs +21 -77
  10. package/dist/components/model-selection/available-model-picker.js +22 -78
  11. package/dist/components/model-selection/model-picker-body.cjs +106 -0
  12. package/dist/components/model-selection/model-picker-body.d.cts +41 -0
  13. package/dist/components/model-selection/model-picker-body.d.ts +41 -0
  14. package/dist/components/model-selection/model-picker-body.js +106 -0
  15. package/dist/components/model-selection/thinking-level-picker.cjs +7 -3
  16. package/dist/components/model-selection/thinking-level-picker.js +7 -3
  17. package/dist/components/model-selection/thinking-picker.cjs +1 -1
  18. package/dist/components/model-selection/thinking-picker.js +1 -1
  19. package/dist/components/ui/calendar.cjs +1 -1
  20. package/dist/components/ui/calendar.js +1 -1
  21. package/dist/components/ui/combobox.cjs +1 -1
  22. package/dist/components/ui/combobox.js +1 -1
  23. package/dist/components/ui/form.d.cts +2 -2
  24. package/dist/components/ui/form.d.ts +2 -2
  25. package/dist/components/ui/input-group.cjs +1 -1
  26. package/dist/components/ui/input-group.js +1 -1
  27. package/dist/components/ui/input-otp.cjs +1 -1
  28. package/dist/components/ui/input-otp.js +1 -1
  29. package/dist/components/ui/input.cjs +3 -3
  30. package/dist/components/ui/input.js +3 -3
  31. package/dist/components/ui/native-select.cjs +2 -2
  32. package/dist/components/ui/native-select.js +2 -2
  33. package/dist/components/ui/select.cjs +1 -1
  34. package/dist/components/ui/select.js +1 -1
  35. package/dist/components/ui/slider.cjs +6 -0
  36. package/dist/components/ui/slider.d.cts +1 -1
  37. package/dist/components/ui/slider.d.ts +1 -1
  38. package/dist/components/ui/slider.js +6 -0
  39. package/dist/components/ui/spinner.d.cts +2 -1
  40. package/dist/components/ui/spinner.d.ts +2 -1
  41. package/dist/components/ui/textarea.cjs +1 -1
  42. package/dist/components/ui/textarea.js +1 -1
  43. package/dist/index.cjs +0 -55
  44. package/dist/index.d.cts +2 -66
  45. package/dist/index.d.ts +2 -66
  46. package/dist/index.js +0 -55
  47. package/dist/reference.cjs +56 -83
  48. package/dist/reference.js +56 -83
  49. package/dist/styles/ai-elements.css +2 -0
  50. package/dist/styles/context-editor.css +2 -0
  51. package/dist/styles/globals.css +32 -0
  52. package/dist/styles/model-selection.css +2 -0
  53. package/dist/styles/plugin.css +4 -1
  54. package/dist/theme/apply-theme.cjs +65 -12
  55. package/dist/theme/apply-theme.js +55 -2
  56. package/dist/theme/index.d.cts +1 -1
  57. package/dist/theme/index.d.ts +1 -1
  58. package/dist/theme/types.cjs +15 -2
  59. package/dist/theme/types.d.cts +21 -1
  60. package/dist/theme/types.d.ts +21 -1
  61. package/dist/theme/types.js +14 -1
  62. package/package.json +44 -30
  63. package/scripts/smoke-dist.mjs +106 -4
  64. package/src/components/ai-elements/conversation-virtual.test.tsx +126 -0
  65. package/src/components/ai-elements/conversation-virtual.tsx +162 -0
  66. package/src/components/model-selection/available-model-picker.tsx +26 -98
  67. package/src/components/model-selection/model-picker-body.test.tsx +152 -0
  68. package/src/components/model-selection/model-picker-body.tsx +188 -0
  69. package/src/components/model-selection/thinking-level-picker.tsx +9 -3
  70. package/src/components/model-selection/thinking-picker.test.tsx +41 -0
  71. package/src/components/model-selection/thinking-picker.tsx +1 -1
  72. package/src/components/ui/calendar.test.tsx +43 -0
  73. package/src/components/ui/calendar.tsx +1 -1
  74. package/src/components/ui/combobox.tsx +1 -1
  75. package/src/components/ui/input-group.tsx +1 -1
  76. package/src/components/ui/input-otp.tsx +1 -1
  77. package/src/components/ui/input.tsx +3 -3
  78. package/src/components/ui/native-select.tsx +2 -2
  79. package/src/components/ui/select.tsx +1 -1
  80. package/src/components/ui/slider.tsx +6 -0
  81. package/src/components/ui/spinner.tsx +1 -1
  82. package/src/components/ui/textarea.tsx +1 -1
  83. package/src/index.ts +4 -63
  84. package/src/styles/ai-elements.css +2 -0
  85. package/src/styles/context-editor.css +2 -0
  86. package/src/styles/globals.css +32 -0
  87. package/src/styles/model-selection.css +2 -0
  88. package/src/styles/plugin.css +4 -1
  89. package/src/theme/apply-theme.test.ts +126 -0
  90. package/src/theme/apply-theme.ts +66 -3
  91. package/src/theme/types.ts +33 -0
@@ -7,13 +7,14 @@
7
7
  // any entrypoint no longer resolves. Run after `pnpm build`.
8
8
 
9
9
  import { createRequire } from "node:module";
10
- import { existsSync, readFileSync } from "node:fs";
11
- import { fileURLToPath } from "node:url";
10
+ import { existsSync, readFileSync, statSync } from "node:fs";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname, resolve } from "node:path";
13
13
 
14
14
  const require = createRequire(import.meta.url);
15
15
  const here = dirname(fileURLToPath(import.meta.url));
16
16
  const dist = resolve(here, "..", "dist");
17
+ const packageRoot = resolve(here, "..");
17
18
 
18
19
  const fail = (msg) => {
19
20
  console.error(`✗ ${msg}`);
@@ -25,6 +26,105 @@ if (!existsSync(dist)) {
25
26
  process.exit(1);
26
27
  }
27
28
 
29
+ // The lightweight root must not re-export specialized dependency graphs.
30
+ const rootEntries = ["index.js", "index.cjs", "index.d.ts", "index.d.cts"];
31
+ const forbiddenRootImports = [
32
+ "@sero-ai/ui/ai-elements",
33
+ "@sero-ai/ui/model-selection",
34
+ "@sero-ai/ui/components/context-editor",
35
+ "components/ai-elements",
36
+ "components/model-selection",
37
+ "components/context-editor",
38
+ "mermaid",
39
+ "shiki",
40
+ "react-jsx-parser",
41
+ "streamdown",
42
+ "unified",
43
+ ];
44
+
45
+ const importPatterns = [
46
+ /(?:from\s+|import\s*)["'](\.[^"']+)["']/g,
47
+ /(?:require|import)\(\s*["'](\.[^"']+)["']\s*\)/g,
48
+ ];
49
+
50
+ function localImports(contents) {
51
+ return importPatterns.flatMap((pattern) =>
52
+ [...contents.matchAll(pattern)].map((match) => match[1]),
53
+ );
54
+ }
55
+
56
+ function emittedExtension(file) {
57
+ if (file.endsWith(".d.cts")) return ".d.cts";
58
+ if (file.endsWith(".d.ts")) return ".d.ts";
59
+ if (file.endsWith(".cjs")) return ".cjs";
60
+ return ".js";
61
+ }
62
+
63
+ function resolveLocalImport(fromFile, specifier) {
64
+ const base = resolve(dirname(fromFile), specifier);
65
+ const extension = emittedExtension(fromFile);
66
+ const candidates = [];
67
+
68
+ if (extension.startsWith(".d.") && specifier.endsWith(".js")) {
69
+ candidates.push(base.replace(/\.js$/, extension));
70
+ }
71
+ candidates.push(base, `${base}${extension}`, resolve(base, `index${extension}`));
72
+
73
+ return candidates.find(
74
+ (candidate) => existsSync(candidate) && statSync(candidate).isFile(),
75
+ );
76
+ }
77
+
78
+ function inspectRootGraph(entry) {
79
+ const pending = [resolve(dist, entry)];
80
+ const visited = new Set();
81
+
82
+ while (pending.length > 0) {
83
+ const file = pending.pop();
84
+ if (visited.has(file)) continue;
85
+ visited.add(file);
86
+
87
+ const contents = readFileSync(file, "utf8");
88
+ const relativeFile = file.slice(dist.length + 1);
89
+ const leak = forbiddenRootImports.find(
90
+ (dependency) =>
91
+ relativeFile.includes(dependency) || contents.includes(dependency),
92
+ );
93
+ if (leak) fail(`${relativeFile} exposes unrelated dependency ${leak}`);
94
+
95
+ for (const specifier of localImports(contents)) {
96
+ const dependency = resolveLocalImport(file, specifier);
97
+ if (dependency) pending.push(dependency);
98
+ else fail(`${relativeFile} has unresolved local import ${specifier}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ for (const file of rootEntries) {
104
+ inspectRootGraph(file);
105
+ }
106
+
107
+ // Specialized components remain available through stable public subpaths.
108
+ const packageJson = JSON.parse(
109
+ readFileSync(resolve(packageRoot, "package.json"), "utf8"),
110
+ );
111
+ for (const [subpath, output] of [
112
+ ["./ai-elements/*", "components/ai-elements/message.js"],
113
+ ["./model-selection/*", "components/model-selection/available-model-picker.js"],
114
+ ]) {
115
+ if (!packageJson.publishConfig.exports[subpath]) {
116
+ fail(`publishConfig is missing ${subpath}`);
117
+ }
118
+ if (!existsSync(resolve(dist, output))) fail(`${output} not emitted to dist`);
119
+ }
120
+ for (const style of [
121
+ "styles/ai-elements.css",
122
+ "styles/model-selection.css",
123
+ "styles/context-editor.css",
124
+ ]) {
125
+ if (!existsSync(resolve(dist, style))) fail(`${style} not emitted to dist`);
126
+ }
127
+
28
128
  // 1. Reference entrypoint resolves as ESM and exposes the example widgets.
29
129
  const EXPECTED = [
30
130
  "StarterExample",
@@ -33,7 +133,7 @@ const EXPECTED = [
33
133
  "ActivityExample",
34
134
  ];
35
135
 
36
- const esm = await import(resolve(dist, "reference.js"));
136
+ const esm = await import(pathToFileURL(resolve(dist, "reference.js")).href);
37
137
  for (const name of EXPECTED) {
38
138
  if (typeof esm[name] !== "function") fail(`reference.js missing export ${name}`);
39
139
  }
@@ -64,5 +164,7 @@ else {
64
164
  if (process.exitCode) {
65
165
  console.error("Smoke test failed.");
66
166
  } else {
67
- console.log("✓ dist entrypoints resolve (reference esm+cjs, catalog json)");
167
+ console.log(
168
+ "✓ dist entrypoints resolve and the root excludes specialized dependencies",
169
+ );
68
170
  }
@@ -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
- import { useCallback, useMemo, useRef, useState } from 'react';
2
- import { Check, ChevronDown, Sparkles, X } from 'lucide-react';
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 { SearchInput } from '../ui/search-input';
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 filteredGroups = useMemo(
69
- () => filterModelGroups(groups, query),
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={handleOpenChange}>
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
- <img
114
- src={selected.group.logo}
115
- alt={selected.group.displayName}
116
- className="size-4 shrink-0 rounded-sm dark:invert"
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
- <SearchInput
163
- ref={inputRef}
164
- value={query}
165
- onChange={(event) => setQuery(event.target.value)}
166
- placeholder={searchPlaceholder}
167
- containerClassName="border-b border-border/40"
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
  );